coeftable 0.3.1__tar.gz → 0.5.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 (46) hide show
  1. {coeftable-0.3.1 → coeftable-0.5.0}/.gitignore +2 -0
  2. coeftable-0.5.0/AGENTS.md +7 -0
  3. {coeftable-0.3.1 → coeftable-0.5.0}/CHANGELOG.md +17 -0
  4. {coeftable-0.3.1 → coeftable-0.5.0}/PKG-INFO +87 -1
  5. {coeftable-0.3.1 → coeftable-0.5.0}/README.md +86 -0
  6. coeftable-0.5.0/docs/images/forest-robust-autoscale.png +0 -0
  7. {coeftable-0.3.1 → coeftable-0.5.0}/pyproject.toml +1 -1
  8. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/__init__.py +5 -9
  9. coeftable-0.5.0/src/coeftable/_axis.py +55 -0
  10. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/_version.py +2 -2
  11. coeftable-0.5.0/src/coeftable/annotations.py +266 -0
  12. coeftable-0.5.0/src/coeftable/errors.py +9 -0
  13. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/series.py +2 -51
  14. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/spec.py +173 -57
  15. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/svg.py +222 -21
  16. coeftable-0.5.0/tests/test_annotations.py +200 -0
  17. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_frame.py +311 -1
  18. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_public_api.py +2 -0
  19. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_series.py +25 -0
  20. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_sparkline.py +426 -6
  21. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_svg.py +302 -1
  22. {coeftable-0.3.1 → coeftable-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  23. {coeftable-0.3.1 → coeftable-0.5.0}/.github/release.yml +0 -0
  24. {coeftable-0.3.1 → coeftable-0.5.0}/.github/workflows/ci.yml +0 -0
  25. {coeftable-0.3.1 → coeftable-0.5.0}/.github/workflows/post-release.yml +0 -0
  26. {coeftable-0.3.1 → coeftable-0.5.0}/.github/workflows/publish.yml +0 -0
  27. {coeftable-0.3.1 → coeftable-0.5.0}/.github/workflows/release.yml +0 -0
  28. {coeftable-0.3.1 → coeftable-0.5.0}/.pre-commit-config.yaml +0 -0
  29. {coeftable-0.3.1 → coeftable-0.5.0}/LICENSE +0 -0
  30. {coeftable-0.3.1 → coeftable-0.5.0}/Makefile +0 -0
  31. {coeftable-0.3.1 → coeftable-0.5.0}/docs/images/example.png +0 -0
  32. {coeftable-0.3.1 → coeftable-0.5.0}/docs/images/trend-example.png +0 -0
  33. {coeftable-0.3.1 → coeftable-0.5.0}/noxfile.py +0 -0
  34. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/collapsible.py +0 -0
  35. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/format.py +0 -0
  36. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/frame.py +0 -0
  37. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/grid.py +0 -0
  38. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/render.py +0 -0
  39. {coeftable-0.3.1 → coeftable-0.5.0}/src/coeftable/theme.py +0 -0
  40. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_collapsible.py +0 -0
  41. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_format.py +0 -0
  42. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_package.py +0 -0
  43. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_render.py +0 -0
  44. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_spec.py +0 -0
  45. {coeftable-0.3.1 → coeftable-0.5.0}/tests/test_theme.py +0 -0
  46. {coeftable-0.3.1 → coeftable-0.5.0}/uv.lock +0 -0
@@ -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/
@@ -0,0 +1,7 @@
1
+ # Agent guidelines
2
+
3
+ ## Changelog
4
+
5
+ Never edit `CHANGELOG.md`. It is generated by release automation
6
+ (`.github/release.yml`) from merged PR titles; Describe changes in the PR
7
+ description instead.
@@ -1,3 +1,19 @@
1
+ <a id="v0.4.0"></a>
2
+ # [v0.4.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.4.0) - 2026-08-14
3
+
4
+ <!-- Release notes generated using configuration in .github/release.yml at v0.4.0 -->
5
+
6
+ ## What's Changed
7
+ ### Maintenance
8
+ * build: align hatchling metadata floor by [@kylejcaron](https://github.com/kylejcaron) in [#30](https://github.com/kylejcaron/coeftable/pull/30)
9
+ * feature: support plot annotations by [@kylejcaron](https://github.com/kylejcaron) in [#31](https://github.com/kylejcaron/coeftable/pull/31)
10
+
11
+
12
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
13
+
14
+ [Changes][v0.4.0]
15
+
16
+
1
17
  <a id="v0.3.1"></a>
2
18
  # [v0.3.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.1) - 2026-08-14
3
19
 
@@ -92,6 +108,7 @@
92
108
  [Changes][v0.1.0]
93
109
 
94
110
 
111
+ [v0.4.0]: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
95
112
  [v0.3.1]: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
96
113
  [v0.3.0]: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
97
114
  [v0.2.1]: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coeftable
3
- Version: 0.3.1
3
+ Version: 0.5.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
@@ -414,6 +414,36 @@ trend = pl.DataFrame(
414
414
  )
415
415
  ```
416
416
 
417
+ **Forest plots take `autoscale` too.** A single extreme interval (say a
418
+ +3000% lift among sub-1% metrics) otherwise stretches the shared domain
419
+ and flattens every other bar:
420
+
421
+ ```python
422
+ lifts = pl.DataFrame(
423
+ {
424
+ "metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
425
+ "rel": [0.03, -0.05, 0.08, 0.02, 30.0],
426
+ "rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
427
+ "rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
428
+ }
429
+ )
430
+
431
+ (
432
+ ct.CoefTable(lifts, rows="metric")
433
+ .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
434
+ # "robust" fits an IQR/Tukey fence over the pooled values instead of
435
+ # the raw min/max, so the outlier stops dictating the axis. Composes
436
+ # with symmetric= (fence first, then mirror around ref); ylim= still
437
+ # overrides everything.
438
+ .forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
439
+ )
440
+ ```
441
+
442
+ A discounted interval is never hidden: bars in a clipping domain plot
443
+ against a slightly inset region and the clipped interval continues into
444
+ the reserved margin as a gradient fade, reading as "extends beyond the
445
+ axis". Buckets with nothing clipped render exactly as before.
446
+
417
447
  **No reference, or a hidden one.** `ref` also drives the dashed line and
418
448
  colour resolution; two ways to opt out, for data with no meaningful
419
449
  zero (revenue, durations, absolute counts):
@@ -439,3 +469,59 @@ absolute = pl.DataFrame(
439
469
  .sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
440
470
  )
441
471
  ```
472
+
473
+ ## Plot annotations
474
+
475
+ `ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
476
+ sparkline. A numeric, date, or datetime coordinate is a literal; a string is
477
+ the name of a scalar column on the main table frame. A missing field value
478
+ leaves that annotation out of that row, which makes row-specific marks
479
+ possible:
480
+
481
+ ```python
482
+ import polars as pl
483
+ import coeftable as ct
484
+
485
+ annotated = pl.DataFrame(
486
+ {
487
+ "metric": ["Revenue", "Latency"],
488
+ "estimate": [1.2, -0.4],
489
+ "lower": [0.8, -0.8],
490
+ "upper": [1.6, 0.1],
491
+ # Only Revenue receives the second vertical rule.
492
+ "target": [1.5, None],
493
+ "trend": [[1.0, 1.2, 1.4], [-0.1, -0.3, -0.4]],
494
+ "guard_low": [0.9, -0.6],
495
+ "guard_high": [1.6, 0.0],
496
+ }
497
+ )
498
+
499
+ (
500
+ ct.CoefTable(annotated, rows="metric")
501
+ .estimate("Effect", "estimate", ci=("lower", "upper"))
502
+ .forest(
503
+ "Effect plot",
504
+ of="Effect",
505
+ annotations=(ct.Rule("target", axis="x"),),
506
+ )
507
+ .sparkline(
508
+ "Trend",
509
+ value="trend",
510
+ annotations=(ct.Band("guard_low", "guard_high", axis="y"),),
511
+ )
512
+ )
513
+ ```
514
+
515
+ Forest annotations use `axis="x"` only. Sparklines accept `axis="x"` and
516
+ `axis="y"`; use the former for a shared time or sequence position and the
517
+ latter for a value threshold or range. `layer="underlay"` (the default for
518
+ bands) draws before the plot; `layer="overlay"` (the default for rules) draws
519
+ after it. `affect_domain=True` by default expands an automatic axis domain to
520
+ include the annotation; set it to `False` to keep the existing domain and
521
+ allow the mark to be clipped. `ylim` overrides the Forest x-domain and the
522
+ Sparkline y-domain, so annotations on those axes do not expand them. Sparkline
523
+ x annotations still participate in their shared x-domain; `max_ylim` can cap
524
+ the Sparkline y-domain and clip or omit distant marks. Annotations supplement
525
+ rather than replace
526
+ `ref`: `ref` remains the built-in semantic reference that controls colors and
527
+ its optional dashed line.
@@ -360,6 +360,36 @@ trend = pl.DataFrame(
360
360
  )
361
361
  ```
362
362
 
363
+ **Forest plots take `autoscale` too.** A single extreme interval (say a
364
+ +3000% lift among sub-1% metrics) otherwise stretches the shared domain
365
+ and flattens every other bar:
366
+
367
+ ```python
368
+ lifts = pl.DataFrame(
369
+ {
370
+ "metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
371
+ "rel": [0.03, -0.05, 0.08, 0.02, 30.0],
372
+ "rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
373
+ "rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
374
+ }
375
+ )
376
+
377
+ (
378
+ ct.CoefTable(lifts, rows="metric")
379
+ .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
380
+ # "robust" fits an IQR/Tukey fence over the pooled values instead of
381
+ # the raw min/max, so the outlier stops dictating the axis. Composes
382
+ # with symmetric= (fence first, then mirror around ref); ylim= still
383
+ # overrides everything.
384
+ .forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
385
+ )
386
+ ```
387
+
388
+ A discounted interval is never hidden: bars in a clipping domain plot
389
+ against a slightly inset region and the clipped interval continues into
390
+ the reserved margin as a gradient fade, reading as "extends beyond the
391
+ axis". Buckets with nothing clipped render exactly as before.
392
+
363
393
  **No reference, or a hidden one.** `ref` also drives the dashed line and
364
394
  colour resolution; two ways to opt out, for data with no meaningful
365
395
  zero (revenue, durations, absolute counts):
@@ -385,3 +415,59 @@ absolute = pl.DataFrame(
385
415
  .sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
386
416
  )
387
417
  ```
418
+
419
+ ## Plot annotations
420
+
421
+ `ct.Rule` draws a line and `ct.Band` shades an interval in a forest plot or
422
+ sparkline. A numeric, date, or datetime coordinate is a literal; a string is
423
+ the name of a scalar column on the main table frame. A missing field value
424
+ leaves that annotation out of that row, which makes row-specific marks
425
+ possible:
426
+
427
+ ```python
428
+ import polars as pl
429
+ import coeftable as ct
430
+
431
+ annotated = pl.DataFrame(
432
+ {
433
+ "metric": ["Revenue", "Latency"],
434
+ "estimate": [1.2, -0.4],
435
+ "lower": [0.8, -0.8],
436
+ "upper": [1.6, 0.1],
437
+ # Only Revenue receives the second vertical rule.
438
+ "target": [1.5, None],
439
+ "trend": [[1.0, 1.2, 1.4], [-0.1, -0.3, -0.4]],
440
+ "guard_low": [0.9, -0.6],
441
+ "guard_high": [1.6, 0.0],
442
+ }
443
+ )
444
+
445
+ (
446
+ ct.CoefTable(annotated, rows="metric")
447
+ .estimate("Effect", "estimate", ci=("lower", "upper"))
448
+ .forest(
449
+ "Effect plot",
450
+ of="Effect",
451
+ annotations=(ct.Rule("target", axis="x"),),
452
+ )
453
+ .sparkline(
454
+ "Trend",
455
+ value="trend",
456
+ annotations=(ct.Band("guard_low", "guard_high", axis="y"),),
457
+ )
458
+ )
459
+ ```
460
+
461
+ Forest annotations use `axis="x"` only. Sparklines accept `axis="x"` and
462
+ `axis="y"`; use the former for a shared time or sequence position and the
463
+ latter for a value threshold or range. `layer="underlay"` (the default for
464
+ bands) draws before the plot; `layer="overlay"` (the default for rules) draws
465
+ after it. `affect_domain=True` by default expands an automatic axis domain to
466
+ include the annotation; set it to `False` to keep the existing domain and
467
+ allow the mark to be clipped. `ylim` overrides the Forest x-domain and the
468
+ Sparkline y-domain, so annotations on those axes do not expand them. Sparkline
469
+ x annotations still participate in their shared x-domain; `max_ylim` can cap
470
+ the Sparkline y-domain and clip or omit distant marks. Annotations supplement
471
+ rather than replace
472
+ `ref`: `ref` remains the built-in semantic reference that controls colors and
473
+ 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]
@@ -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.1'
22
- __version_tuple__ = version_tuple = (0, 3, 1)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,266 @@
1
+ """Typed plot-annotation declarations and frame-aware resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import math
7
+ from collections.abc import Mapping, Sequence
8
+ from dataclasses import dataclass
9
+ from typing import Any, Literal
10
+
11
+ import narwhals as nw
12
+
13
+ from coeftable._axis import _coerce_temporal
14
+ from coeftable.errors import SpecError
15
+ from coeftable.format import coerce_numeric
16
+
17
+ type Axis = Literal["x", "y"]
18
+ type AxisKind = Literal["numeric", "temporal"]
19
+ type Layer = Literal["underlay", "overlay"]
20
+ type Dash = Literal["solid", "dashed", "dotted"]
21
+ type AnnotationSource = int | float | dt.date | dt.datetime | str
22
+
23
+
24
+ def _validate_coordinate(value: AnnotationSource, *, name: str) -> None:
25
+ """Validate a declaration's literal-or-field coordinate."""
26
+ if isinstance(value, bool) or not isinstance(value, (int, float, dt.date, str)):
27
+ raise SpecError(f"Annotation {name} must be a numeric/temporal literal or field name.")
28
+ if isinstance(value, float) and not math.isfinite(value):
29
+ raise SpecError(f"Annotation {name} must be finite.")
30
+
31
+
32
+ def _validate_unit_interval(value: float, *, name: str) -> None:
33
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
34
+ raise SpecError(f"Annotation {name} must be a finite number between 0 and 1.")
35
+ if not 0.0 <= value <= 1.0:
36
+ raise SpecError(f"Annotation {name} must be between 0 and 1.")
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Rule:
41
+ """A line annotation at one axis coordinate."""
42
+
43
+ at: AnnotationSource
44
+ axis: Axis
45
+ layer: Layer = "overlay"
46
+ affect_domain: bool = True
47
+ color: str | None = None
48
+ opacity: float = 1.0
49
+ width: float = 1.0
50
+ dash: Dash = "dashed"
51
+
52
+ def __post_init__(self) -> None:
53
+ """Validate declaration-only rule styling and coordinates."""
54
+ _validate_coordinate(self.at, name="coordinate")
55
+ if self.layer not in ("underlay", "overlay"):
56
+ raise SpecError(
57
+ f"Annotation layer must be 'underlay' or 'overlay'; got {self.layer!r}."
58
+ )
59
+ _validate_unit_interval(self.opacity, name="opacity")
60
+ if isinstance(self.width, bool) or not isinstance(self.width, (int, float)):
61
+ raise SpecError("Annotation width must be a finite positive number.")
62
+ if not math.isfinite(self.width) or self.width <= 0:
63
+ raise SpecError("Annotation width must be a finite positive number.")
64
+ if self.dash not in ("solid", "dashed", "dotted"):
65
+ raise SpecError(
66
+ f"Annotation dash must be 'solid', 'dashed', or 'dotted'; got {self.dash!r}."
67
+ )
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Band:
72
+ """A shaded interval annotation between two axis coordinates."""
73
+
74
+ start: AnnotationSource
75
+ end: AnnotationSource
76
+ axis: Axis
77
+ layer: Layer = "underlay"
78
+ affect_domain: bool = True
79
+ color: str | None = None
80
+ opacity: float = 0.12
81
+
82
+ def __post_init__(self) -> None:
83
+ """Validate declaration-only band styling and coordinates."""
84
+ _validate_coordinate(self.start, name="start")
85
+ _validate_coordinate(self.end, name="end")
86
+ if self.layer not in ("underlay", "overlay"):
87
+ raise SpecError(
88
+ f"Annotation layer must be 'underlay' or 'overlay'; got {self.layer!r}."
89
+ )
90
+ _validate_unit_interval(self.opacity, name="opacity")
91
+
92
+
93
+ type Annotation = Rule | Band
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class ResolvedRule:
98
+ """A fully resolved line annotation."""
99
+
100
+ at: float
101
+ axis: Axis
102
+ layer: Layer
103
+ affect_domain: bool
104
+ color: str | None
105
+ opacity: float
106
+ width: float
107
+ dash: Dash
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class ResolvedBand:
112
+ """A fully resolved interval annotation."""
113
+
114
+ start: float
115
+ end: float
116
+ axis: Axis
117
+ layer: Layer
118
+ affect_domain: bool
119
+ color: str | None
120
+ opacity: float
121
+
122
+
123
+ type ResolvedAnnotation = ResolvedRule | ResolvedBand
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class PreparedAnnotations:
128
+ """Resolved annotations grouped in frame-row order."""
129
+
130
+ by_row: tuple[tuple[ResolvedAnnotation, ...], ...]
131
+
132
+
133
+ def annotation_sources(annotations: Sequence[Annotation]) -> tuple[str, ...]:
134
+ """Return source-field names once, in declaration order."""
135
+ sources: dict[str, None] = {}
136
+ for annotation in annotations:
137
+ coordinates = (
138
+ (annotation.at,)
139
+ if isinstance(annotation, Rule)
140
+ else (annotation.start, annotation.end)
141
+ )
142
+ for coordinate in coordinates:
143
+ if isinstance(coordinate, str):
144
+ sources.setdefault(coordinate, None)
145
+ return tuple(sources)
146
+
147
+
148
+ def _coerce_values(
149
+ values: Sequence[Any],
150
+ *,
151
+ kind: AxisKind,
152
+ context: str,
153
+ ) -> list[float | None]:
154
+ present = [value for value in values if value is not None]
155
+ if kind == "numeric":
156
+ if any(isinstance(value, (bool, dt.date)) for value in present):
157
+ raise SpecError(f"{context} must be numeric for a numeric axis.")
158
+ try:
159
+ resolved = coerce_numeric(values, subject=context)
160
+ except TypeError as error:
161
+ raise SpecError(str(error)) from None
162
+ elif kind == "temporal":
163
+ if any(not isinstance(value, dt.date) for value in present):
164
+ raise SpecError(f"{context} must be temporal for a temporal axis.")
165
+ resolved = _coerce_temporal(values)
166
+ else:
167
+ raise SpecError(f"{context} has unsupported axis kind {kind!r}.")
168
+
169
+ if any(value is not None and not math.isfinite(value) for value in resolved):
170
+ raise SpecError(f"{context} must be finite.")
171
+ return resolved
172
+
173
+
174
+ def prepare_annotations(
175
+ annotations: Sequence[Annotation],
176
+ frame: nw.DataFrame,
177
+ *,
178
+ axis_kinds: Mapping[Axis, AxisKind],
179
+ plot_label: str,
180
+ row_identities: Sequence[Any],
181
+ ) -> PreparedAnnotations:
182
+ """Resolve annotation literals and fields into one ordered tuple per row."""
183
+ row_count = len(frame)
184
+ if len(row_identities) != row_count:
185
+ raise SpecError(
186
+ f"{plot_label} annotation rows do not match the frame: "
187
+ f"{len(row_identities)} identities for {row_count} rows."
188
+ )
189
+
190
+ source_cache: dict[str, list[Any]] = {}
191
+
192
+ def values_for(
193
+ source: AnnotationSource, *, kind: AxisKind, context: str
194
+ ) -> list[float | None]:
195
+ if isinstance(source, str):
196
+ if source not in frame.columns:
197
+ raise SpecError(f"{context} names missing column {source!r}.")
198
+ if source not in source_cache:
199
+ source_cache[source] = frame[source].to_list()
200
+ raw = source_cache[source]
201
+ else:
202
+ raw = [source] * row_count
203
+ return _coerce_values(raw, kind=kind, context=context)
204
+
205
+ resolved_rows: list[list[ResolvedAnnotation]] = [[] for _ in range(row_count)]
206
+ for index, annotation in enumerate(annotations):
207
+ if annotation.axis not in axis_kinds:
208
+ raise SpecError(
209
+ f"{plot_label} annotation {index} uses unsupported axis {annotation.axis!r}."
210
+ )
211
+ kind = axis_kinds[annotation.axis]
212
+ prefix = f"{plot_label} annotation {index}"
213
+ if isinstance(annotation, Rule):
214
+ positions = values_for(annotation.at, kind=kind, context=f"{prefix} at")
215
+ for row, position in enumerate(positions):
216
+ if position is not None:
217
+ resolved_rows[row].append(
218
+ ResolvedRule(
219
+ at=position,
220
+ axis=annotation.axis,
221
+ layer=annotation.layer,
222
+ affect_domain=annotation.affect_domain,
223
+ color=annotation.color,
224
+ opacity=annotation.opacity,
225
+ width=annotation.width,
226
+ dash=annotation.dash,
227
+ )
228
+ )
229
+ continue
230
+
231
+ starts = values_for(annotation.start, kind=kind, context=f"{prefix} start")
232
+ ends = values_for(annotation.end, kind=kind, context=f"{prefix} end")
233
+ for row, (start, end) in enumerate(zip(starts, ends, strict=True)):
234
+ if start is None or end is None:
235
+ continue
236
+ if start > end:
237
+ raise SpecError(
238
+ f"{prefix} row {row_identities[row]!r}: "
239
+ f"band start {start!r} must not exceed end {end!r}."
240
+ )
241
+ resolved_rows[row].append(
242
+ ResolvedBand(
243
+ start=start,
244
+ end=end,
245
+ axis=annotation.axis,
246
+ layer=annotation.layer,
247
+ affect_domain=annotation.affect_domain,
248
+ color=annotation.color,
249
+ opacity=annotation.opacity,
250
+ )
251
+ )
252
+
253
+ return PreparedAnnotations(by_row=tuple(tuple(marks) for marks in resolved_rows))
254
+
255
+
256
+ def domain_values(marks: Sequence[ResolvedAnnotation], *, axis: Axis) -> list[float]:
257
+ """Return domain-affecting positions for one axis, preserving mark order."""
258
+ values: list[float] = []
259
+ for mark in marks:
260
+ if mark.axis != axis or not mark.affect_domain:
261
+ continue
262
+ if isinstance(mark, ResolvedRule):
263
+ values.append(mark.at)
264
+ else:
265
+ values.extend((mark.start, mark.end))
266
+ return values
@@ -0,0 +1,9 @@
1
+ """Shared exceptions for invalid table specifications."""
2
+
3
+
4
+ class SpecError(ValueError):
5
+ """Raised when a table specification is internally inconsistent."""
6
+
7
+
8
+ class ColumnNotFoundError(KeyError):
9
+ """Raised when a specification names a column absent from the frame."""