coeftable 0.7.0__tar.gz → 0.9.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.7.0 → coeftable-0.9.0}/CHANGELOG.md +39 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/PKG-INFO +39 -3
- {coeftable-0.7.0 → coeftable-0.9.0}/README.md +38 -2
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/_version.py +2 -2
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/frame.py +146 -61
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/breakout.py +15 -5
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/driver_tree.py +182 -85
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/honesty.py +102 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/model.py +2 -2
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/timeline.py +36 -16
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_driver_tree.py +246 -18
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_honesty.py +33 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_timeline.py +106 -5
- {coeftable-0.7.0 → coeftable-0.9.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/.github/release.yml +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/.github/workflows/ci.yml +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/.github/workflows/post-release.yml +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/.github/workflows/publish.yml +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/.github/workflows/release.yml +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/.gitignore +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/.pre-commit-config.yaml +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/AGENTS.md +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/LICENSE +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/Makefile +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/docs/images/example.png +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/docs/images/forest-robust-autoscale.png +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/docs/images/trend-example.png +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/noxfile.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/pyproject.toml +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/__init__.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/_axis.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/annotations.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/__init__.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/adornments.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/card.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/chrome.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/fragments.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/measure.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/panel.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/regions.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/cards/template.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/collapsible.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/errors.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/format.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/__init__.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/metric_tree.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/render.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/report.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/state.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/graph/topology.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/grid.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/plots.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/render.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/series.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/spec.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/svg.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/src/coeftable/theme.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_annotations.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_breakout.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_card_entry.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_cards.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_collapsible.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_format.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_frame.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_graph.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_graph_report.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_package.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_panel.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_plots.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_public_api.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_regions.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_render.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_series.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_sparkline.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_spec.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_svg.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/tests/test_theme.py +0 -0
- {coeftable-0.7.0 → coeftable-0.9.0}/uv.lock +0 -0
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
<a id="v0.8.0"></a>
|
|
2
|
+
# [v0.8.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.8.0) - 2026-08-27
|
|
3
|
+
|
|
4
|
+
<!-- Release notes generated using configuration in .github/release.yml at v0.8.0 -->
|
|
5
|
+
|
|
6
|
+
## What's Changed
|
|
7
|
+
### New Features
|
|
8
|
+
* Label periods through one caller-supplied formatter by [@kylejcaron](https://github.com/kylejcaron) in [#49](https://github.com/kylejcaron/coeftable/pull/49)
|
|
9
|
+
### Maintenance
|
|
10
|
+
* Update changelog for v0.7.0 by [@github-actions](https://github.com/github-actions)[bot] in [#48](https://github.com/kylejcaron/coeftable/pull/48)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
**Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.7.0...v0.8.0
|
|
14
|
+
|
|
15
|
+
[Changes][v0.8.0]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
<a id="v0.7.0"></a>
|
|
19
|
+
# [v0.7.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.7.0) - 2026-08-27
|
|
20
|
+
|
|
21
|
+
<!-- Release notes generated using configuration in .github/release.yml at v0.7.0 -->
|
|
22
|
+
|
|
23
|
+
## What's Changed
|
|
24
|
+
### New Features
|
|
25
|
+
* Generalize the shared-position proof for nested breakout switchers by [@kylejcaron](https://github.com/kylejcaron) in [#43](https://github.com/kylejcaron/coeftable/pull/43)
|
|
26
|
+
* Add DriverTree, a decomposition report with nested breakout switching by [@kylejcaron](https://github.com/kylejcaron) in [#44](https://github.com/kylejcaron/coeftable/pull/44)
|
|
27
|
+
### Maintenance
|
|
28
|
+
* Update changelog for v0.6.0 by [@github-actions](https://github.com/github-actions)[bot] in [#42](https://github.com/kylejcaron/coeftable/pull/42)
|
|
29
|
+
* Run CI on pull requests to any base branch by [@kylejcaron](https://github.com/kylejcaron) in [#46](https://github.com/kylejcaron/coeftable/pull/46)
|
|
30
|
+
* Judge additive decompositions at their endpoints too by [@kylejcaron](https://github.com/kylejcaron) in [#47](https://github.com/kylejcaron/coeftable/pull/47)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
**Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.6.0...v0.7.0
|
|
34
|
+
|
|
35
|
+
[Changes][v0.7.0]
|
|
36
|
+
|
|
37
|
+
|
|
1
38
|
<a id="v0.6.0"></a>
|
|
2
39
|
# [v0.6.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.6.0) - 2026-08-27
|
|
3
40
|
|
|
@@ -147,6 +184,8 @@
|
|
|
147
184
|
[Changes][v0.1.0]
|
|
148
185
|
|
|
149
186
|
|
|
187
|
+
[v0.8.0]: https://github.com/kylejcaron/coeftable/compare/v0.7.0...v0.8.0
|
|
188
|
+
[v0.7.0]: https://github.com/kylejcaron/coeftable/compare/v0.6.0...v0.7.0
|
|
150
189
|
[v0.6.0]: https://github.com/kylejcaron/coeftable/compare/v0.5.0...v0.6.0
|
|
151
190
|
[v0.5.0]: https://github.com/kylejcaron/coeftable/compare/v0.4.0...v0.5.0
|
|
152
191
|
[v0.4.0]: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coeftable
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -692,6 +692,31 @@ with two or more breakouts (like `revenue` above) renders a native
|
|
|
692
692
|
because the alternatives share one slotted position and pure CSS shows
|
|
693
693
|
exactly one of them at a time.
|
|
694
694
|
|
|
695
|
+
**Two different percentages appear, and they are not the same number.** Under
|
|
696
|
+
each card's title sits that card's *own* change across the window. On each
|
|
697
|
+
wire sits that child's *contribution* to its parent's change — a
|
|
698
|
+
size-weighted delta for an additive slice, a log share for a factorization —
|
|
699
|
+
and those contributions sum to the parent's own change. The two diverge
|
|
700
|
+
whenever a child is small relative to its parent: a region that grew 90% on
|
|
701
|
+
its own may have moved the total by only 6 points. The card's number answers
|
|
702
|
+
"did this grow?", the wire's answers "how much of the parent's move came from
|
|
703
|
+
here?". A card whose first level is zero or negative shows no own-change,
|
|
704
|
+
since a percentage from that base misleads rather than informs.
|
|
705
|
+
|
|
706
|
+
`op` is optional. Omit it and the operator is derived from the series: a
|
|
707
|
+
genuine sum reconciles under `+` and is off by orders of magnitude under `x`,
|
|
708
|
+
so the numbers already say which was meant. Supplying it asserts that intent
|
|
709
|
+
and is still checked. If neither reading explains the parent, the build
|
|
710
|
+
refuses and names the breakout rather than adopting one and blaming your
|
|
711
|
+
arithmetic for the gap.
|
|
712
|
+
|
|
713
|
+
A child may subtract from its parent. Growth accounting —
|
|
714
|
+
`DAU = retained + new + resurrected − churned`, where churn is negative —
|
|
715
|
+
decomposes correctly. The credibility statistics work in log space and so
|
|
716
|
+
need positive levels; a signed child simply gets no uncertainty ribbon and
|
|
717
|
+
claims no direction, exactly as an injected residual does, rather than
|
|
718
|
+
refusing the whole tree.
|
|
719
|
+
|
|
695
720
|
Each decomposition is checked against its own arithmetic: a parent that
|
|
696
721
|
should equal the sum or product of its children but falls short gets an
|
|
697
722
|
injected `"Unattributed"` residual card (additive shortfalls) or a reported
|
|
@@ -708,12 +733,12 @@ wraps to fit the card using an estimated character width, growing across
|
|
|
708
733
|
as many lines as that estimate needs. Runs of whitespace collapse to a
|
|
709
734
|
single space and line breaks are not preserved, and unusually wide text
|
|
710
735
|
can still be clipped on a line, since the wrap is an estimate rather than
|
|
711
|
-
a font measurement. A literal `{
|
|
736
|
+
a font measurement. A literal `{periods}` placeholder is substituted for the
|
|
712
737
|
observed period count, and `caption` defaults to `None`, so nothing
|
|
713
738
|
renders unless you ask for it.
|
|
714
739
|
If you want a note that edge labels are accounting, not causal
|
|
715
740
|
claims, that wording is yours to choose — for example
|
|
716
|
-
`caption="Edge labels are an accounting of the realized {
|
|
741
|
+
`caption="Edge labels are an accounting of the realized {periods}-week
|
|
717
742
|
change, not causal impact."` `events` fan out to every card named in their
|
|
718
743
|
`affects` tuple, both as sparkline markers and as captions, and the
|
|
719
744
|
report's header is a timeline strip indexing them across the whole canvas.
|
|
@@ -723,6 +748,17 @@ experiments. With neither events nor a `strip_title` there is nothing to
|
|
|
723
748
|
index and no heading to show, so the strip is omitted rather than reserving
|
|
724
749
|
space for a bare axis; supplying either one brings it back.
|
|
725
750
|
|
|
751
|
+
Tick labels on the strip, each event pin's trailing period text, and every
|
|
752
|
+
card's own sparkline x axis all come from one callable, `period_label`: it
|
|
753
|
+
maps a zero-based period index to display text and defaults to
|
|
754
|
+
`coeftable.graph.timeline.default_period_label` (`W1`, `W2`, ...), which is
|
|
755
|
+
why a report built with no `period_label` argument renders exactly the
|
|
756
|
+
weekly labels used above. Pass your own, e.g.
|
|
757
|
+
`period_label=lambda i: f"M{int(i) + 1}"`, and it reaches the strip's ticks,
|
|
758
|
+
its event pins, and every card's axis together — the one parameter that
|
|
759
|
+
makes a report period-neutral, so monthly, daily, or quarterly data renders
|
|
760
|
+
its own correct labels instead of a hardcoded week count.
|
|
761
|
+
|
|
726
762
|
**Switchers can nest.** Every level of the tree may carry its own switcher at
|
|
727
763
|
the same time — revenue by drivers or region, and within drivers, active users
|
|
728
764
|
by funnel or country, and within that, sessions by platform or channel.
|
|
@@ -638,6 +638,31 @@ with two or more breakouts (like `revenue` above) renders a native
|
|
|
638
638
|
because the alternatives share one slotted position and pure CSS shows
|
|
639
639
|
exactly one of them at a time.
|
|
640
640
|
|
|
641
|
+
**Two different percentages appear, and they are not the same number.** Under
|
|
642
|
+
each card's title sits that card's *own* change across the window. On each
|
|
643
|
+
wire sits that child's *contribution* to its parent's change — a
|
|
644
|
+
size-weighted delta for an additive slice, a log share for a factorization —
|
|
645
|
+
and those contributions sum to the parent's own change. The two diverge
|
|
646
|
+
whenever a child is small relative to its parent: a region that grew 90% on
|
|
647
|
+
its own may have moved the total by only 6 points. The card's number answers
|
|
648
|
+
"did this grow?", the wire's answers "how much of the parent's move came from
|
|
649
|
+
here?". A card whose first level is zero or negative shows no own-change,
|
|
650
|
+
since a percentage from that base misleads rather than informs.
|
|
651
|
+
|
|
652
|
+
`op` is optional. Omit it and the operator is derived from the series: a
|
|
653
|
+
genuine sum reconciles under `+` and is off by orders of magnitude under `x`,
|
|
654
|
+
so the numbers already say which was meant. Supplying it asserts that intent
|
|
655
|
+
and is still checked. If neither reading explains the parent, the build
|
|
656
|
+
refuses and names the breakout rather than adopting one and blaming your
|
|
657
|
+
arithmetic for the gap.
|
|
658
|
+
|
|
659
|
+
A child may subtract from its parent. Growth accounting —
|
|
660
|
+
`DAU = retained + new + resurrected − churned`, where churn is negative —
|
|
661
|
+
decomposes correctly. The credibility statistics work in log space and so
|
|
662
|
+
need positive levels; a signed child simply gets no uncertainty ribbon and
|
|
663
|
+
claims no direction, exactly as an injected residual does, rather than
|
|
664
|
+
refusing the whole tree.
|
|
665
|
+
|
|
641
666
|
Each decomposition is checked against its own arithmetic: a parent that
|
|
642
667
|
should equal the sum or product of its children but falls short gets an
|
|
643
668
|
injected `"Unattributed"` residual card (additive shortfalls) or a reported
|
|
@@ -654,12 +679,12 @@ wraps to fit the card using an estimated character width, growing across
|
|
|
654
679
|
as many lines as that estimate needs. Runs of whitespace collapse to a
|
|
655
680
|
single space and line breaks are not preserved, and unusually wide text
|
|
656
681
|
can still be clipped on a line, since the wrap is an estimate rather than
|
|
657
|
-
a font measurement. A literal `{
|
|
682
|
+
a font measurement. A literal `{periods}` placeholder is substituted for the
|
|
658
683
|
observed period count, and `caption` defaults to `None`, so nothing
|
|
659
684
|
renders unless you ask for it.
|
|
660
685
|
If you want a note that edge labels are accounting, not causal
|
|
661
686
|
claims, that wording is yours to choose — for example
|
|
662
|
-
`caption="Edge labels are an accounting of the realized {
|
|
687
|
+
`caption="Edge labels are an accounting of the realized {periods}-week
|
|
663
688
|
change, not causal impact."` `events` fan out to every card named in their
|
|
664
689
|
`affects` tuple, both as sparkline markers and as captions, and the
|
|
665
690
|
report's header is a timeline strip indexing them across the whole canvas.
|
|
@@ -669,6 +694,17 @@ experiments. With neither events nor a `strip_title` there is nothing to
|
|
|
669
694
|
index and no heading to show, so the strip is omitted rather than reserving
|
|
670
695
|
space for a bare axis; supplying either one brings it back.
|
|
671
696
|
|
|
697
|
+
Tick labels on the strip, each event pin's trailing period text, and every
|
|
698
|
+
card's own sparkline x axis all come from one callable, `period_label`: it
|
|
699
|
+
maps a zero-based period index to display text and defaults to
|
|
700
|
+
`coeftable.graph.timeline.default_period_label` (`W1`, `W2`, ...), which is
|
|
701
|
+
why a report built with no `period_label` argument renders exactly the
|
|
702
|
+
weekly labels used above. Pass your own, e.g.
|
|
703
|
+
`period_label=lambda i: f"M{int(i) + 1}"`, and it reaches the strip's ticks,
|
|
704
|
+
its event pins, and every card's axis together — the one parameter that
|
|
705
|
+
makes a report period-neutral, so monthly, daily, or quarterly data renders
|
|
706
|
+
its own correct labels instead of a hardcoded week count.
|
|
707
|
+
|
|
672
708
|
**Switchers can nest.** Every level of the tree may carry its own switcher at
|
|
673
709
|
the same time — revenue by drivers or region, and within drivers, active users
|
|
674
710
|
by funnel or country, and within that, sessions by platform or channel.
|
|
@@ -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.9.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 9, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
+
from functools import partial
|
|
6
7
|
from itertools import combinations
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
9
10
|
import narwhals as nw
|
|
10
11
|
|
|
11
|
-
from coeftable.grid import assemble_rows, build_grid
|
|
12
|
+
from coeftable.grid import AssembledRows, Grid, assemble_rows, build_grid
|
|
12
13
|
from coeftable.spec import (
|
|
13
14
|
Cell,
|
|
14
15
|
CoefTable,
|
|
@@ -92,30 +93,11 @@ def _check_columns(frame: nw.DataFrame, table: CoefTable) -> None:
|
|
|
92
93
|
)
|
|
93
94
|
|
|
94
95
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
Parameters
|
|
99
|
-
----------
|
|
100
|
-
table
|
|
101
|
-
The specification to resolve.
|
|
96
|
+
def _output_name(column: Column, split: Any) -> str:
|
|
97
|
+
return column.label if split is None else f"{split}{SPLIT_JOINER}{column.label}"
|
|
102
98
|
|
|
103
|
-
Returns
|
|
104
|
-
-------
|
|
105
|
-
Resolved
|
|
106
|
-
Rendered cells plus the layout metadata `render` needs.
|
|
107
|
-
|
|
108
|
-
Raises
|
|
109
|
-
------
|
|
110
|
-
ColumnNotFoundError
|
|
111
|
-
When a named column is absent from the frame.
|
|
112
|
-
TypeError
|
|
113
|
-
When an estimate or bound column is not numeric.
|
|
114
|
-
SpecError
|
|
115
|
-
When the column specification is inconsistent.
|
|
116
|
-
"""
|
|
117
|
-
validate_columns(table.columns)
|
|
118
99
|
|
|
100
|
+
def _validate_layout_label_collisions(table: CoefTable) -> None:
|
|
119
101
|
# A column label colliding with a layout key silently overwrites the
|
|
120
102
|
# layout column in the output frame. Catch it here at spec-check time.
|
|
121
103
|
layout_keys = {n for n in (table.rows, table.nest, table.groups) if n is not None}
|
|
@@ -126,6 +108,8 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
|
|
|
126
108
|
f"(rows/nest/groups key {column.label!r}); choose a different label."
|
|
127
109
|
)
|
|
128
110
|
|
|
111
|
+
|
|
112
|
+
def _validate_layout_key_uniqueness(table: CoefTable) -> None:
|
|
129
113
|
# Two layout roles naming the same column collide in the output frame,
|
|
130
114
|
# which `resolve()` builds keyed by column name -- the later write
|
|
131
115
|
# silently discards the earlier role's values, so the table renders with
|
|
@@ -139,6 +123,8 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
|
|
|
139
123
|
f"{first_key!r}; each layout role needs its own column."
|
|
140
124
|
)
|
|
141
125
|
|
|
126
|
+
|
|
127
|
+
def _validate_sparkline_series_collisions(table: CoefTable) -> None:
|
|
142
128
|
# The overlaid `series` dimension cannot also be a table axis: the
|
|
143
129
|
# table's row structure already spends whichever of these an
|
|
144
130
|
# identity maps to, so naming the same column both ways is
|
|
@@ -156,15 +142,32 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
|
|
|
156
142
|
"overlaid dimension cannot also be a table axis."
|
|
157
143
|
)
|
|
158
144
|
|
|
159
|
-
frame = nw.from_native(table.data, eager_only=True)
|
|
160
|
-
_check_columns(frame, table)
|
|
161
145
|
|
|
162
|
-
|
|
146
|
+
def _validate_spec(table: CoefTable) -> None:
|
|
147
|
+
validate_columns(table.columns)
|
|
148
|
+
_validate_layout_label_collisions(table)
|
|
149
|
+
_validate_layout_key_uniqueness(table)
|
|
150
|
+
_validate_sparkline_series_collisions(table)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _extract_layout_keys(
|
|
154
|
+
frame: nw.DataFrame, table: CoefTable, n: int
|
|
155
|
+
) -> tuple[list[Any], list[Any], list[Any], list[Any]]:
|
|
163
156
|
row_keys = frame[table.rows].to_list() if table.rows else [""] * n
|
|
164
157
|
nest_keys = frame[table.nest].to_list() if table.nest else [None] * n
|
|
165
158
|
group_keys = frame[table.groups].to_list() if table.groups else [None] * n
|
|
166
159
|
split_keys = frame[table.split_columns].to_list() if table.split_columns else [None] * n
|
|
160
|
+
return row_keys, nest_keys, group_keys, split_keys
|
|
161
|
+
|
|
167
162
|
|
|
163
|
+
def _prepare_columns(
|
|
164
|
+
table: CoefTable,
|
|
165
|
+
frame: nw.DataFrame,
|
|
166
|
+
row_keys: list[Any],
|
|
167
|
+
nest_keys: list[Any],
|
|
168
|
+
group_keys: list[Any],
|
|
169
|
+
split_keys: list[Any],
|
|
170
|
+
) -> list[Prepared]:
|
|
168
171
|
scan = Scan(
|
|
169
172
|
frame=frame,
|
|
170
173
|
columns=table.columns,
|
|
@@ -177,35 +180,35 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
|
|
|
177
180
|
groups=table.groups,
|
|
178
181
|
split_columns=table.split_columns,
|
|
179
182
|
)
|
|
180
|
-
|
|
183
|
+
return [column.prepare(scan) for column in table.columns]
|
|
181
184
|
|
|
182
|
-
grid = build_grid(
|
|
183
|
-
row_keys,
|
|
184
|
-
nest_keys,
|
|
185
|
-
group_keys,
|
|
186
|
-
split_keys,
|
|
187
|
-
sort_rows=table.sort_rows,
|
|
188
|
-
has_splits=table.split_columns is not None,
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
def output_name(column: Column, split: Any) -> str:
|
|
192
|
-
return column.label if split is None else f"{split}{SPLIT_JOINER}{column.label}"
|
|
193
185
|
|
|
186
|
+
def _build_display_columns(
|
|
187
|
+
table: CoefTable, grid: Grid
|
|
188
|
+
) -> tuple[list[str], dict[str, str], dict[str, list[str]], list[str]]:
|
|
194
189
|
display_columns: list[str] = []
|
|
195
190
|
labels: dict[str, str] = {}
|
|
196
191
|
spanners: dict[str, list[str]] = {}
|
|
197
192
|
plot_columns: list[str] = []
|
|
198
193
|
for split in grid.splits:
|
|
199
194
|
for column in table.columns:
|
|
200
|
-
name =
|
|
195
|
+
name = _output_name(column, split)
|
|
201
196
|
display_columns.append(name)
|
|
202
197
|
labels[name] = column.label
|
|
203
198
|
if split is not None:
|
|
204
199
|
spanners.setdefault(str(split), []).append(name)
|
|
205
200
|
if isinstance(column, (Forest, Sparkline)):
|
|
206
201
|
plot_columns.append(name)
|
|
202
|
+
return display_columns, labels, spanners, plot_columns
|
|
207
203
|
|
|
208
|
-
|
|
204
|
+
|
|
205
|
+
def _compute_cells(
|
|
206
|
+
table: CoefTable,
|
|
207
|
+
grid: Grid,
|
|
208
|
+
prepared: list[Prepared],
|
|
209
|
+
display_columns: list[str],
|
|
210
|
+
) -> dict[str, list[str]]:
|
|
211
|
+
# One call to `column.cell` per (row, split, column).
|
|
209
212
|
cell_values: dict[str, list[str]] = {name: [] for name in display_columns}
|
|
210
213
|
for position, (row_key, nest_key, identity_group) in enumerate(grid.ordered):
|
|
211
214
|
direction = table.direction_for(str(row_key))
|
|
@@ -213,7 +216,7 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
|
|
|
213
216
|
for split in grid.splits:
|
|
214
217
|
index = grid.source_index.get(((row_key, nest_key, identity_group), split))
|
|
215
218
|
for column, prep in zip(table.columns, prepared, strict=True):
|
|
216
|
-
name =
|
|
219
|
+
name = _output_name(column, split)
|
|
217
220
|
if index is None:
|
|
218
221
|
cell_values[name].append("")
|
|
219
222
|
continue
|
|
@@ -228,8 +231,13 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
|
|
|
228
231
|
theme=table.theme,
|
|
229
232
|
)
|
|
230
233
|
cell_values[name].append(column.cell(ctx))
|
|
234
|
+
return cell_values
|
|
235
|
+
|
|
231
236
|
|
|
232
|
-
|
|
237
|
+
def _build_footer_data(
|
|
238
|
+
table: CoefTable, grid: Grid, prepared: list[Prepared]
|
|
239
|
+
) -> tuple[dict[str, Column], dict[str, Prepared], dict[str, list[list[Any]]], set[str]]:
|
|
240
|
+
# Interleave axis rows wherever a column's domain closes.
|
|
233
241
|
column_by_label: dict[str, Column] = {}
|
|
234
242
|
prepared_by_label: dict[str, Prepared] = {}
|
|
235
243
|
footer_keys: dict[str, list[list[Any]]] = {}
|
|
@@ -250,23 +258,32 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
|
|
|
250
258
|
shared_footer_labels = {
|
|
251
259
|
label for label in footer_keys if prepared_by_label[label].shared_footer
|
|
252
260
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
261
|
+
return column_by_label, prepared_by_label, footer_keys, shared_footer_labels
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _render_footer(
|
|
265
|
+
pending: dict[str, list[Any]],
|
|
266
|
+
*,
|
|
267
|
+
column_by_label: dict[str, Column],
|
|
268
|
+
prepared_by_label: dict[str, Prepared],
|
|
269
|
+
grid: Grid,
|
|
270
|
+
theme: Any,
|
|
271
|
+
) -> dict[str, str]:
|
|
272
|
+
out: dict[str, str] = {}
|
|
273
|
+
for label, keys in pending.items():
|
|
274
|
+
column = column_by_label[label]
|
|
275
|
+
prep = prepared_by_label[label]
|
|
276
|
+
for split_idx, split in enumerate(grid.splits):
|
|
277
|
+
ctx = Footer(prepared=prep, key=keys[split_idx], theme=theme)
|
|
278
|
+
text = column.footer(ctx)
|
|
279
|
+
if text is not None:
|
|
280
|
+
out[_output_name(column, split)] = text
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _build_output_frame(
|
|
285
|
+
table: CoefTable, frame: nw.DataFrame, assembled: AssembledRows, display_columns: list[str]
|
|
286
|
+
) -> Any:
|
|
270
287
|
data: dict[str, list[Any]] = {}
|
|
271
288
|
if table.groups:
|
|
272
289
|
data[table.groups] = assembled.layout_group
|
|
@@ -276,13 +293,81 @@ def resolve(table: CoefTable) -> Resolved: # noqa: C901
|
|
|
276
293
|
data[table.nest] = assembled.layout_nest
|
|
277
294
|
for name in display_columns:
|
|
278
295
|
data[name] = assembled.cells[name]
|
|
296
|
+
return nw.from_dict(data, backend=nw.get_native_namespace(frame)).to_native()
|
|
279
297
|
|
|
298
|
+
|
|
299
|
+
def _build_display_and_markdown_columns(
|
|
300
|
+
table: CoefTable, display_columns: list[str]
|
|
301
|
+
) -> tuple[list[str], list[str]]:
|
|
280
302
|
leading = [c for c in (table.groups, table.rows, table.nest) if c]
|
|
281
303
|
markdown = [c for c in (table.rows, table.nest) if c] + display_columns
|
|
304
|
+
display = [*(leading[1:] if table.groups else leading), *display_columns]
|
|
305
|
+
return display, markdown
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def resolve(table: CoefTable) -> Resolved:
|
|
309
|
+
"""Resolve `table` against its frame.
|
|
310
|
+
|
|
311
|
+
Parameters
|
|
312
|
+
----------
|
|
313
|
+
table
|
|
314
|
+
The specification to resolve.
|
|
315
|
+
|
|
316
|
+
Returns
|
|
317
|
+
-------
|
|
318
|
+
Resolved
|
|
319
|
+
Rendered cells plus the layout metadata `render` needs.
|
|
320
|
+
|
|
321
|
+
Raises
|
|
322
|
+
------
|
|
323
|
+
ColumnNotFoundError
|
|
324
|
+
When a named column is absent from the frame.
|
|
325
|
+
TypeError
|
|
326
|
+
When an estimate or bound column is not numeric.
|
|
327
|
+
SpecError
|
|
328
|
+
When the column specification is inconsistent.
|
|
329
|
+
"""
|
|
330
|
+
_validate_spec(table)
|
|
331
|
+
|
|
332
|
+
frame = nw.from_native(table.data, eager_only=True)
|
|
333
|
+
_check_columns(frame, table)
|
|
334
|
+
|
|
335
|
+
n = len(frame)
|
|
336
|
+
row_keys, nest_keys, group_keys, split_keys = _extract_layout_keys(frame, table, n)
|
|
337
|
+
prepared = _prepare_columns(table, frame, row_keys, nest_keys, group_keys, split_keys)
|
|
338
|
+
|
|
339
|
+
grid = build_grid(
|
|
340
|
+
row_keys,
|
|
341
|
+
nest_keys,
|
|
342
|
+
group_keys,
|
|
343
|
+
split_keys,
|
|
344
|
+
sort_rows=table.sort_rows,
|
|
345
|
+
has_splits=table.split_columns is not None,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
display_columns, labels, spanners, plot_columns = _build_display_columns(table, grid)
|
|
349
|
+
cell_values = _compute_cells(table, grid, prepared, display_columns)
|
|
350
|
+
column_by_label, prepared_by_label, footer_keys, shared_footer_labels = _build_footer_data(
|
|
351
|
+
table, grid, prepared
|
|
352
|
+
)
|
|
353
|
+
render_footer = partial(
|
|
354
|
+
_render_footer,
|
|
355
|
+
column_by_label=column_by_label,
|
|
356
|
+
prepared_by_label=prepared_by_label,
|
|
357
|
+
grid=grid,
|
|
358
|
+
theme=table.theme,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
assembled = assemble_rows(
|
|
362
|
+
grid, display_columns, cell_values, footer_keys, render_footer, shared_footer_labels
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
native_frame = _build_output_frame(table, frame, assembled, display_columns)
|
|
366
|
+
display, markdown = _build_display_and_markdown_columns(table, display_columns)
|
|
282
367
|
|
|
283
368
|
return Resolved(
|
|
284
|
-
frame=
|
|
285
|
-
display_columns=
|
|
369
|
+
frame=native_frame,
|
|
370
|
+
display_columns=display,
|
|
286
371
|
labels=labels,
|
|
287
372
|
spanners=spanners,
|
|
288
373
|
group_column=table.groups,
|
|
@@ -59,19 +59,29 @@ def _canonical(value: object, *, name: str) -> tuple[object, ...]:
|
|
|
59
59
|
|
|
60
60
|
@dataclass(frozen=True, slots=True)
|
|
61
61
|
class Breakout:
|
|
62
|
-
"""One alternative decomposition: a label, its
|
|
62
|
+
"""One alternative decomposition: a label, its cards, and optionally its operator.
|
|
63
|
+
|
|
64
|
+
``op`` may be left as ``None``, in which case the builder derives it from
|
|
65
|
+
the series themselves -- a genuine sum reconciles under ``"+"`` and is off
|
|
66
|
+
by orders of magnitude under ``"x"``, so the data already says which one
|
|
67
|
+
the caller meant. Supplying it explicitly asserts that intent and is still
|
|
68
|
+
checked against the numbers.
|
|
69
|
+
|
|
70
|
+
``children`` keeps its position, so both the fully positional form and the
|
|
71
|
+
keyword form continue to work unchanged.
|
|
72
|
+
"""
|
|
63
73
|
|
|
64
74
|
key: str
|
|
65
75
|
label: str
|
|
66
|
-
op: Op
|
|
67
|
-
children: tuple[str, ...]
|
|
76
|
+
op: Op | None = None
|
|
77
|
+
children: tuple[str, ...] = ()
|
|
68
78
|
|
|
69
79
|
def __post_init__(self) -> None:
|
|
70
80
|
"""Validate the breakout's identity, operator, and child cards."""
|
|
71
81
|
_non_empty_str(self.key, name="Breakout.key")
|
|
72
82
|
_non_empty_str(self.label, name="Breakout.label")
|
|
73
|
-
if self.op not in _OPS:
|
|
74
|
-
raise SpecError(f"Breakout.op must be one of {_OPS}, got {self.op!r}")
|
|
83
|
+
if self.op is not None and self.op not in _OPS:
|
|
84
|
+
raise SpecError(f"Breakout.op must be one of {_OPS} or None, got {self.op!r}")
|
|
75
85
|
children = _canonical(self.children, name="Breakout.children")
|
|
76
86
|
if not children:
|
|
77
87
|
raise SpecError("Breakout.children must not be empty")
|