coeftable 0.2.1__tar.gz → 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.
- {coeftable-0.2.1 → coeftable-0.3.0}/.github/workflows/publish.yml +8 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/CHANGELOG.md +19 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/PKG-INFO +46 -16
- {coeftable-0.2.1 → coeftable-0.3.0}/README.md +45 -15
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/_version.py +2 -2
- coeftable-0.3.0/src/coeftable/collapsible.py +166 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/frame.py +36 -1
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/grid.py +36 -4
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/render.py +13 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/series.py +84 -18
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/spec.py +381 -70
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/svg.py +311 -78
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/theme.py +83 -5
- coeftable-0.3.0/tests/test_collapsible.py +260 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_frame.py +53 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_public_api.py +21 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_render.py +55 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_series.py +79 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_sparkline.py +562 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_svg.py +398 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_theme.py +42 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/.github/release.yml +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/.github/workflows/ci.yml +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/.github/workflows/post-release.yml +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/.github/workflows/release.yml +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/.gitignore +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/.pre-commit-config.yaml +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/LICENSE +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/Makefile +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/docs/images/example.png +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/docs/images/trend-example.png +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/noxfile.py +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/pyproject.toml +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/__init__.py +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/format.py +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_format.py +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_package.py +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_spec.py +0 -0
- {coeftable-0.2.1 → coeftable-0.3.0}/uv.lock +0 -0
|
@@ -40,6 +40,14 @@ jobs:
|
|
|
40
40
|
|
|
41
41
|
publish:
|
|
42
42
|
runs-on: ubuntu-latest
|
|
43
|
+
# PyPI must have a trusted publisher registered for BOTH entry points,
|
|
44
|
+
# each with environment "publish": this workflow, for a manual run or a
|
|
45
|
+
# hand-pushed tag, and release.yml, for an automated release. Upload
|
|
46
|
+
# authentication matches on the reusable workflow, but the build
|
|
47
|
+
# provenance attestation carries the TOP-LEVEL workflow, so an automated
|
|
48
|
+
# release presents release.yml and is rejected with "does not match
|
|
49
|
+
# expected Trusted Publisher" unless that entry point is registered too.
|
|
50
|
+
#
|
|
43
51
|
# A tag pushed by a workflow using the default token does not raise a
|
|
44
52
|
# push event, so a release cut by automation asks for the upload
|
|
45
53
|
# explicitly. Any other run sitting on a tag -- a hand-pushed tag, or a
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
<a id="v0.2.1"></a>
|
|
2
|
+
# [v0.2.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.2.1) - 2026-08-05
|
|
3
|
+
|
|
4
|
+
<!-- Release notes generated using configuration in .github/release.yml at v0.2.1 -->
|
|
5
|
+
|
|
6
|
+
## What's Changed
|
|
7
|
+
### Maintenance
|
|
8
|
+
* Update changelog for v0.2.0 by [@github-actions](https://github.com/github-actions)[bot] in [#11](https://github.com/kylejcaron/coeftable/pull/11)
|
|
9
|
+
* Publish releases to PyPI automatically by [@kylejcaron](https://github.com/kylejcaron) in [#12](https://github.com/kylejcaron/coeftable/pull/12)
|
|
10
|
+
|
|
11
|
+
## New Contributors
|
|
12
|
+
* [@github-actions](https://github.com/github-actions)[bot] made their first contribution in [#11](https://github.com/kylejcaron/coeftable/pull/11)
|
|
13
|
+
|
|
14
|
+
**Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
|
|
15
|
+
|
|
16
|
+
[Changes][v0.2.1]
|
|
17
|
+
|
|
18
|
+
|
|
1
19
|
<a id="v0.2.0"></a>
|
|
2
20
|
# [v0.2.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.2.0) - 2026-08-04
|
|
3
21
|
|
|
@@ -36,6 +54,7 @@
|
|
|
36
54
|
[Changes][v0.1.0]
|
|
37
55
|
|
|
38
56
|
|
|
57
|
+
[v0.2.1]: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
|
|
39
58
|
[v0.2.0]: https://github.com/kylejcaron/coeftable/compare/v0.1.0...v0.2.0
|
|
40
59
|
[v0.1.0]: https://github.com/kylejcaron/coeftable/tree/v0.1.0
|
|
41
60
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coeftable
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Publication-quality summary tables for estimates with uncertainty.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kylejcaron/coeftable
|
|
6
6
|
Project-URL: Repository, https://github.com/kylejcaron/coeftable
|
|
@@ -87,10 +87,11 @@ ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub"))
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
A `CoefTable` renders itself in marimo, Jupyter, and any other `_repr_html_`-aware viewer —
|
|
90
|
-
leave it as the last expression in a cell, no extra call needed. Outside a notebook,
|
|
91
|
-
`.gt()`
|
|
92
|
-
|
|
93
|
-
image, `table.gt().tab_options(...)` to keep styling with
|
|
90
|
+
leave it as the last expression in a cell, no extra call needed. Outside a notebook,
|
|
91
|
+
`table.as_raw_html()` is the HTML string entry point. `.gt()` remains the escape hatch to the
|
|
92
|
+
underlying [great_tables](https://posit-dev.github.io/great-tables/) object itself:
|
|
93
|
+
`table.gt().save("t.png")` for an image, `table.gt().tab_options(...)` to keep styling with
|
|
94
|
+
great_tables' own API.
|
|
94
95
|
|
|
95
96
|
## Data shape
|
|
96
97
|
|
|
@@ -103,7 +104,12 @@ a `parameter` column.
|
|
|
103
104
|
**Dimensions:**
|
|
104
105
|
- `rows` — the label for each row in the table (e.g. a metric name).
|
|
105
106
|
- `nest` — an optional secondary label stacked below each row.
|
|
106
|
-
- `groups` — an optional column whose values produce section headers.
|
|
107
|
+
- `groups` — an optional column whose values produce section headers. Pass
|
|
108
|
+
`collapsible_groups=True` to make those sections collapsible in the rendered
|
|
109
|
+
HTML — a pure CSS toggle (relies on `:has()`, Baseline since late 2023; on an
|
|
110
|
+
older browser without it the toggle no-ops and sections stay expanded), no
|
|
111
|
+
JavaScript, sections start expanded. Applies to `as_raw_html()`/`_repr_html_`;
|
|
112
|
+
`.gt()` is unaffected.
|
|
107
113
|
- `split_columns` — an optional column whose values produce repeated column
|
|
108
114
|
groups side by side, useful for comparing methods.
|
|
109
115
|
|
|
@@ -172,8 +178,7 @@ methods = pl.DataFrame(
|
|
|
172
178
|
(
|
|
173
179
|
ct.CoefTable(
|
|
174
180
|
methods, rows="metric", split_columns="method", estimate="est", ci=("lb", "ub")
|
|
175
|
-
)
|
|
176
|
-
.header("Cohort Revenue by Method")
|
|
181
|
+
).header("Cohort Revenue by Method")
|
|
177
182
|
)
|
|
178
183
|
```
|
|
179
184
|
|
|
@@ -184,11 +189,11 @@ Four built-in themes are available from `coeftable.theme`:
|
|
|
184
189
|
```python
|
|
185
190
|
from coeftable.theme import BLUE, COLORBLIND, DEFAULT, MONO, TEXTUAL
|
|
186
191
|
|
|
187
|
-
DEFAULT
|
|
188
|
-
TEXTUAL
|
|
189
|
-
BLUE
|
|
190
|
-
COLORBLIND
|
|
191
|
-
MONO
|
|
192
|
+
DEFAULT # Alias for TEXTUAL -- what CoefTable uses if you don't set a theme
|
|
193
|
+
TEXTUAL # Minimal, publication-style: muted colours, light chrome
|
|
194
|
+
BLUE # The original blue-grey palette
|
|
195
|
+
COLORBLIND # Colourblind-safe palette
|
|
196
|
+
MONO # Grayscale for mono journals
|
|
192
197
|
```
|
|
193
198
|
|
|
194
199
|
Apply one with `.with_theme(...)`:
|
|
@@ -209,9 +214,8 @@ Use `with_direction` to mark rows where lower values are favourable (reusing
|
|
|
209
214
|
the `df` frame from [Quick start](#quick-start)):
|
|
210
215
|
|
|
211
216
|
```python
|
|
212
|
-
table = (
|
|
213
|
-
|
|
214
|
-
.with_direction({"Latency": "lower_is_better"})
|
|
217
|
+
table = ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub")).with_direction(
|
|
218
|
+
{"Latency": "lower_is_better"}
|
|
215
219
|
)
|
|
216
220
|
```
|
|
217
221
|
|
|
@@ -373,3 +377,29 @@ trend = pl.DataFrame(
|
|
|
373
377
|
.sparkline("Override", value="lift", ref=1.0, ylim=(0.9, 1.1))
|
|
374
378
|
)
|
|
375
379
|
```
|
|
380
|
+
|
|
381
|
+
**No reference, or a hidden one.** `ref` also drives the dashed line and
|
|
382
|
+
colour resolution; two ways to opt out, for data with no meaningful
|
|
383
|
+
zero (revenue, durations, absolute counts):
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
absolute = pl.DataFrame(
|
|
387
|
+
{
|
|
388
|
+
"metric": ["Revenue", "Latency"],
|
|
389
|
+
"value": [[282.3, 300.1, 320.0], [900.0, 910.0, 920.0]],
|
|
390
|
+
}
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
(
|
|
394
|
+
ct.CoefTable(absolute, rows="metric")
|
|
395
|
+
# ref=None: no reference at all. No dashed line, no forced domain
|
|
396
|
+
# inclusion of 0, and every cell colours neutral -- "favorable" has
|
|
397
|
+
# no meaning without something to compare against.
|
|
398
|
+
.sparkline("No reference", value="value", ref=None)
|
|
399
|
+
# ref=0.0, show_ref=False: the reference is real and still drives
|
|
400
|
+
# colour (favorable/unfavorable against 0), it just isn't drawn or
|
|
401
|
+
# forced into the domain. Opt in deliberately: a mark can then claim
|
|
402
|
+
# "above the reference" while the reference sits off-canvas.
|
|
403
|
+
.sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
|
|
404
|
+
)
|
|
405
|
+
```
|
|
@@ -33,10 +33,11 @@ ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub"))
|
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
A `CoefTable` renders itself in marimo, Jupyter, and any other `_repr_html_`-aware viewer —
|
|
36
|
-
leave it as the last expression in a cell, no extra call needed. Outside a notebook,
|
|
37
|
-
`.gt()`
|
|
38
|
-
|
|
39
|
-
image, `table.gt().tab_options(...)` to keep styling with
|
|
36
|
+
leave it as the last expression in a cell, no extra call needed. Outside a notebook,
|
|
37
|
+
`table.as_raw_html()` is the HTML string entry point. `.gt()` remains the escape hatch to the
|
|
38
|
+
underlying [great_tables](https://posit-dev.github.io/great-tables/) object itself:
|
|
39
|
+
`table.gt().save("t.png")` for an image, `table.gt().tab_options(...)` to keep styling with
|
|
40
|
+
great_tables' own API.
|
|
40
41
|
|
|
41
42
|
## Data shape
|
|
42
43
|
|
|
@@ -49,7 +50,12 @@ a `parameter` column.
|
|
|
49
50
|
**Dimensions:**
|
|
50
51
|
- `rows` — the label for each row in the table (e.g. a metric name).
|
|
51
52
|
- `nest` — an optional secondary label stacked below each row.
|
|
52
|
-
- `groups` — an optional column whose values produce section headers.
|
|
53
|
+
- `groups` — an optional column whose values produce section headers. Pass
|
|
54
|
+
`collapsible_groups=True` to make those sections collapsible in the rendered
|
|
55
|
+
HTML — a pure CSS toggle (relies on `:has()`, Baseline since late 2023; on an
|
|
56
|
+
older browser without it the toggle no-ops and sections stay expanded), no
|
|
57
|
+
JavaScript, sections start expanded. Applies to `as_raw_html()`/`_repr_html_`;
|
|
58
|
+
`.gt()` is unaffected.
|
|
53
59
|
- `split_columns` — an optional column whose values produce repeated column
|
|
54
60
|
groups side by side, useful for comparing methods.
|
|
55
61
|
|
|
@@ -118,8 +124,7 @@ methods = pl.DataFrame(
|
|
|
118
124
|
(
|
|
119
125
|
ct.CoefTable(
|
|
120
126
|
methods, rows="metric", split_columns="method", estimate="est", ci=("lb", "ub")
|
|
121
|
-
)
|
|
122
|
-
.header("Cohort Revenue by Method")
|
|
127
|
+
).header("Cohort Revenue by Method")
|
|
123
128
|
)
|
|
124
129
|
```
|
|
125
130
|
|
|
@@ -130,11 +135,11 @@ Four built-in themes are available from `coeftable.theme`:
|
|
|
130
135
|
```python
|
|
131
136
|
from coeftable.theme import BLUE, COLORBLIND, DEFAULT, MONO, TEXTUAL
|
|
132
137
|
|
|
133
|
-
DEFAULT
|
|
134
|
-
TEXTUAL
|
|
135
|
-
BLUE
|
|
136
|
-
COLORBLIND
|
|
137
|
-
MONO
|
|
138
|
+
DEFAULT # Alias for TEXTUAL -- what CoefTable uses if you don't set a theme
|
|
139
|
+
TEXTUAL # Minimal, publication-style: muted colours, light chrome
|
|
140
|
+
BLUE # The original blue-grey palette
|
|
141
|
+
COLORBLIND # Colourblind-safe palette
|
|
142
|
+
MONO # Grayscale for mono journals
|
|
138
143
|
```
|
|
139
144
|
|
|
140
145
|
Apply one with `.with_theme(...)`:
|
|
@@ -155,9 +160,8 @@ Use `with_direction` to mark rows where lower values are favourable (reusing
|
|
|
155
160
|
the `df` frame from [Quick start](#quick-start)):
|
|
156
161
|
|
|
157
162
|
```python
|
|
158
|
-
table = (
|
|
159
|
-
|
|
160
|
-
.with_direction({"Latency": "lower_is_better"})
|
|
163
|
+
table = ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub")).with_direction(
|
|
164
|
+
{"Latency": "lower_is_better"}
|
|
161
165
|
)
|
|
162
166
|
```
|
|
163
167
|
|
|
@@ -319,3 +323,29 @@ trend = pl.DataFrame(
|
|
|
319
323
|
.sparkline("Override", value="lift", ref=1.0, ylim=(0.9, 1.1))
|
|
320
324
|
)
|
|
321
325
|
```
|
|
326
|
+
|
|
327
|
+
**No reference, or a hidden one.** `ref` also drives the dashed line and
|
|
328
|
+
colour resolution; two ways to opt out, for data with no meaningful
|
|
329
|
+
zero (revenue, durations, absolute counts):
|
|
330
|
+
|
|
331
|
+
```python
|
|
332
|
+
absolute = pl.DataFrame(
|
|
333
|
+
{
|
|
334
|
+
"metric": ["Revenue", "Latency"],
|
|
335
|
+
"value": [[282.3, 300.1, 320.0], [900.0, 910.0, 920.0]],
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
(
|
|
340
|
+
ct.CoefTable(absolute, rows="metric")
|
|
341
|
+
# ref=None: no reference at all. No dashed line, no forced domain
|
|
342
|
+
# inclusion of 0, and every cell colours neutral -- "favorable" has
|
|
343
|
+
# no meaning without something to compare against.
|
|
344
|
+
.sparkline("No reference", value="value", ref=None)
|
|
345
|
+
# ref=0.0, show_ref=False: the reference is real and still drives
|
|
346
|
+
# colour (favorable/unfavorable against 0), it just isn't drawn or
|
|
347
|
+
# forced into the domain. Opt in deliberately: a mark can then claim
|
|
348
|
+
# "above the reference" while the reference sits off-canvas.
|
|
349
|
+
.sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
|
|
350
|
+
)
|
|
351
|
+
```
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Post-process `great_tables` HTML to make row-group sections collapsible.
|
|
2
|
+
|
|
3
|
+
`make_collapsible` is a pure string transform: it knows nothing about
|
|
4
|
+
`CoefTable`. It walks the rendered `<tbody>`, tags each group heading row and
|
|
5
|
+
its member rows with `data-ct-group*` attributes, and appends a `<style>`
|
|
6
|
+
block whose `:has()` rules do the collapsing -- no JavaScript.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
SHARED_AXIS_ROW_MARK = "--ct-axis-row:1"
|
|
14
|
+
"""CSS custom property `render.py` stamps on a table-wide axis/footer row
|
|
15
|
+
(`scale in {"table", "split_column"}`) so this module can recognise it and
|
|
16
|
+
leave it untagged. A single string literal, imported by both the producer
|
|
17
|
+
and the consumer, so the two can never drift apart."""
|
|
18
|
+
|
|
19
|
+
_TBODY_OPEN_RE = re.compile(r'<tbody\s+class="gt_table_body"[^>]*>')
|
|
20
|
+
_WRAPPER_DIV_ID_RE = re.compile(r'<div\s+id="([^"]+)"')
|
|
21
|
+
_ROW_TOKEN_RE = re.compile(r"<tr(?=[\s>])|</tr>")
|
|
22
|
+
_GROUP_HEADING_TH_RE = re.compile(r'(<th\s+class="gt_group_heading"[^>]*>)(.*?)(</th>)', re.DOTALL)
|
|
23
|
+
|
|
24
|
+
_BASE_CSS = """\
|
|
25
|
+
#{uid} .ct-group-state{{
|
|
26
|
+
position:absolute;width:1px;height:1px;overflow:hidden;
|
|
27
|
+
clip-path:inset(50%);white-space:nowrap
|
|
28
|
+
}}
|
|
29
|
+
#{uid} label.ct-group-toggle{{display:block;cursor:pointer}}
|
|
30
|
+
#{uid} .ct-caret{{display:inline-block;width:1em}}
|
|
31
|
+
#{uid} .ct-group-state:focus-visible + label.ct-group-toggle{{
|
|
32
|
+
outline:2px solid currentColor;outline-offset:2px
|
|
33
|
+
}}
|
|
34
|
+
/* per group n: */
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_GROUP_CSS = (
|
|
38
|
+
'#{uid} tbody:has(#{cid}:checked) tr[data-ct-group-member="{n}"]{{display:none}}\n'
|
|
39
|
+
'#{uid} tbody:has(#{cid}:checked) label[for="{cid}"] .ct-caret{{transform:rotate(-90deg)}}\n'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _top_level_row_spans(body: str) -> list[tuple[int, int]]:
|
|
44
|
+
"""Depth-tracking scan for `<tr>...</tr>` spans at nesting depth 0.
|
|
45
|
+
|
|
46
|
+
A markdown/passthrough cell can legitimately contain a nested
|
|
47
|
+
`<table>`, so a naive `"<tr" in body` split would also tag its rows.
|
|
48
|
+
Only rows that open and close at depth 0 are real body rows.
|
|
49
|
+
"""
|
|
50
|
+
depth = 0
|
|
51
|
+
start: int | None = None
|
|
52
|
+
spans: list[tuple[int, int]] = []
|
|
53
|
+
for m in _ROW_TOKEN_RE.finditer(body):
|
|
54
|
+
if m.group(0) == "</tr>":
|
|
55
|
+
depth -= 1
|
|
56
|
+
if depth == 0 and start is not None:
|
|
57
|
+
spans.append((start, m.end()))
|
|
58
|
+
start = None
|
|
59
|
+
else:
|
|
60
|
+
if depth == 0:
|
|
61
|
+
start = m.start()
|
|
62
|
+
depth += 1
|
|
63
|
+
return spans
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _insert_tr_attr(row: str, attr: str) -> str:
|
|
67
|
+
"""Insert `attr` into the row's opening `<tr...>` tag, right after `<tr`."""
|
|
68
|
+
close = row.index(">")
|
|
69
|
+
return "<tr " + attr + row[len("<tr") : close] + row[close:]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tag_heading_row(row: str, *, uid: str, n: int) -> str:
|
|
73
|
+
cid = f"ct-{uid}-g{n}"
|
|
74
|
+
|
|
75
|
+
def _wrap_heading(match: re.Match[str]) -> str:
|
|
76
|
+
open_th, inner, close_th = match.group(1), match.group(2), match.group(3)
|
|
77
|
+
checkbox = f'<input class="ct-group-state" type="checkbox" id="{cid}">'
|
|
78
|
+
label = (
|
|
79
|
+
f'<label class="ct-group-toggle" for="{cid}">'
|
|
80
|
+
f'<span class="ct-caret" aria-hidden="true">\u25be</span>{inner}</label>'
|
|
81
|
+
)
|
|
82
|
+
return f"{open_th}{checkbox}{label}{close_th}"
|
|
83
|
+
|
|
84
|
+
row = _GROUP_HEADING_TH_RE.sub(_wrap_heading, row, count=1)
|
|
85
|
+
return _insert_tr_attr(row, f'data-ct-group="{n}"')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _tag_member_row(row: str, *, n: int) -> str:
|
|
89
|
+
return _insert_tr_attr(row, f'data-ct-group-member="{n}"')
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def make_collapsible(html: str) -> str:
|
|
93
|
+
"""Tag `great_tables` row-group headings so CSS alone can collapse them.
|
|
94
|
+
|
|
95
|
+
Locates the `<tbody class="gt_table_body">...</tbody>` slice and walks
|
|
96
|
+
its rows with a depth-tracking scan (not a naive split, which would
|
|
97
|
+
also match `<tr>` tags nested inside a markdown/passthrough cell).
|
|
98
|
+
Each group heading row (identified by its `<th class="gt_group_heading">`,
|
|
99
|
+
present whether the label is empty or not) gets a `data-ct-group="n"`
|
|
100
|
+
attribute, its heading text is wrapped in a `<label>`/`<input
|
|
101
|
+
type="checkbox">` pair, and every following row up to the next heading
|
|
102
|
+
gets `data-ct-group-member="n"` -- except a shared forest/sparkline
|
|
103
|
+
axis row (marked by `render.py` with the `SHARED_AXIS_ROW_MARK` CSS
|
|
104
|
+
custom property), which stays untagged so it never disappears into
|
|
105
|
+
whichever group happens to precede it. A `<style>` block using `:has()`
|
|
106
|
+
is appended after the wrapper `</div>` to do the actual hiding.
|
|
107
|
+
|
|
108
|
+
Returns `html` unchanged if the tbody is not found, or if no group
|
|
109
|
+
heading row (empty- or non-empty-label) is present.
|
|
110
|
+
"""
|
|
111
|
+
tbody_open = _TBODY_OPEN_RE.search(html)
|
|
112
|
+
if tbody_open is None:
|
|
113
|
+
return html
|
|
114
|
+
tbody_close = html.find("</tbody>", tbody_open.end())
|
|
115
|
+
if tbody_close == -1:
|
|
116
|
+
return html
|
|
117
|
+
|
|
118
|
+
body = html[tbody_open.end() : tbody_close]
|
|
119
|
+
spans = _top_level_row_spans(body)
|
|
120
|
+
if not any('class="gt_group_heading"' in body[s:e] for s, e in spans):
|
|
121
|
+
return html
|
|
122
|
+
|
|
123
|
+
uid_match = _WRAPPER_DIV_ID_RE.search(html)
|
|
124
|
+
if uid_match is None:
|
|
125
|
+
return html
|
|
126
|
+
uid = uid_match.group(1)
|
|
127
|
+
|
|
128
|
+
pieces: list[str] = []
|
|
129
|
+
cursor = 0
|
|
130
|
+
n = -1
|
|
131
|
+
group_ids: list[str] = []
|
|
132
|
+
for start, end in spans:
|
|
133
|
+
pieces.append(body[cursor:start])
|
|
134
|
+
row = body[start:end]
|
|
135
|
+
if 'class="gt_group_heading"' in row:
|
|
136
|
+
n += 1
|
|
137
|
+
pieces.append(_tag_heading_row(row, uid=uid, n=n))
|
|
138
|
+
group_ids.append(f"ct-{uid}-g{n}")
|
|
139
|
+
elif SHARED_AXIS_ROW_MARK in row:
|
|
140
|
+
# A forest/sparkline axis row whose domain is table-wide
|
|
141
|
+
# (scale in {"table", "split_column"}; render.py stamps only
|
|
142
|
+
# these). It is not any single group's data and must stay
|
|
143
|
+
# visible no matter which group collapses -- leave it
|
|
144
|
+
# untagged rather than folding it into whichever group
|
|
145
|
+
# happens to precede it in row order. A per-group axis row
|
|
146
|
+
# (scale="row_group"/"row") carries no marker and falls
|
|
147
|
+
# through to the ordinary member-row branch below, so it
|
|
148
|
+
# correctly collapses with its own group.
|
|
149
|
+
pieces.append(row)
|
|
150
|
+
elif n >= 0:
|
|
151
|
+
pieces.append(_tag_member_row(row, n=n))
|
|
152
|
+
else:
|
|
153
|
+
pieces.append(row)
|
|
154
|
+
cursor = end
|
|
155
|
+
pieces.append(body[cursor:])
|
|
156
|
+
new_body = "".join(pieces)
|
|
157
|
+
|
|
158
|
+
new_html = html[: tbody_open.end()] + new_body + html[tbody_close:]
|
|
159
|
+
|
|
160
|
+
style = "<style>\n" + _BASE_CSS.format(uid=uid)
|
|
161
|
+
for n, cid in enumerate(group_ids):
|
|
162
|
+
style += _GROUP_CSS.format(uid=uid, cid=cid, n=n)
|
|
163
|
+
style += "</style>"
|
|
164
|
+
|
|
165
|
+
div_close = new_html.rfind("</div>")
|
|
166
|
+
return new_html[: div_close + len("</div>")] + style + new_html[div_close + len("</div>") :]
|
|
@@ -45,6 +45,12 @@ class Resolved:
|
|
|
45
45
|
Name of the row-group column, if any.
|
|
46
46
|
band_rows, divider_rows, axis_rows
|
|
47
47
|
Zero-based row indices for banding, dividers and axis rows.
|
|
48
|
+
shared_axis_rows
|
|
49
|
+
Subset of `axis_rows` whose axis is table-wide (`scale in
|
|
50
|
+
{"table", "split_column"}`) rather than per-group or per-row-key
|
|
51
|
+
-- see `assemble_rows`. `render.py` marks only these for
|
|
52
|
+
`collapsible.py` to leave untagged; the rest stay tied to
|
|
53
|
+
whichever group they belong to.
|
|
48
54
|
markdown_columns
|
|
49
55
|
Output columns whose contents are HTML.
|
|
50
56
|
plot_columns
|
|
@@ -61,6 +67,7 @@ class Resolved:
|
|
|
61
67
|
band_rows: list[int] = field(default_factory=list)
|
|
62
68
|
divider_rows: list[int] = field(default_factory=list)
|
|
63
69
|
axis_rows: list[int] = field(default_factory=list)
|
|
70
|
+
shared_axis_rows: list[int] = field(default_factory=list)
|
|
64
71
|
markdown_columns: list[str] = field(default_factory=list)
|
|
65
72
|
plot_columns: list[str] = field(default_factory=list)
|
|
66
73
|
|
|
@@ -118,6 +125,23 @@ def resolve(table: CoefTable) -> Resolved:
|
|
|
118
125
|
f"(rows/nest/groups key {column.label!r}); choose a different label."
|
|
119
126
|
)
|
|
120
127
|
|
|
128
|
+
# The overlaid `series` dimension cannot also be a table axis: the
|
|
129
|
+
# table's row structure already spends whichever of these an
|
|
130
|
+
# identity maps to, so naming the same column both ways is
|
|
131
|
+
# ambiguous about which grouping wins.
|
|
132
|
+
axis_keys = {n for n in (table.rows, table.nest, table.groups, table.split_columns) if n}
|
|
133
|
+
for column in table.columns:
|
|
134
|
+
if (
|
|
135
|
+
isinstance(column, Sparkline)
|
|
136
|
+
and column.series is not None
|
|
137
|
+
and column.series in axis_keys
|
|
138
|
+
):
|
|
139
|
+
raise SpecError(
|
|
140
|
+
f"Sparkline column {column.label!r} series={column.series!r} collides "
|
|
141
|
+
"with a table layout key (rows/nest/groups/split_columns); the "
|
|
142
|
+
"overlaid dimension cannot also be a table axis."
|
|
143
|
+
)
|
|
144
|
+
|
|
121
145
|
frame = nw.from_native(table.data, eager_only=True)
|
|
122
146
|
_check_columns(frame, table)
|
|
123
147
|
|
|
@@ -203,6 +227,14 @@ def resolve(table: CoefTable) -> Resolved:
|
|
|
203
227
|
[key_fn(row_key, group, split) for split in grid.splits]
|
|
204
228
|
for (row_key, _nest), group in zip(grid.ordered, grid.row_group, strict=True)
|
|
205
229
|
]
|
|
230
|
+
# A footer's domain is table-wide only when the column itself says so
|
|
231
|
+
# (see `Prepared.shared_footer`) -- inferring it from `scale` would be
|
|
232
|
+
# wrong for `Sparkline`, whose x-axis footer_key is always constant
|
|
233
|
+
# regardless of `scale` (that setting only buckets each row's own
|
|
234
|
+
# y-domain, never the shared axis's closing scope).
|
|
235
|
+
shared_footer_labels = {
|
|
236
|
+
label for label in footer_keys if prepared_by_label[label].shared_footer
|
|
237
|
+
}
|
|
206
238
|
|
|
207
239
|
def render_footer(pending: dict[str, list[Any]]) -> dict[str, str]:
|
|
208
240
|
out: dict[str, str] = {}
|
|
@@ -216,7 +248,9 @@ def resolve(table: CoefTable) -> Resolved:
|
|
|
216
248
|
out[output_name(column, split)] = text
|
|
217
249
|
return out
|
|
218
250
|
|
|
219
|
-
assembled = assemble_rows(
|
|
251
|
+
assembled = assemble_rows(
|
|
252
|
+
grid, display_columns, cell_values, footer_keys, render_footer, shared_footer_labels
|
|
253
|
+
)
|
|
220
254
|
|
|
221
255
|
data: dict[str, list[Any]] = {}
|
|
222
256
|
if table.groups:
|
|
@@ -240,6 +274,7 @@ def resolve(table: CoefTable) -> Resolved:
|
|
|
240
274
|
band_rows=assembled.band_rows,
|
|
241
275
|
divider_rows=assembled.divider_rows,
|
|
242
276
|
axis_rows=assembled.axis_rows,
|
|
277
|
+
shared_axis_rows=assembled.shared_axis_rows,
|
|
243
278
|
markdown_columns=markdown,
|
|
244
279
|
plot_columns=plot_columns,
|
|
245
280
|
)
|
|
@@ -8,7 +8,7 @@ kind (`Estimate`, `Forest`, `Passthrough`) it is laying out.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import math
|
|
11
|
-
from collections.abc import Callable
|
|
11
|
+
from collections.abc import Callable, Set
|
|
12
12
|
from dataclasses import dataclass
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
@@ -153,6 +153,11 @@ class AssembledRows:
|
|
|
153
153
|
Per-row values for the rows/nest/groups layout columns.
|
|
154
154
|
band_rows, divider_rows, axis_rows
|
|
155
155
|
Zero-based indices into the final row sequence.
|
|
156
|
+
shared_axis_rows
|
|
157
|
+
Subset of `axis_rows` where every column closing at that row has a
|
|
158
|
+
table-wide domain (`scale` in `{"table", "split_column"}`) rather
|
|
159
|
+
than a per-group or per-row-key one -- see `shared_footer_labels`
|
|
160
|
+
on `assemble_rows`.
|
|
156
161
|
cells
|
|
157
162
|
Rendered cell text per display column, one entry per final row.
|
|
158
163
|
"""
|
|
@@ -163,6 +168,7 @@ class AssembledRows:
|
|
|
163
168
|
band_rows: list[int]
|
|
164
169
|
divider_rows: list[int]
|
|
165
170
|
axis_rows: list[int]
|
|
171
|
+
shared_axis_rows: list[int]
|
|
166
172
|
cells: dict[str, list[str]]
|
|
167
173
|
|
|
168
174
|
|
|
@@ -172,6 +178,7 @@ def assemble_rows(
|
|
|
172
178
|
cell_values: dict[str, list[str]],
|
|
173
179
|
footer_keys: dict[str, list[list[Any]]],
|
|
174
180
|
render_footer: Callable[[dict[str, list[Any]]], dict[str, str]],
|
|
181
|
+
shared_footer_labels: Set[str] = frozenset(),
|
|
175
182
|
) -> AssembledRows:
|
|
176
183
|
"""Interleave rendered cells with footer rows, and lay out band/dividers.
|
|
177
184
|
|
|
@@ -198,6 +205,15 @@ def assemble_rows(
|
|
|
198
205
|
render_footer
|
|
199
206
|
Called with the labels (with their keys) due at this row; returns
|
|
200
207
|
the rendered footer text per display column.
|
|
208
|
+
shared_footer_labels
|
|
209
|
+
Labels (a subset of `footer_keys`' keys) whose footer closes once
|
|
210
|
+
for the whole table rather than once per group or row key. This
|
|
211
|
+
module is column-agnostic (see the module docstring), so the
|
|
212
|
+
caller decides membership -- `frame.py` populates it from each
|
|
213
|
+
column's own `Prepared.shared_footer`. An axis row is recorded in
|
|
214
|
+
`shared_axis_rows` only when every column closing at that row is
|
|
215
|
+
in this set; a row combining a shared and a per-group footer is
|
|
216
|
+
treated as per-group, since it is not safe to show unconditionally.
|
|
201
217
|
|
|
202
218
|
Returns
|
|
203
219
|
-------
|
|
@@ -211,14 +227,27 @@ def assemble_rows(
|
|
|
211
227
|
band_rows: list[int] = []
|
|
212
228
|
divider_rows: list[int] = []
|
|
213
229
|
axis_rows: list[int] = []
|
|
230
|
+
shared_axis_rows: list[int] = []
|
|
214
231
|
emitted: dict[str, set[Any]] = {label: set() for label in footer_keys}
|
|
215
232
|
|
|
216
233
|
def blank_row() -> None:
|
|
217
234
|
for name in display_columns:
|
|
218
235
|
cells[name].append("")
|
|
219
236
|
|
|
220
|
-
|
|
237
|
+
previous_row_key_by_group: dict[Any, Any] = {}
|
|
221
238
|
for position, (row_key, nest_key) in enumerate(grid.ordered):
|
|
239
|
+
group = grid.row_group[position]
|
|
240
|
+
# `great_tables`' `groupname_col` (see `render.py`) collects rows
|
|
241
|
+
# into contiguous per-group blocks for *display*, independent of
|
|
242
|
+
# this row-key-major physical order (`grid.ordered`). A row key
|
|
243
|
+
# spanning more than one group -- e.g. "Revenue" appearing under
|
|
244
|
+
# both "US" and "EU" -- is therefore not adjacent to its own
|
|
245
|
+
# prior occurrence once grouped; tracking "first occurrence"
|
|
246
|
+
# per group (rather than one running `previous_row_key`) keeps
|
|
247
|
+
# the label shown once per group block, matching what actually
|
|
248
|
+
# renders, instead of blanking every occurrence after the first
|
|
249
|
+
# anywhere in the table.
|
|
250
|
+
previous_row_key = previous_row_key_by_group.get(group)
|
|
222
251
|
first_of_key = row_key != previous_row_key
|
|
223
252
|
if first_of_key and previous_row_key is not None:
|
|
224
253
|
divider_rows.append(len(layout_rows))
|
|
@@ -226,8 +255,8 @@ def assemble_rows(
|
|
|
226
255
|
band_rows.append(len(layout_rows))
|
|
227
256
|
layout_rows.append(f"<b>{row_key}</b>" if first_of_key else "")
|
|
228
257
|
layout_nest.append("" if nest_key is None else str(nest_key))
|
|
229
|
-
layout_group.append(
|
|
230
|
-
|
|
258
|
+
layout_group.append(group)
|
|
259
|
+
previous_row_key_by_group[group] = row_key
|
|
231
260
|
|
|
232
261
|
for name in display_columns:
|
|
233
262
|
cells[name].append(cell_values[name][position])
|
|
@@ -252,6 +281,8 @@ def assemble_rows(
|
|
|
252
281
|
layout_nest.append("")
|
|
253
282
|
layout_group.append(layout_group[-1])
|
|
254
283
|
axis_rows.append(len(layout_rows) - 1)
|
|
284
|
+
if set(pending) <= shared_footer_labels:
|
|
285
|
+
shared_axis_rows.append(len(layout_rows) - 1)
|
|
255
286
|
for label, keys in pending.items():
|
|
256
287
|
emitted[label].update(keys)
|
|
257
288
|
for name, text in render_footer(pending).items():
|
|
@@ -264,5 +295,6 @@ def assemble_rows(
|
|
|
264
295
|
band_rows=band_rows,
|
|
265
296
|
divider_rows=divider_rows,
|
|
266
297
|
axis_rows=axis_rows,
|
|
298
|
+
shared_axis_rows=shared_axis_rows,
|
|
267
299
|
cells=cells,
|
|
268
300
|
)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from great_tables import GT, loc, style
|
|
6
6
|
|
|
7
|
+
from coeftable.collapsible import SHARED_AXIS_ROW_MARK
|
|
7
8
|
from coeftable.frame import resolve
|
|
8
9
|
from coeftable.spec import CoefTable
|
|
9
10
|
|
|
@@ -88,6 +89,18 @@ def to_gt(table: CoefTable) -> GT:
|
|
|
88
89
|
],
|
|
89
90
|
locations=loc.body(rows=resolved.axis_rows),
|
|
90
91
|
)
|
|
92
|
+
if resolved.shared_axis_rows:
|
|
93
|
+
# Marks this as a table-wide axis/footer row rather than a
|
|
94
|
+
# per-group one: `collapsible.py` reads this to keep the row
|
|
95
|
+
# visible regardless of which group precedes it, since a
|
|
96
|
+
# `scale="table"`/`"split_column"` axis belongs to the whole
|
|
97
|
+
# column, not to whichever group happens to render last. A
|
|
98
|
+
# `scale="row_group"`/`"row"` axis row is *not* in this set --
|
|
99
|
+
# it genuinely belongs to one group and stays a normal member.
|
|
100
|
+
gt = gt.tab_style(
|
|
101
|
+
style=style.css(SHARED_AXIS_ROW_MARK),
|
|
102
|
+
locations=loc.body(rows=resolved.shared_axis_rows),
|
|
103
|
+
)
|
|
91
104
|
if resolved.group_column:
|
|
92
105
|
gt = gt.tab_style(
|
|
93
106
|
style=[
|