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.
Files changed (44) hide show
  1. {coeftable-0.3.0 → coeftable-0.4.0}/.github/workflows/publish.yml +18 -19
  2. {coeftable-0.3.0 → coeftable-0.4.0}/.github/workflows/release.yml +37 -5
  3. {coeftable-0.3.0 → coeftable-0.4.0}/.gitignore +2 -0
  4. {coeftable-0.3.0 → coeftable-0.4.0}/CHANGELOG.md +45 -0
  5. {coeftable-0.3.0 → coeftable-0.4.0}/PKG-INFO +96 -4
  6. {coeftable-0.3.0 → coeftable-0.4.0}/README.md +95 -3
  7. {coeftable-0.3.0 → coeftable-0.4.0}/pyproject.toml +7 -1
  8. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/__init__.py +5 -9
  9. coeftable-0.4.0/src/coeftable/_axis.py +55 -0
  10. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/_version.py +2 -2
  11. coeftable-0.4.0/src/coeftable/annotations.py +266 -0
  12. coeftable-0.4.0/src/coeftable/errors.py +9 -0
  13. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/frame.py +18 -3
  14. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/grid.py +48 -54
  15. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/series.py +37 -74
  16. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/spec.py +162 -56
  17. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/svg.py +132 -3
  18. coeftable-0.4.0/tests/test_annotations.py +200 -0
  19. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_frame.py +324 -15
  20. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_public_api.py +2 -0
  21. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_series.py +71 -30
  22. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_sparkline.py +531 -6
  23. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_svg.py +266 -1
  24. {coeftable-0.3.0 → coeftable-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  25. {coeftable-0.3.0 → coeftable-0.4.0}/.github/release.yml +0 -0
  26. {coeftable-0.3.0 → coeftable-0.4.0}/.github/workflows/ci.yml +0 -0
  27. {coeftable-0.3.0 → coeftable-0.4.0}/.github/workflows/post-release.yml +0 -0
  28. {coeftable-0.3.0 → coeftable-0.4.0}/.pre-commit-config.yaml +0 -0
  29. {coeftable-0.3.0 → coeftable-0.4.0}/LICENSE +0 -0
  30. {coeftable-0.3.0 → coeftable-0.4.0}/Makefile +0 -0
  31. {coeftable-0.3.0 → coeftable-0.4.0}/docs/images/example.png +0 -0
  32. {coeftable-0.3.0 → coeftable-0.4.0}/docs/images/trend-example.png +0 -0
  33. {coeftable-0.3.0 → coeftable-0.4.0}/noxfile.py +0 -0
  34. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/collapsible.py +0 -0
  35. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/format.py +0 -0
  36. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/render.py +0 -0
  37. {coeftable-0.3.0 → coeftable-0.4.0}/src/coeftable/theme.py +0 -0
  38. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_collapsible.py +0 -0
  39. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_format.py +0 -0
  40. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_package.py +0 -0
  41. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_render.py +0 -0
  42. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_spec.py +0 -0
  43. {coeftable-0.3.0 → coeftable-0.4.0}/tests/test_theme.py +0 -0
  44. {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 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
@@ -18,3 +18,5 @@ src/coeftable/_version.py
18
18
  /.roborev/
19
19
  # internal planning docs (not shipped, not for the public repo)
20
20
  docs/superpowers/
21
+ # local feature worktrees
22
+ /.worktrees/
@@ -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.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. 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
@@ -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. 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
@@ -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.25", "hatch-vcs>=0.4"]
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.3.0'
22
- __version_tuple__ = version_tuple = (0, 3, 0)
21
+ __version__ = version = '0.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None