coeftable 0.3.0__tar.gz → 0.3.1__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.3.0 → coeftable-0.3.1}/.github/workflows/publish.yml +18 -19
- {coeftable-0.3.0 → coeftable-0.3.1}/.github/workflows/release.yml +37 -5
- {coeftable-0.3.0 → coeftable-0.3.1}/CHANGELOG.md +40 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/PKG-INFO +40 -4
- {coeftable-0.3.0 → coeftable-0.3.1}/README.md +39 -3
- {coeftable-0.3.0 → coeftable-0.3.1}/pyproject.toml +6 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/_version.py +2 -2
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/frame.py +18 -3
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/grid.py +48 -54
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/series.py +35 -23
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/spec.py +35 -2
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_frame.py +101 -14
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_series.py +46 -30
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_sparkline.py +105 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/.github/release.yml +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/.github/workflows/ci.yml +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/.github/workflows/post-release.yml +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/.gitignore +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/.pre-commit-config.yaml +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/LICENSE +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/Makefile +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/docs/images/example.png +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/docs/images/trend-example.png +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/noxfile.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/__init__.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/collapsible.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/format.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/render.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/svg.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/theme.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_collapsible.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_format.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_package.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_public_api.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_render.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_spec.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_svg.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_theme.py +0 -0
- {coeftable-0.3.0 → coeftable-0.3.1}/uv.lock +0 -0
|
@@ -11,11 +11,6 @@ on:
|
|
|
11
11
|
description: "Git ref to build. Defaults to the triggering ref."
|
|
12
12
|
required: false
|
|
13
13
|
type: string
|
|
14
|
-
publish:
|
|
15
|
-
description: "Upload the built package to PyPI."
|
|
16
|
-
required: false
|
|
17
|
-
default: false
|
|
18
|
-
type: boolean
|
|
19
14
|
workflow_dispatch:
|
|
20
15
|
|
|
21
16
|
jobs:
|
|
@@ -40,29 +35,33 @@ jobs:
|
|
|
40
35
|
|
|
41
36
|
publish:
|
|
42
37
|
runs-on: ubuntu-latest
|
|
43
|
-
# PyPI
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
# manual run selecting one, which is how a stranded release gets
|
|
55
|
-
# published -- is also a release build. A run on a branch never is.
|
|
56
|
-
if: inputs.publish || startsWith(github.ref, 'refs/tags')
|
|
38
|
+
# PyPI Trusted Publishing does not support reusable workflows
|
|
39
|
+
# (https://github.com/pypa/gh-action-pypi-publish/issues/166): a
|
|
40
|
+
# `workflow_call` invocation of this job would present the *caller's*
|
|
41
|
+
# workflow identity, not this one, and fail to match the Trusted
|
|
42
|
+
# Publisher entry registered for this workflow. So this job never
|
|
43
|
+
# publishes when called reusably -- release.yml calls this workflow
|
|
44
|
+
# for `build-package` only and does its own top-level publish. This
|
|
45
|
+
# job runs only for a direct trigger of this workflow sitting on a
|
|
46
|
+
# tag: a tag push, or a manual run selecting a tag ref (how a
|
|
47
|
+
# stranded release gets republished). A run on a branch never does.
|
|
48
|
+
if: startsWith(github.ref, 'refs/tags')
|
|
57
49
|
environment:
|
|
58
50
|
name: publish
|
|
59
51
|
url: https://pypi.org/p/coeftable
|
|
60
52
|
needs: build-package
|
|
61
53
|
permissions:
|
|
54
|
+
contents: read
|
|
62
55
|
id-token: write
|
|
63
56
|
steps:
|
|
64
57
|
- uses: actions/download-artifact@v8
|
|
65
58
|
with:
|
|
66
59
|
name: Packages
|
|
67
60
|
path: dist
|
|
61
|
+
# Must be invoked straight from the job, never via a local composite
|
|
62
|
+
# action. This action builds a Docker trampoline whose image it names
|
|
63
|
+
# from `github.action_repository`/`github.action_ref`, and both are
|
|
64
|
+
# empty for an action nested inside another composite action
|
|
65
|
+
# (actions/runner#2473). It then falls back to this repository and its
|
|
66
|
+
# branch, and tries to pull an image that was never published.
|
|
68
67
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -90,10 +90,12 @@ jobs:
|
|
|
90
90
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
91
91
|
run: gh release create "${{ steps.version.outputs.tag }}" --generate-notes
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
# Called
|
|
95
|
-
#
|
|
96
|
-
#
|
|
93
|
+
build:
|
|
94
|
+
# Called reusably for the build step only: `publish.yml`'s own
|
|
95
|
+
# `publish` job is gated on `startsWith(github.ref, 'refs/tags')`,
|
|
96
|
+
# which is false here (`github.ref` reflects this run's own
|
|
97
|
+
# branch/dispatch ref, not the `ref:` input below), so it no-ops --
|
|
98
|
+
# only `build-package` executes.
|
|
97
99
|
needs: tag
|
|
98
100
|
uses: ./.github/workflows/publish.yml
|
|
99
101
|
permissions:
|
|
@@ -102,7 +104,37 @@ jobs:
|
|
|
102
104
|
id-token: write
|
|
103
105
|
with:
|
|
104
106
|
ref: ${{ needs.tag.outputs.tag }}
|
|
105
|
-
|
|
107
|
+
|
|
108
|
+
publish:
|
|
109
|
+
# A real top-level job, not delegated via `uses:`: PyPI Trusted
|
|
110
|
+
# Publishing does not support reusable workflows -- the OIDC
|
|
111
|
+
# certificate's Build Config URI reflects the top-level caller
|
|
112
|
+
# (release.yml) for a `uses:`-invoked job, which then fails to match
|
|
113
|
+
# a Trusted Publisher entry registered for the *called* workflow.
|
|
114
|
+
# See https://github.com/pypa/gh-action-pypi-publish/issues/166 and
|
|
115
|
+
# the "Non-goals" section of that action's README: "keep the job
|
|
116
|
+
# calling pypi-publish in a top-level [workflow]". `build-package`
|
|
117
|
+
# itself has no such restriction and stays reusable above.
|
|
118
|
+
needs: build
|
|
119
|
+
runs-on: ubuntu-latest
|
|
120
|
+
environment:
|
|
121
|
+
name: publish
|
|
122
|
+
url: https://pypi.org/p/coeftable
|
|
123
|
+
permissions:
|
|
124
|
+
contents: read
|
|
125
|
+
id-token: write
|
|
126
|
+
steps:
|
|
127
|
+
- uses: actions/download-artifact@v8
|
|
128
|
+
with:
|
|
129
|
+
name: Packages
|
|
130
|
+
path: dist
|
|
131
|
+
# Must be invoked straight from the job, never via a local composite
|
|
132
|
+
# action. This action builds a Docker trampoline whose image it names
|
|
133
|
+
# from `github.action_repository`/`github.action_ref`, and both are
|
|
134
|
+
# empty for an action nested inside another composite action
|
|
135
|
+
# (actions/runner#2473). It then falls back to this repository and its
|
|
136
|
+
# branch, and tries to pull an image that was never published.
|
|
137
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
106
138
|
|
|
107
139
|
changelog:
|
|
108
140
|
# Same reason as the publish job above: the release is created with the
|
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
<a id="v0.3.1"></a>
|
|
2
|
+
# [v0.3.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.1) - 2026-08-14
|
|
3
|
+
|
|
4
|
+
<!-- Release notes generated using configuration in .github/release.yml at v0.3.1 -->
|
|
5
|
+
|
|
6
|
+
## What's Changed
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
* Fix row-group handling: labels spanning groups, row striping, and layout key collisions by [@kylejcaron](https://github.com/kylejcaron) in [#26](https://github.com/kylejcaron/coeftable/pull/26)
|
|
9
|
+
* Build core metadata 2.4 so releases pass validation by [@kylejcaron](https://github.com/kylejcaron) in [#27](https://github.com/kylejcaron/coeftable/pull/27)
|
|
10
|
+
### Maintenance
|
|
11
|
+
* Update changelog for v0.3.0 by [@github-actions](https://github.com/github-actions)[bot] in [#21](https://github.com/kylejcaron/coeftable/pull/21)
|
|
12
|
+
* Fix automated releases: PyPI Trusted Publishing rejects reusable workflows by [@kylejcaron](https://github.com/kylejcaron) in [#22](https://github.com/kylejcaron/coeftable/pull/22)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
**Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
|
|
16
|
+
|
|
17
|
+
[Changes][v0.3.1]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
<a id="v0.3.0"></a>
|
|
21
|
+
# [v0.3.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.0) - 2026-08-05
|
|
22
|
+
|
|
23
|
+
<!-- Release notes generated using configuration in .github/release.yml at v0.3.0 -->
|
|
24
|
+
|
|
25
|
+
## What's Changed
|
|
26
|
+
### Maintenance
|
|
27
|
+
* Update changelog for v0.2.1 by [@github-actions](https://github.com/github-actions)[bot] in [#13](https://github.com/kylejcaron/coeftable/pull/13)
|
|
28
|
+
* Document the PyPI trusted-publisher requirement for both entry points by [@kylejcaron](https://github.com/kylejcaron) in [#14](https://github.com/kylejcaron/coeftable/pull/14)
|
|
29
|
+
* Add collapsible group sections by [@kylejcaron](https://github.com/kylejcaron) in [#18](https://github.com/kylejcaron/coeftable/pull/18)
|
|
30
|
+
* Allow ref=None on Sparkline to drop the reference line and its domain constraint by [@kylejcaron](https://github.com/kylejcaron) in [#19](https://github.com/kylejcaron/coeftable/pull/19)
|
|
31
|
+
* Support multiple overlaid series in one sparkline cell by [@kylejcaron](https://github.com/kylejcaron) in [#20](https://github.com/kylejcaron/coeftable/pull/20)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
**Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
|
|
35
|
+
|
|
36
|
+
[Changes][v0.3.0]
|
|
37
|
+
|
|
38
|
+
|
|
1
39
|
<a id="v0.2.1"></a>
|
|
2
40
|
# [v0.2.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.2.1) - 2026-08-05
|
|
3
41
|
|
|
@@ -54,6 +92,8 @@
|
|
|
54
92
|
[Changes][v0.1.0]
|
|
55
93
|
|
|
56
94
|
|
|
95
|
+
[v0.3.1]: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
|
|
96
|
+
[v0.3.0]: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
|
|
57
97
|
[v0.2.1]: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
|
|
58
98
|
[v0.2.0]: https://github.com/kylejcaron/coeftable/compare/v0.1.0...v0.2.0
|
|
59
99
|
[v0.1.0]: https://github.com/kylejcaron/coeftable/tree/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coeftable
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -104,7 +104,10 @@ a `parameter` column.
|
|
|
104
104
|
**Dimensions:**
|
|
105
105
|
- `rows` — the label for each row in the table (e.g. a metric name).
|
|
106
106
|
- `nest` — an optional secondary label stacked below each row.
|
|
107
|
-
- `groups` — an optional column whose values produce section headers.
|
|
107
|
+
- `groups` — an optional column whose values produce section headers. A row
|
|
108
|
+
label may appear under more than one group, in which case it renders once per
|
|
109
|
+
section — so the same set of metrics can be reported per region without
|
|
110
|
+
inventing a `nest` column to tell them apart. Pass
|
|
108
111
|
`collapsible_groups=True` to make those sections collapsible in the rendered
|
|
109
112
|
HTML — a pure CSS toggle (relies on `:has()`, Baseline since late 2023; on an
|
|
110
113
|
older browser without it the toggle no-ops and sections stay expanded), no
|
|
@@ -182,6 +185,34 @@ methods = pl.DataFrame(
|
|
|
182
185
|
)
|
|
183
186
|
```
|
|
184
187
|
|
|
188
|
+
## Repeating metrics across sections
|
|
189
|
+
|
|
190
|
+
A row label may appear under more than one `groups` value. The same metrics can
|
|
191
|
+
therefore be reported per region, each section repeating the full set:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
import polars as pl
|
|
195
|
+
import coeftable as ct
|
|
196
|
+
|
|
197
|
+
regions = pl.DataFrame(
|
|
198
|
+
{
|
|
199
|
+
"metric": ["Revenue", "Signups", "Revenue", "Signups"],
|
|
200
|
+
"region": ["US", "US", "EU", "EU"],
|
|
201
|
+
"est": [1.2, 0.4, 2.1, 0.9],
|
|
202
|
+
"lb": [0.8, 0.1, 1.5, 0.5],
|
|
203
|
+
"ub": [1.6, 0.7, 2.7, 1.3],
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
(
|
|
208
|
+
ct.CoefTable(
|
|
209
|
+
regions, rows="metric", groups="region", collapsible_groups=True
|
|
210
|
+
)
|
|
211
|
+
.estimate("Effect", "est", ci=("lb", "ub"))
|
|
212
|
+
.forest("Effect Plot", of="Effect", ref=0.0, symmetric=True)
|
|
213
|
+
)
|
|
214
|
+
```
|
|
215
|
+
|
|
185
216
|
## Theming
|
|
186
217
|
|
|
187
218
|
Four built-in themes are available from `coeftable.theme`:
|
|
@@ -236,8 +267,13 @@ companion-frame choice used elsewhere in coeftable:
|
|
|
236
267
|
arrives in: a SQL export, a dbt model, an experimentation platform's daily
|
|
237
268
|
metrics table. Pass `data=` a separate frame with one row per point, and
|
|
238
269
|
`value` / `ci` / `x` name *scalar* columns on it. coeftable groups the
|
|
239
|
-
companion frame by the table's `rows` (+ `nest`, + `
|
|
240
|
-
and collapses each group into a series internally
|
|
270
|
+
companion frame by the table's `rows` (+ `nest`, + `groups`, +
|
|
271
|
+
`split_columns`) keys and collapses each group into a series internally.
|
|
272
|
+
`groups` participates only when `data` actually carries that column, so a
|
|
273
|
+
companion frame keyed on the row alone stays valid. The one case that needs
|
|
274
|
+
it is a row label appearing under more than one group: without the group
|
|
275
|
+
column in `data` those rows are indistinguishable, so coeftable reports it
|
|
276
|
+
rather than serving every section the same merged series.
|
|
241
277
|
|
|
242
278
|
```python
|
|
243
279
|
import datetime as dt
|
|
@@ -50,7 +50,10 @@ a `parameter` column.
|
|
|
50
50
|
**Dimensions:**
|
|
51
51
|
- `rows` — the label for each row in the table (e.g. a metric name).
|
|
52
52
|
- `nest` — an optional secondary label stacked below each row.
|
|
53
|
-
- `groups` — an optional column whose values produce section headers.
|
|
53
|
+
- `groups` — an optional column whose values produce section headers. A row
|
|
54
|
+
label may appear under more than one group, in which case it renders once per
|
|
55
|
+
section — so the same set of metrics can be reported per region without
|
|
56
|
+
inventing a `nest` column to tell them apart. Pass
|
|
54
57
|
`collapsible_groups=True` to make those sections collapsible in the rendered
|
|
55
58
|
HTML — a pure CSS toggle (relies on `:has()`, Baseline since late 2023; on an
|
|
56
59
|
older browser without it the toggle no-ops and sections stay expanded), no
|
|
@@ -128,6 +131,34 @@ methods = pl.DataFrame(
|
|
|
128
131
|
)
|
|
129
132
|
```
|
|
130
133
|
|
|
134
|
+
## Repeating metrics across sections
|
|
135
|
+
|
|
136
|
+
A row label may appear under more than one `groups` value. The same metrics can
|
|
137
|
+
therefore be reported per region, each section repeating the full set:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import polars as pl
|
|
141
|
+
import coeftable as ct
|
|
142
|
+
|
|
143
|
+
regions = pl.DataFrame(
|
|
144
|
+
{
|
|
145
|
+
"metric": ["Revenue", "Signups", "Revenue", "Signups"],
|
|
146
|
+
"region": ["US", "US", "EU", "EU"],
|
|
147
|
+
"est": [1.2, 0.4, 2.1, 0.9],
|
|
148
|
+
"lb": [0.8, 0.1, 1.5, 0.5],
|
|
149
|
+
"ub": [1.6, 0.7, 2.7, 1.3],
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
(
|
|
154
|
+
ct.CoefTable(
|
|
155
|
+
regions, rows="metric", groups="region", collapsible_groups=True
|
|
156
|
+
)
|
|
157
|
+
.estimate("Effect", "est", ci=("lb", "ub"))
|
|
158
|
+
.forest("Effect Plot", of="Effect", ref=0.0, symmetric=True)
|
|
159
|
+
)
|
|
160
|
+
```
|
|
161
|
+
|
|
131
162
|
## Theming
|
|
132
163
|
|
|
133
164
|
Four built-in themes are available from `coeftable.theme`:
|
|
@@ -182,8 +213,13 @@ companion-frame choice used elsewhere in coeftable:
|
|
|
182
213
|
arrives in: a SQL export, a dbt model, an experimentation platform's daily
|
|
183
214
|
metrics table. Pass `data=` a separate frame with one row per point, and
|
|
184
215
|
`value` / `ci` / `x` name *scalar* columns on it. coeftable groups the
|
|
185
|
-
companion frame by the table's `rows` (+ `nest`, + `
|
|
186
|
-
and collapses each group into a series internally
|
|
216
|
+
companion frame by the table's `rows` (+ `nest`, + `groups`, +
|
|
217
|
+
`split_columns`) keys and collapses each group into a series internally.
|
|
218
|
+
`groups` participates only when `data` actually carries that column, so a
|
|
219
|
+
companion frame keyed on the row alone stays valid. The one case that needs
|
|
220
|
+
it is a row label appearing under more than one group: without the group
|
|
221
|
+
column in `data` those rows are indistinguishable, so coeftable reports it
|
|
222
|
+
rather than serving every section the same merged series.
|
|
187
223
|
|
|
188
224
|
```python
|
|
189
225
|
import datetime as dt
|
|
@@ -44,8 +44,14 @@ Homepage = "https://github.com/kylejcaron/coeftable"
|
|
|
44
44
|
Repository = "https://github.com/kylejcaron/coeftable"
|
|
45
45
|
Issues = "https://github.com/kylejcaron/coeftable/issues"
|
|
46
46
|
|
|
47
|
+
# PyPI rejects core metadata 2.5, which hatchling 1.30 defaults to.
|
|
48
|
+
# Drop once https://github.com/pypi/warehouse/issues/19083 ships.
|
|
47
49
|
[tool.hatch.build.targets.wheel]
|
|
48
50
|
packages = ["src/coeftable"]
|
|
51
|
+
core-metadata-version = "2.4"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.sdist]
|
|
54
|
+
core-metadata-version = "2.4"
|
|
49
55
|
|
|
50
56
|
[tool.hatch.version]
|
|
51
57
|
source = "vcs"
|
|
@@ -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.3.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 3,
|
|
21
|
+
__version__ = version = '0.3.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 3, 1)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
+
from itertools import combinations
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
import narwhals as nw
|
|
@@ -125,6 +126,19 @@ def resolve(table: CoefTable) -> Resolved:
|
|
|
125
126
|
f"(rows/nest/groups key {column.label!r}); choose a different label."
|
|
126
127
|
)
|
|
127
128
|
|
|
129
|
+
# Two layout roles naming the same column collide in the output frame,
|
|
130
|
+
# which `resolve()` builds keyed by column name -- the later write
|
|
131
|
+
# silently discards the earlier role's values, so the table renders with
|
|
132
|
+
# one role's layout missing. Reject it rather than emit a corrupt table.
|
|
133
|
+
roles = [("rows", table.rows), ("nest", table.nest), ("groups", table.groups)]
|
|
134
|
+
named = [(role, key) for role, key in roles if key is not None]
|
|
135
|
+
for (first_role, first_key), (second_role, second_key) in combinations(named, 2):
|
|
136
|
+
if first_key == second_key:
|
|
137
|
+
raise SpecError(
|
|
138
|
+
f"Layout keys {first_role}= and {second_role}= both name column "
|
|
139
|
+
f"{first_key!r}; each layout role needs its own column."
|
|
140
|
+
)
|
|
141
|
+
|
|
128
142
|
# The overlaid `series` dimension cannot also be a table axis: the
|
|
129
143
|
# table's row structure already spends whichever of these an
|
|
130
144
|
# identity maps to, so naming the same column both ways is
|
|
@@ -160,6 +174,7 @@ def resolve(table: CoefTable) -> Resolved:
|
|
|
160
174
|
split_keys=split_keys,
|
|
161
175
|
rows=table.rows,
|
|
162
176
|
nest=table.nest,
|
|
177
|
+
groups=table.groups,
|
|
163
178
|
split_columns=table.split_columns,
|
|
164
179
|
)
|
|
165
180
|
prepared: list[Prepared] = [column.prepare(scan) for column in table.columns]
|
|
@@ -192,11 +207,11 @@ def resolve(table: CoefTable) -> Resolved:
|
|
|
192
207
|
|
|
193
208
|
# Cell pass: one call to `column.cell` per (row, split, column).
|
|
194
209
|
cell_values: dict[str, list[str]] = {name: [] for name in display_columns}
|
|
195
|
-
for position, (row_key, nest_key) in enumerate(grid.ordered):
|
|
210
|
+
for position, (row_key, nest_key, identity_group) in enumerate(grid.ordered):
|
|
196
211
|
direction = table.direction_for(str(row_key))
|
|
197
212
|
group = grid.row_group[position]
|
|
198
213
|
for split in grid.splits:
|
|
199
|
-
index = grid.source_index.get(((row_key, nest_key), split))
|
|
214
|
+
index = grid.source_index.get(((row_key, nest_key, identity_group), split))
|
|
200
215
|
for column, prep in zip(table.columns, prepared, strict=True):
|
|
201
216
|
name = output_name(column, split)
|
|
202
217
|
if index is None:
|
|
@@ -225,7 +240,7 @@ def resolve(table: CoefTable) -> Resolved:
|
|
|
225
240
|
key_fn = prep.footer_key
|
|
226
241
|
footer_keys[column.label] = [
|
|
227
242
|
[key_fn(row_key, group, split) for split in grid.splits]
|
|
228
|
-
for (row_key, _nest
|
|
243
|
+
for (row_key, _nest, group) in grid.ordered
|
|
229
244
|
]
|
|
230
245
|
# A footer's domain is table-wide only when the column itself says so
|
|
231
246
|
# (see `Prepared.shared_footer`) -- inferring it from `scale` would be
|
|
@@ -32,24 +32,6 @@ def _ordered_unique(values: list[Any], *, sort: bool) -> list[Any]:
|
|
|
32
32
|
return sorted(seen, key=str) if sort else seen
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def _first_source(
|
|
36
|
-
source_index: dict[tuple[tuple[Any, Any], Any], int],
|
|
37
|
-
identity: tuple[Any, Any],
|
|
38
|
-
splits: list[Any],
|
|
39
|
-
) -> int:
|
|
40
|
-
"""Return the input row backing `identity`, preferring the first split value.
|
|
41
|
-
|
|
42
|
-
Split-column data is often sparse, so the first split value may have no row
|
|
43
|
-
for a given identity. Falling back to any split keeps layout metadata such
|
|
44
|
-
as the row-group value resolvable.
|
|
45
|
-
"""
|
|
46
|
-
for split in splits:
|
|
47
|
-
found = source_index.get((identity, split))
|
|
48
|
-
if found is not None:
|
|
49
|
-
return found
|
|
50
|
-
raise KeyError(f"No input row for {identity!r} under any split value.")
|
|
51
|
-
|
|
52
|
-
|
|
53
35
|
@dataclass(frozen=True)
|
|
54
36
|
class Grid:
|
|
55
37
|
"""Row identity and ordering for one resolved table, independent of column kind.
|
|
@@ -57,23 +39,23 @@ class Grid:
|
|
|
57
39
|
Parameters
|
|
58
40
|
----------
|
|
59
41
|
ordered
|
|
60
|
-
Output rows as `(row key, nest key)` identities, in display
|
|
61
|
-
|
|
62
|
-
|
|
42
|
+
Output rows as `(row key, nest key, group)` identities, in display
|
|
43
|
+
order: group-major, then row-key order within each group, matching the
|
|
44
|
+
order `great_tables` renders them in.
|
|
63
45
|
splits
|
|
64
46
|
Distinct split-column values, in display order, or `[None]` when the
|
|
65
47
|
table has no split column.
|
|
66
48
|
source_index
|
|
67
|
-
Maps `((row key, nest key), split)` to the input frame row
|
|
49
|
+
Maps `((row key, nest key, group), split)` to the input frame row
|
|
50
|
+
backing it.
|
|
68
51
|
row_group
|
|
69
|
-
The row-group value for each `ordered` position,
|
|
70
|
-
|
|
52
|
+
The row-group value for each `ordered` position, read straight off the
|
|
53
|
+
identity.
|
|
71
54
|
"""
|
|
72
55
|
|
|
73
|
-
ordered: list[tuple[Any, Any]]
|
|
74
|
-
unique_rows: list[Any]
|
|
56
|
+
ordered: list[tuple[Any, Any, Any]]
|
|
75
57
|
splits: list[Any]
|
|
76
|
-
source_index: dict[tuple[tuple[Any, Any], Any], int]
|
|
58
|
+
source_index: dict[tuple[tuple[Any, Any, Any], Any], int]
|
|
77
59
|
row_group: list[Any]
|
|
78
60
|
|
|
79
61
|
|
|
@@ -106,37 +88,46 @@ def build_grid(
|
|
|
106
88
|
Raises
|
|
107
89
|
------
|
|
108
90
|
SpecError
|
|
109
|
-
When the same (rows, nest, split_columns) combination appears
|
|
110
|
-
than once in the input frame.
|
|
91
|
+
When the same (rows, nest, groups, split_columns) combination appears
|
|
92
|
+
more than once in the input frame.
|
|
111
93
|
"""
|
|
112
94
|
n = len(row_keys)
|
|
113
|
-
identities = [(row_keys[i], nest_keys[i]) for i in range(n)]
|
|
114
|
-
unique_rows = _ordered_unique([r for r, _ in identities], sort=sort_rows)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
95
|
+
identities = [(row_keys[i], nest_keys[i], group_keys[i]) for i in range(n)]
|
|
96
|
+
unique_rows = _ordered_unique([r for r, _, _ in identities], sort=sort_rows)
|
|
97
|
+
# Groups keep input order regardless of `sort_rows`, matching what
|
|
98
|
+
# `great_tables` would have done, so no existing table's group order moves.
|
|
99
|
+
unique_groups = _ordered_unique(group_keys, sort=False)
|
|
100
|
+
|
|
101
|
+
# Group-major: rows are laid out in the same order they render in, so the
|
|
102
|
+
# band/divider indices computed below stay valid. Ordering by row key alone
|
|
103
|
+
# would leave `great_tables` to regroup, desynchronising those indices from
|
|
104
|
+
# what the reader sees whenever a group's rows are not already adjacent.
|
|
105
|
+
ordered: list[tuple[Any, Any, Any]] = []
|
|
106
|
+
for group in unique_groups:
|
|
107
|
+
for row_key in unique_rows:
|
|
108
|
+
for identity in identities:
|
|
109
|
+
if identity[0] == row_key and identity[2] == group and identity not in ordered:
|
|
110
|
+
ordered.append(identity)
|
|
120
111
|
|
|
121
112
|
splits = _ordered_unique(split_keys, sort=sort_rows) if has_splits else [None]
|
|
122
|
-
source_index: dict[tuple[tuple[Any, Any], Any], int] = {}
|
|
113
|
+
source_index: dict[tuple[tuple[Any, Any, Any], Any], int] = {}
|
|
123
114
|
for i in range(n):
|
|
124
115
|
key = (identities[i], split_keys[i])
|
|
125
116
|
if key in source_index:
|
|
126
|
-
row_label, nest_label = identities[i]
|
|
117
|
+
row_label, nest_label, group_label = identities[i]
|
|
127
118
|
extra = f", split={split_keys[i]!r}" if split_keys[i] is not None else ""
|
|
128
119
|
raise SpecError(
|
|
129
|
-
f"Duplicate input row for row={row_label!r}, nest={nest_label!r}
|
|
130
|
-
f"
|
|
120
|
+
f"Duplicate input row for row={row_label!r}, nest={nest_label!r}, "
|
|
121
|
+
f"group={group_label!r}{extra}"
|
|
122
|
+
f" — each (rows, nest, groups, split_columns) combination "
|
|
131
123
|
f"must appear at most once."
|
|
132
124
|
)
|
|
133
125
|
source_index[key] = i
|
|
134
126
|
|
|
135
|
-
row_group = [
|
|
127
|
+
row_group = [identity[2] for identity in ordered]
|
|
136
128
|
|
|
137
129
|
return Grid(
|
|
138
130
|
ordered=ordered,
|
|
139
|
-
unique_rows=unique_rows,
|
|
140
131
|
splits=splits,
|
|
141
132
|
source_index=source_index,
|
|
142
133
|
row_group=row_group,
|
|
@@ -235,23 +226,26 @@ def assemble_rows(
|
|
|
235
226
|
cells[name].append("")
|
|
236
227
|
|
|
237
228
|
previous_row_key_by_group: dict[Any, Any] = {}
|
|
238
|
-
|
|
229
|
+
block_index = -1
|
|
230
|
+
for position, (row_key, nest_key, _group) in enumerate(grid.ordered):
|
|
239
231
|
group = grid.row_group[position]
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
245
|
-
#
|
|
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.
|
|
232
|
+
# A row key may appear under more than one group -- e.g. "Revenue"
|
|
233
|
+
# under both "US" and "EU" -- so it is not adjacent to its own prior
|
|
234
|
+
# occurrence. Tracking "first occurrence" per group (rather than one
|
|
235
|
+
# running `previous_row_key`) keeps the label shown once per group
|
|
236
|
+
# block instead of blanking every occurrence after the first anywhere
|
|
237
|
+
# in the table.
|
|
250
238
|
previous_row_key = previous_row_key_by_group.get(group)
|
|
251
239
|
first_of_key = row_key != previous_row_key
|
|
252
240
|
if first_of_key and previous_row_key is not None:
|
|
253
241
|
divider_rows.append(len(layout_rows))
|
|
254
|
-
|
|
242
|
+
# Banding alternates over row-key blocks in display order. Keying off a
|
|
243
|
+
# global index into the distinct row keys would stripe two adjacent
|
|
244
|
+
# blocks the same shade whenever a row key spans groups, or groups
|
|
245
|
+
# interleave.
|
|
246
|
+
if first_of_key:
|
|
247
|
+
block_index += 1
|
|
248
|
+
if block_index % 2 == 0:
|
|
255
249
|
band_rows.append(len(layout_rows))
|
|
256
250
|
layout_rows.append(f"<b>{row_key}</b>" if first_of_key else "")
|
|
257
251
|
layout_nest.append("" if nest_key is None else str(nest_key))
|