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.
Files changed (40) hide show
  1. {coeftable-0.2.1 → coeftable-0.3.0}/.github/workflows/publish.yml +8 -0
  2. {coeftable-0.2.1 → coeftable-0.3.0}/CHANGELOG.md +19 -0
  3. {coeftable-0.2.1 → coeftable-0.3.0}/PKG-INFO +46 -16
  4. {coeftable-0.2.1 → coeftable-0.3.0}/README.md +45 -15
  5. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/_version.py +2 -2
  6. coeftable-0.3.0/src/coeftable/collapsible.py +166 -0
  7. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/frame.py +36 -1
  8. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/grid.py +36 -4
  9. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/render.py +13 -0
  10. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/series.py +84 -18
  11. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/spec.py +381 -70
  12. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/svg.py +311 -78
  13. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/theme.py +83 -5
  14. coeftable-0.3.0/tests/test_collapsible.py +260 -0
  15. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_frame.py +53 -0
  16. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_public_api.py +21 -0
  17. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_render.py +55 -0
  18. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_series.py +79 -0
  19. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_sparkline.py +562 -0
  20. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_svg.py +398 -0
  21. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_theme.py +42 -0
  22. {coeftable-0.2.1 → coeftable-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {coeftable-0.2.1 → coeftable-0.3.0}/.github/release.yml +0 -0
  24. {coeftable-0.2.1 → coeftable-0.3.0}/.github/workflows/ci.yml +0 -0
  25. {coeftable-0.2.1 → coeftable-0.3.0}/.github/workflows/post-release.yml +0 -0
  26. {coeftable-0.2.1 → coeftable-0.3.0}/.github/workflows/release.yml +0 -0
  27. {coeftable-0.2.1 → coeftable-0.3.0}/.gitignore +0 -0
  28. {coeftable-0.2.1 → coeftable-0.3.0}/.pre-commit-config.yaml +0 -0
  29. {coeftable-0.2.1 → coeftable-0.3.0}/LICENSE +0 -0
  30. {coeftable-0.2.1 → coeftable-0.3.0}/Makefile +0 -0
  31. {coeftable-0.2.1 → coeftable-0.3.0}/docs/images/example.png +0 -0
  32. {coeftable-0.2.1 → coeftable-0.3.0}/docs/images/trend-example.png +0 -0
  33. {coeftable-0.2.1 → coeftable-0.3.0}/noxfile.py +0 -0
  34. {coeftable-0.2.1 → coeftable-0.3.0}/pyproject.toml +0 -0
  35. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/__init__.py +0 -0
  36. {coeftable-0.2.1 → coeftable-0.3.0}/src/coeftable/format.py +0 -0
  37. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_format.py +0 -0
  38. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_package.py +0 -0
  39. {coeftable-0.2.1 → coeftable-0.3.0}/tests/test_spec.py +0 -0
  40. {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.2.1
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, use
91
- `.gt()` to reach the underlying [great_tables](https://posit-dev.github.io/great-tables/)
92
- object: `table.gt().as_raw_html()` for an HTML string, `table.gt().save("t.png")` for an
93
- image, `table.gt().tab_options(...)` to keep styling with great_tables' own API.
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 # Alias for TEXTUAL -- what CoefTable uses if you don't set a theme
188
- TEXTUAL # Minimal, publication-style: muted colours, light chrome
189
- BLUE # The original blue-grey palette
190
- COLORBLIND # Colourblind-safe palette
191
- MONO # Grayscale for mono journals
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
- ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub"))
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, use
37
- `.gt()` to reach the underlying [great_tables](https://posit-dev.github.io/great-tables/)
38
- object: `table.gt().as_raw_html()` for an HTML string, `table.gt().save("t.png")` for an
39
- image, `table.gt().tab_options(...)` to keep styling with great_tables' own API.
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 # Alias for TEXTUAL -- what CoefTable uses if you don't set a theme
134
- TEXTUAL # Minimal, publication-style: muted colours, light chrome
135
- BLUE # The original blue-grey palette
136
- COLORBLIND # Colourblind-safe palette
137
- MONO # Grayscale for mono journals
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
- ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub"))
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.2.1'
22
- __version_tuple__ = version_tuple = (0, 2, 1)
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(grid, display_columns, cell_values, footer_keys, render_footer)
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
- previous_row_key: Any = None
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(grid.row_group[position])
230
- previous_row_key = row_key
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=[