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.
Files changed (40) hide show
  1. {coeftable-0.3.0 → coeftable-0.3.1}/.github/workflows/publish.yml +18 -19
  2. {coeftable-0.3.0 → coeftable-0.3.1}/.github/workflows/release.yml +37 -5
  3. {coeftable-0.3.0 → coeftable-0.3.1}/CHANGELOG.md +40 -0
  4. {coeftable-0.3.0 → coeftable-0.3.1}/PKG-INFO +40 -4
  5. {coeftable-0.3.0 → coeftable-0.3.1}/README.md +39 -3
  6. {coeftable-0.3.0 → coeftable-0.3.1}/pyproject.toml +6 -0
  7. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/_version.py +2 -2
  8. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/frame.py +18 -3
  9. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/grid.py +48 -54
  10. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/series.py +35 -23
  11. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/spec.py +35 -2
  12. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_frame.py +101 -14
  13. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_series.py +46 -30
  14. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_sparkline.py +105 -0
  15. {coeftable-0.3.0 → coeftable-0.3.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  16. {coeftable-0.3.0 → coeftable-0.3.1}/.github/release.yml +0 -0
  17. {coeftable-0.3.0 → coeftable-0.3.1}/.github/workflows/ci.yml +0 -0
  18. {coeftable-0.3.0 → coeftable-0.3.1}/.github/workflows/post-release.yml +0 -0
  19. {coeftable-0.3.0 → coeftable-0.3.1}/.gitignore +0 -0
  20. {coeftable-0.3.0 → coeftable-0.3.1}/.pre-commit-config.yaml +0 -0
  21. {coeftable-0.3.0 → coeftable-0.3.1}/LICENSE +0 -0
  22. {coeftable-0.3.0 → coeftable-0.3.1}/Makefile +0 -0
  23. {coeftable-0.3.0 → coeftable-0.3.1}/docs/images/example.png +0 -0
  24. {coeftable-0.3.0 → coeftable-0.3.1}/docs/images/trend-example.png +0 -0
  25. {coeftable-0.3.0 → coeftable-0.3.1}/noxfile.py +0 -0
  26. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/__init__.py +0 -0
  27. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/collapsible.py +0 -0
  28. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/format.py +0 -0
  29. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/render.py +0 -0
  30. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/svg.py +0 -0
  31. {coeftable-0.3.0 → coeftable-0.3.1}/src/coeftable/theme.py +0 -0
  32. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_collapsible.py +0 -0
  33. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_format.py +0 -0
  34. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_package.py +0 -0
  35. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_public_api.py +0 -0
  36. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_render.py +0 -0
  37. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_spec.py +0 -0
  38. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_svg.py +0 -0
  39. {coeftable-0.3.0 → coeftable-0.3.1}/tests/test_theme.py +0 -0
  40. {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 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
- #
51
- # A tag pushed by a workflow using the default token does not raise a
52
- # push event, so a release cut by automation asks for the upload
53
- # explicitly. Any other run sitting on a tag -- a hand-pushed tag, or a
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
- publish:
94
- # Called directly rather than left to the tag-push trigger on the publish
95
- # workflow: the tag above is pushed with the workflow token, and GitHub
96
- # does not raise workflow-triggering events for it.
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
- publish: true
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.0
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. Pass
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`, + `split_columns`) keys
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. Pass
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`, + `split_columns`) keys
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.0'
22
- __version_tuple__ = version_tuple = (0, 3, 0)
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), group in zip(grid.ordered, grid.row_group, strict=True)
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 order.
61
- unique_rows
62
- Distinct row keys, in the same order banding and dividers key off.
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 backing it.
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, falling back across
70
- splits via `_first_source` when the first split has no data there.
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 more
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
- ordered: list[tuple[Any, Any]] = []
116
- for row_key in unique_rows:
117
- for identity in identities:
118
- if identity[0] == row_key and identity not in ordered:
119
- ordered.append(identity)
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}{extra}"
130
- f" — each (rows, nest, split_columns) combination "
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 = [group_keys[_first_source(source_index, identity, splits)] for identity in ordered]
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
- for position, (row_key, nest_key) in enumerate(grid.ordered):
229
+ block_index = -1
230
+ for position, (row_key, nest_key, _group) in enumerate(grid.ordered):
239
231
  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.
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
- if grid.unique_rows.index(row_key) % 2 == 0:
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))