coeftable 0.3.0__tar.gz → 0.4.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.3.0 → coeftable-0.4.0}/.github/workflows/publish.yml +18 -19
- {coeftable-0.3.0 → coeftable-0.4.0}/.github/workflows/release.yml +37 -5
- {coeftable-0.3.0 → coeftable-0.4.0}/.gitignore +2 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/CHANGELOG.md +45 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/PKG-INFO +96 -4
- {coeftable-0.3.0 → coeftable-0.4.0}/README.md +95 -3
- {coeftable-0.3.0 → coeftable-0.4.0}/pyproject.toml +7 -1
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/__init__.py +5 -9
- coeftable-0.4.0/src/coeftable/_axis.py +55 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/_version.py +2 -2
- coeftable-0.4.0/src/coeftable/annotations.py +266 -0
- coeftable-0.4.0/src/coeftable/errors.py +9 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/frame.py +18 -3
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/grid.py +48 -54
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/series.py +37 -74
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/spec.py +162 -56
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/svg.py +132 -3
- coeftable-0.4.0/tests/test_annotations.py +200 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_frame.py +324 -15
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_public_api.py +2 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_series.py +71 -30
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_sparkline.py +531 -6
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_svg.py +266 -1
- {coeftable-0.3.0 → coeftable-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/.github/release.yml +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/.github/workflows/ci.yml +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/.github/workflows/post-release.yml +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/.pre-commit-config.yaml +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/LICENSE +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/Makefile +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/docs/images/example.png +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/docs/images/trend-example.png +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/noxfile.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/collapsible.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/format.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/render.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/theme.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_collapsible.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_format.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_package.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_render.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_spec.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_theme.py +0 -0
- {coeftable-0.3.0 → coeftable-0.4.0}/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,46 @@
|
|
|
1
|
+
## Unreleased
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
- Add typed rule and band annotations to forest plots and sparklines, including row-specific field binding and domain-aware layering.
|
|
5
|
+
|
|
6
|
+
<a id="v0.3.1"></a>
|
|
7
|
+
# [v0.3.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.1) - 2026-08-14
|
|
8
|
+
|
|
9
|
+
<!-- Release notes generated using configuration in .github/release.yml at v0.3.1 -->
|
|
10
|
+
|
|
11
|
+
## What's Changed
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
* 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)
|
|
14
|
+
* Build core metadata 2.4 so releases pass validation by [@kylejcaron](https://github.com/kylejcaron) in [#27](https://github.com/kylejcaron/coeftable/pull/27)
|
|
15
|
+
### Maintenance
|
|
16
|
+
* Update changelog for v0.3.0 by [@github-actions](https://github.com/github-actions)[bot] in [#21](https://github.com/kylejcaron/coeftable/pull/21)
|
|
17
|
+
* Fix automated releases: PyPI Trusted Publishing rejects reusable workflows by [@kylejcaron](https://github.com/kylejcaron) in [#22](https://github.com/kylejcaron/coeftable/pull/22)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
**Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
|
|
21
|
+
|
|
22
|
+
[Changes][v0.3.1]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
<a id="v0.3.0"></a>
|
|
26
|
+
# [v0.3.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.0) - 2026-08-05
|
|
27
|
+
|
|
28
|
+
<!-- Release notes generated using configuration in .github/release.yml at v0.3.0 -->
|
|
29
|
+
|
|
30
|
+
## What's Changed
|
|
31
|
+
### Maintenance
|
|
32
|
+
* Update changelog for v0.2.1 by [@github-actions](https://github.com/github-actions)[bot] in [#13](https://github.com/kylejcaron/coeftable/pull/13)
|
|
33
|
+
* 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)
|
|
34
|
+
* Add collapsible group sections by [@kylejcaron](https://github.com/kylejcaron) in [#18](https://github.com/kylejcaron/coeftable/pull/18)
|
|
35
|
+
* 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)
|
|
36
|
+
* Support multiple overlaid series in one sparkline cell by [@kylejcaron](https://github.com/kylejcaron) in [#20](https://github.com/kylejcaron/coeftable/pull/20)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
**Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
|
|
40
|
+
|
|
41
|
+
[Changes][v0.3.0]
|
|
42
|
+
|
|
43
|
+
|
|
1
44
|
<a id="v0.2.1"></a>
|
|
2
45
|
# [v0.2.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.2.1) - 2026-08-05
|
|
3
46
|
|
|
@@ -54,6 +97,8 @@
|
|
|
54
97
|
[Changes][v0.1.0]
|
|
55
98
|
|
|
56
99
|
|
|
100
|
+
[v0.3.1]: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
|
|
101
|
+
[v0.3.0]: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
|
|
57
102
|
[v0.2.1]: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
|
|
58
103
|
[v0.2.0]: https://github.com/kylejcaron/coeftable/compare/v0.1.0...v0.2.0
|
|
59
104
|
[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
|
+
Version: 0.4.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
|
|
@@ -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
|
|
@@ -403,3 +439,59 @@ absolute = pl.DataFrame(
|
|
|
403
439
|
.sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
|
|
404
440
|
)
|
|
405
441
|
```
|
|
442
|
+
|
|
443
|
+
## Plot annotations
|
|
444
|
+
|
|
445
|
+
`ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
|
|
446
|
+
sparkline. A numeric, date, or datetime coordinate is a literal; a string is
|
|
447
|
+
the name of a scalar column on the main table frame. A missing field value
|
|
448
|
+
leaves that annotation out of that row, which makes row-specific marks
|
|
449
|
+
possible:
|
|
450
|
+
|
|
451
|
+
```python
|
|
452
|
+
import polars as pl
|
|
453
|
+
import coeftable as ct
|
|
454
|
+
|
|
455
|
+
annotated = pl.DataFrame(
|
|
456
|
+
{
|
|
457
|
+
"metric": ["Revenue", "Latency"],
|
|
458
|
+
"estimate": [1.2, -0.4],
|
|
459
|
+
"lower": [0.8, -0.8],
|
|
460
|
+
"upper": [1.6, 0.1],
|
|
461
|
+
# Only Revenue receives the second vertical rule.
|
|
462
|
+
"target": [1.5, None],
|
|
463
|
+
"trend": [[1.0, 1.2, 1.4], [-0.1, -0.3, -0.4]],
|
|
464
|
+
"guard_low": [0.9, -0.6],
|
|
465
|
+
"guard_high": [1.6, 0.0],
|
|
466
|
+
}
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
(
|
|
470
|
+
ct.CoefTable(annotated, rows="metric")
|
|
471
|
+
.estimate("Effect", "estimate", ci=("lower", "upper"))
|
|
472
|
+
.forest(
|
|
473
|
+
"Effect plot",
|
|
474
|
+
of="Effect",
|
|
475
|
+
annotations=(ct.Rule("target", axis="x"),),
|
|
476
|
+
)
|
|
477
|
+
.sparkline(
|
|
478
|
+
"Trend",
|
|
479
|
+
value="trend",
|
|
480
|
+
annotations=(ct.Band("guard_low", "guard_high", axis="y"),),
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
Forest annotations use `axis="x"` only. Sparklines accept `axis="x"` and
|
|
486
|
+
`axis="y"`; use the former for a shared time or sequence position and the
|
|
487
|
+
latter for a value threshold or range. `layer="underlay"` (the default for
|
|
488
|
+
bands) draws before the plot; `layer="overlay"` (the default for rules) draws
|
|
489
|
+
after it. `affect_domain=True` by default expands an automatic axis domain to
|
|
490
|
+
include the annotation; set it to `False` to keep the existing domain and
|
|
491
|
+
allow the mark to be clipped. `ylim` overrides the Forest x-domain and the
|
|
492
|
+
Sparkline y-domain, so annotations on those axes do not expand them. Sparkline
|
|
493
|
+
x annotations still participate in their shared x-domain; `max_ylim` can cap
|
|
494
|
+
the Sparkline y-domain and clip or omit distant marks. Annotations supplement
|
|
495
|
+
rather than replace
|
|
496
|
+
`ref`: `ref` remains the built-in semantic reference that controls colors and
|
|
497
|
+
its optional dashed line.
|
|
@@ -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
|
|
@@ -349,3 +385,59 @@ absolute = pl.DataFrame(
|
|
|
349
385
|
.sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
|
|
350
386
|
)
|
|
351
387
|
```
|
|
388
|
+
|
|
389
|
+
## Plot annotations
|
|
390
|
+
|
|
391
|
+
`ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
|
|
392
|
+
sparkline. A numeric, date, or datetime coordinate is a literal; a string is
|
|
393
|
+
the name of a scalar column on the main table frame. A missing field value
|
|
394
|
+
leaves that annotation out of that row, which makes row-specific marks
|
|
395
|
+
possible:
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
import polars as pl
|
|
399
|
+
import coeftable as ct
|
|
400
|
+
|
|
401
|
+
annotated = pl.DataFrame(
|
|
402
|
+
{
|
|
403
|
+
"metric": ["Revenue", "Latency"],
|
|
404
|
+
"estimate": [1.2, -0.4],
|
|
405
|
+
"lower": [0.8, -0.8],
|
|
406
|
+
"upper": [1.6, 0.1],
|
|
407
|
+
# Only Revenue receives the second vertical rule.
|
|
408
|
+
"target": [1.5, None],
|
|
409
|
+
"trend": [[1.0, 1.2, 1.4], [-0.1, -0.3, -0.4]],
|
|
410
|
+
"guard_low": [0.9, -0.6],
|
|
411
|
+
"guard_high": [1.6, 0.0],
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
(
|
|
416
|
+
ct.CoefTable(annotated, rows="metric")
|
|
417
|
+
.estimate("Effect", "estimate", ci=("lower", "upper"))
|
|
418
|
+
.forest(
|
|
419
|
+
"Effect plot",
|
|
420
|
+
of="Effect",
|
|
421
|
+
annotations=(ct.Rule("target", axis="x"),),
|
|
422
|
+
)
|
|
423
|
+
.sparkline(
|
|
424
|
+
"Trend",
|
|
425
|
+
value="trend",
|
|
426
|
+
annotations=(ct.Band("guard_low", "guard_high", axis="y"),),
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Forest annotations use `axis="x"` only. Sparklines accept `axis="x"` and
|
|
432
|
+
`axis="y"`; use the former for a shared time or sequence position and the
|
|
433
|
+
latter for a value threshold or range. `layer="underlay"` (the default for
|
|
434
|
+
bands) draws before the plot; `layer="overlay"` (the default for rules) draws
|
|
435
|
+
after it. `affect_domain=True` by default expands an automatic axis domain to
|
|
436
|
+
include the annotation; set it to `False` to keep the existing domain and
|
|
437
|
+
allow the mark to be clipped. `ylim` overrides the Forest x-domain and the
|
|
438
|
+
Sparkline y-domain, so annotations on those axes do not expand them. Sparkline
|
|
439
|
+
x annotations still participate in their shared x-domain; `max_ylim` can cap
|
|
440
|
+
the Sparkline y-domain and clip or omit distant marks. Annotations supplement
|
|
441
|
+
rather than replace
|
|
442
|
+
`ref`: `ref` remains the built-in semantic reference that controls colors and
|
|
443
|
+
its optional dashed line.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["hatchling>=1.
|
|
2
|
+
requires = ["hatchling>=1.27", "hatch-vcs>=0.4"]
|
|
3
3
|
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
@@ -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"
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from importlib.metadata import PackageNotFoundError, version
|
|
4
4
|
|
|
5
|
+
from coeftable.annotations import Band, Rule
|
|
6
|
+
from coeftable.errors import ColumnNotFoundError, SpecError
|
|
5
7
|
from coeftable.format import (
|
|
6
8
|
CalendarStep,
|
|
7
9
|
CIStyle,
|
|
@@ -11,15 +13,7 @@ from coeftable.format import (
|
|
|
11
13
|
Percent,
|
|
12
14
|
TimeFormat,
|
|
13
15
|
)
|
|
14
|
-
from coeftable.spec import
|
|
15
|
-
CoefTable,
|
|
16
|
-
ColumnNotFoundError,
|
|
17
|
-
Estimate,
|
|
18
|
-
Forest,
|
|
19
|
-
Passthrough,
|
|
20
|
-
Sparkline,
|
|
21
|
-
SpecError,
|
|
22
|
-
)
|
|
16
|
+
from coeftable.spec import CoefTable, Estimate, Forest, Passthrough, Sparkline
|
|
23
17
|
from coeftable.theme import Theme, role_for
|
|
24
18
|
|
|
25
19
|
try:
|
|
@@ -28,6 +22,7 @@ except PackageNotFoundError: # pragma: no cover
|
|
|
28
22
|
__version__ = "0.0.0.dev0"
|
|
29
23
|
|
|
30
24
|
__all__ = [
|
|
25
|
+
"Band",
|
|
31
26
|
"CIStyle",
|
|
32
27
|
"CalendarStep",
|
|
33
28
|
"CoefTable",
|
|
@@ -39,6 +34,7 @@ __all__ = [
|
|
|
39
34
|
"Number",
|
|
40
35
|
"Passthrough",
|
|
41
36
|
"Percent",
|
|
37
|
+
"Rule",
|
|
42
38
|
"Sparkline",
|
|
43
39
|
"SpecError",
|
|
44
40
|
"Theme",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared axis coercion helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# Deliberately naive: paired with elapsed-time subtraction in _epoch_seconds
|
|
11
|
+
# so relative spacing never depends on the host machine's local timezone.
|
|
12
|
+
_EPOCH = datetime.datetime(1970, 1, 1)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _epoch_seconds(value: datetime.date) -> float:
|
|
16
|
+
"""Convert a date/datetime to seconds since the Unix epoch.
|
|
17
|
+
|
|
18
|
+
A timezone-aware `datetime` is converted to UTC first; a naive
|
|
19
|
+
`datetime` (or a plain `date`, read as midnight) is measured as an
|
|
20
|
+
elapsed-time delta from a naive epoch, never through
|
|
21
|
+
`datetime.timestamp()` -- which reads a naive value against the host's
|
|
22
|
+
*local* timezone and would make relative spacing depend on where the
|
|
23
|
+
code runs.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(value, datetime.datetime):
|
|
26
|
+
if value.tzinfo is not None:
|
|
27
|
+
value = value.astimezone(datetime.UTC).replace(tzinfo=None)
|
|
28
|
+
return (value - _EPOCH).total_seconds()
|
|
29
|
+
return (datetime.datetime(value.year, value.month, value.day) - _EPOCH).total_seconds()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _detect_temporal(values: Iterable[Any]) -> bool:
|
|
33
|
+
"""Return True when the first non-missing value is a date or datetime."""
|
|
34
|
+
for value in values:
|
|
35
|
+
if value is not None:
|
|
36
|
+
return isinstance(value, datetime.date)
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce_temporal(values: Iterable[Any]) -> list[float | None]:
|
|
41
|
+
"""Coerce raw date/datetime values to epoch seconds, `None` for missing.
|
|
42
|
+
|
|
43
|
+
A bare `None` is missing directly; pandas' `NaT` is not `None` but is
|
|
44
|
+
still an `isinstance(..., datetime.datetime)` whose epoch delta
|
|
45
|
+
degenerates to NaN rather than raising, so the result is checked for
|
|
46
|
+
NaN too -- mirroring `coerce_numeric`'s own missing-value handling.
|
|
47
|
+
"""
|
|
48
|
+
out: list[float | None] = []
|
|
49
|
+
for value in values:
|
|
50
|
+
if value is None:
|
|
51
|
+
out.append(None)
|
|
52
|
+
continue
|
|
53
|
+
seconds = _epoch_seconds(value)
|
|
54
|
+
out.append(None if math.isnan(seconds) else seconds)
|
|
55
|
+
return out
|
|
@@ -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.4.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 4, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|