coeftable 0.3.1__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.1 → coeftable-0.4.0}/.gitignore +2 -0
  2. {coeftable-0.3.1 → coeftable-0.4.0}/CHANGELOG.md +5 -0
  3. {coeftable-0.3.1 → coeftable-0.4.0}/PKG-INFO +57 -1
  4. {coeftable-0.3.1 → coeftable-0.4.0}/README.md +56 -0
  5. {coeftable-0.3.1 → coeftable-0.4.0}/pyproject.toml +1 -1
  6. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/__init__.py +5 -9
  7. coeftable-0.4.0/src/coeftable/_axis.py +55 -0
  8. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/_version.py +2 -2
  9. coeftable-0.4.0/src/coeftable/annotations.py +266 -0
  10. coeftable-0.4.0/src/coeftable/errors.py +9 -0
  11. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/series.py +2 -51
  12. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/spec.py +127 -54
  13. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/svg.py +132 -3
  14. coeftable-0.4.0/tests/test_annotations.py +200 -0
  15. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_frame.py +223 -1
  16. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_public_api.py +2 -0
  17. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_series.py +25 -0
  18. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_sparkline.py +426 -6
  19. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_svg.py +266 -1
  20. {coeftable-0.3.1 → coeftable-0.4.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  21. {coeftable-0.3.1 → coeftable-0.4.0}/.github/release.yml +0 -0
  22. {coeftable-0.3.1 → coeftable-0.4.0}/.github/workflows/ci.yml +0 -0
  23. {coeftable-0.3.1 → coeftable-0.4.0}/.github/workflows/post-release.yml +0 -0
  24. {coeftable-0.3.1 → coeftable-0.4.0}/.github/workflows/publish.yml +0 -0
  25. {coeftable-0.3.1 → coeftable-0.4.0}/.github/workflows/release.yml +0 -0
  26. {coeftable-0.3.1 → coeftable-0.4.0}/.pre-commit-config.yaml +0 -0
  27. {coeftable-0.3.1 → coeftable-0.4.0}/LICENSE +0 -0
  28. {coeftable-0.3.1 → coeftable-0.4.0}/Makefile +0 -0
  29. {coeftable-0.3.1 → coeftable-0.4.0}/docs/images/example.png +0 -0
  30. {coeftable-0.3.1 → coeftable-0.4.0}/docs/images/trend-example.png +0 -0
  31. {coeftable-0.3.1 → coeftable-0.4.0}/noxfile.py +0 -0
  32. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/collapsible.py +0 -0
  33. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/format.py +0 -0
  34. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/frame.py +0 -0
  35. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/grid.py +0 -0
  36. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/render.py +0 -0
  37. {coeftable-0.3.1 → coeftable-0.4.0}/src/coeftable/theme.py +0 -0
  38. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_collapsible.py +0 -0
  39. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_format.py +0 -0
  40. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_package.py +0 -0
  41. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_render.py +0 -0
  42. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_spec.py +0 -0
  43. {coeftable-0.3.1 → coeftable-0.4.0}/tests/test_theme.py +0 -0
  44. {coeftable-0.3.1 → coeftable-0.4.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/
@@ -1,3 +1,8 @@
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
+
1
6
  <a id="v0.3.1"></a>
2
7
  # [v0.3.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.1) - 2026-08-14
3
8
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: coeftable
3
- Version: 0.3.1
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
@@ -439,3 +439,59 @@ absolute = pl.DataFrame(
439
439
  .sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
440
440
  )
441
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.
@@ -385,3 +385,59 @@ absolute = pl.DataFrame(
385
385
  .sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
386
386
  )
387
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]
@@ -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.4.0'
22
+ __version_tuple__ = version_tuple = (0, 4, 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."""
@@ -10,16 +10,15 @@ coerces values and detects a temporal x -- callers only ever see a `Series`.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- import datetime
14
13
  import math
15
- from collections.abc import Iterable
16
14
  from dataclasses import dataclass
17
15
  from typing import Any, overload
18
16
 
19
17
  import narwhals as nw
20
18
 
19
+ from coeftable._axis import _coerce_temporal, _detect_temporal
20
+ from coeftable.errors import SpecError
21
21
  from coeftable.format import coerce_numeric
22
- from coeftable.spec import SpecError
23
22
 
24
23
 
25
24
  @dataclass(frozen=True)
@@ -73,54 +72,6 @@ def _nan_to_none(values: list[Any]) -> list[Any]:
73
72
  return [None if isinstance(v, float) and math.isnan(v) else v for v in values]
74
73
 
75
74
 
76
- # Deliberately naive: paired with elapsed-time subtraction in _epoch_seconds
77
- # so relative spacing never depends on the host machine's local timezone.
78
- _EPOCH = datetime.datetime(1970, 1, 1)
79
-
80
-
81
- def _epoch_seconds(value: datetime.date) -> float:
82
- """Convert a date/datetime to seconds since the Unix epoch.
83
-
84
- A timezone-aware `datetime` is converted to UTC first; a naive
85
- `datetime` (or a plain `date`, read as midnight) is measured as an
86
- elapsed-time delta from a naive epoch, never through
87
- `datetime.timestamp()` -- which reads a naive value against the host's
88
- *local* timezone and would make relative spacing depend on where the
89
- code runs.
90
- """
91
- if isinstance(value, datetime.datetime):
92
- if value.tzinfo is not None:
93
- value = value.astimezone(datetime.UTC).replace(tzinfo=None)
94
- return (value - _EPOCH).total_seconds()
95
- return (datetime.datetime(value.year, value.month, value.day) - _EPOCH).total_seconds()
96
-
97
-
98
- def _detect_temporal(values: Iterable[Any]) -> bool:
99
- """Return True when the first non-missing value is a date or datetime."""
100
- for value in values:
101
- if value is not None:
102
- return isinstance(value, datetime.date)
103
- return False
104
-
105
-
106
- def _coerce_temporal(values: Iterable[Any]) -> list[float | None]:
107
- """Coerce raw date/datetime values to epoch seconds, `None` for missing.
108
-
109
- A bare `None` is missing directly; pandas' `NaT` is not `None` but is
110
- still an `isinstance(..., datetime.datetime)` whose epoch delta
111
- degenerates to NaN rather than raising, so the result is checked for
112
- NaN too -- mirroring `coerce_numeric`'s own missing-value handling.
113
- """
114
- out: list[float | None] = []
115
- for value in values:
116
- if value is None:
117
- out.append(None)
118
- continue
119
- seconds = _epoch_seconds(value)
120
- out.append(None if math.isnan(seconds) else seconds)
121
- return out
122
-
123
-
124
75
  def _build_series(
125
76
  y_raw: list[Any],
126
77
  lower_raw: list[Any] | None,