plot-io 0.12.0__py3-none-win_amd64.whl

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 (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/validators.py ADDED
@@ -0,0 +1,1029 @@
1
+ """Validators: what a setting accepts, as objects.
2
+
3
+ A validator parses a value given to a setting (``parse(v)``, also ``validator(v)``) into a
4
+ plain Python value or a :class:`~plot_io.units.Value` (``Dim``, ``Var``, ``Calc``), raising
5
+ ``ValueError``. Its fields tell resolution (:mod:`plot_io.units`) how to finish the value:
6
+ ``kind`` (``"length"``, ``"angle"``, ``"number"`` or ``None``), bounds (``lo``, ``hi``,
7
+ ``positive``), for lengths what ``%`` is of (``percent``), the unit of bare numbers
8
+ (``bare``), whether absolute units follow the figure's ``scaling`` (``scaled``), the units
9
+ accepted (``units``), the font size ``em`` is of (``em``); ``inner``: the validator of each
10
+ item of a list or pair; ``choices``: the words of a choice; ``alternatives``: those of an
11
+ :class:`Either`.
12
+
13
+ After ``var()`` and ``calc()`` are evaluated, ``finish(x, ctx)`` checks the result (bounds,
14
+ finiteness, at most :data:`MAX_PX` px for lengths); ``accepts(other)`` tells whether the
15
+ values of a setting checked by ``other`` are values of this one (``var()``).
16
+
17
+ For the generated typed API and the docs: ``annotation`` is the type of the values given,
18
+ as source text (``tools/gen_api.py``); ``describe(name)`` the value type in words
19
+ (``tools/settings_doc.py``, the docs' settings tables).
20
+
21
+ The constructors (:func:`number`, :func:`length`, :func:`angle`, :func:`one_of`,
22
+ :func:`either`, :func:`pair`, :func:`literal`) and the instances (:data:`boolean`,
23
+ :data:`text`, :data:`family`, …) are what settings are declared with; a plain function
24
+ given to a :class:`~plot_io.settings.Setting` is wrapped in a :class:`Custom`. Real
25
+ numbers are read here, once (:func:`real`, :func:`integer`, :func:`as_bool`).
26
+ """
27
+
28
+ import datetime
29
+ import enum
30
+ import math
31
+ import numbers
32
+ import re
33
+ from collections.abc import Callable
34
+ from typing import Any, TypeVar
35
+
36
+ from vec_io import Simplify, Span
37
+ from vec_io.units import ANGLE_UNITS, PX_PER_UNIT, split_length
38
+
39
+ from .units import ABSOLUTE, DURATIONS, FONT_KEYWORDS, _RELATIVE_FONT, Dim, Value, parse, percent_meaning
40
+
41
+ __all__ = ["MAX_PX", "Validator", "Number", "Length", "Angle", "Choice", "Exact", "Bool", "Text", "Family",
42
+ "Pair", "Either", "Dpi", "Color", "Custom", "as_validator", "typed", "union", "real",
43
+ "integer", "as_bool",
44
+ "number", "length", "angle", "one_of", "literal", "either", "pair", "boolean", "text",
45
+ "family", "anything", "custom_vars", "dpi", "dpi_or_bool", "simplify_value",
46
+ "font_weight", "font_style", "font_stretch", "line_height", "font_variations",
47
+ "time_zone"]
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Real numbers
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def real(v: Any) -> float | None:
55
+ """``v`` as a float when it is a real number (Python's, numpy's, any ``numbers.Real``, a
56
+ 0-d numeric array), not a boolean; else ``None``."""
57
+ if isinstance(v, bool):
58
+ return None
59
+ if isinstance(v, (int, float)):
60
+ try:
61
+ return float(v)
62
+ except OverflowError:
63
+ return math.inf if v > 0 else -math.inf
64
+ dt = getattr(v, "dtype", None)
65
+ if dt is not None:
66
+ if getattr(dt, "kind", "") not in ("i", "u", "f") or getattr(v, "shape", ()) != ():
67
+ return None
68
+ return float(v)
69
+ if isinstance(v, numbers.Real):
70
+ try:
71
+ return float(v)
72
+ except (TypeError, ValueError, OverflowError):
73
+ return None
74
+ return None
75
+
76
+
77
+ def integer(v: Any) -> int | None:
78
+ """``v`` as an int when it is an integer (``numbers.Integral``, or a real of integral
79
+ value), not a boolean; else ``None``."""
80
+ if isinstance(v, bool):
81
+ return None
82
+ if isinstance(v, int):
83
+ return v
84
+ dt = getattr(v, "dtype", None)
85
+ if dt is not None and getattr(dt, "kind", "") in ("i", "u") and getattr(v, "shape", ()) == ():
86
+ return int(v)
87
+ if isinstance(v, numbers.Integral):
88
+ return int(v)
89
+ x = real(v)
90
+ if x is not None and math.isfinite(x) and x.is_integer():
91
+ return int(x)
92
+ return None
93
+
94
+
95
+ def as_bool(v: Any) -> bool | None:
96
+ """``v`` as a bool when it is one (Python's or numpy's); else ``None``."""
97
+ if isinstance(v, bool):
98
+ return v
99
+ dt = getattr(v, "dtype", None)
100
+ if dt is not None and getattr(dt, "kind", "") == "b" and getattr(v, "shape", ()) == ():
101
+ return bool(v)
102
+ return None
103
+
104
+
105
+ #: The largest length in px (and resolution in dpi) a setting takes, either sign.
106
+ MAX_PX = 1e6
107
+ #: The largest integer an integer setting takes, either sign (exact as a float).
108
+ MAX_INT = 2 ** 53
109
+
110
+
111
+ def _bound(v: float, lo: float | None, hi: float | None, positive: bool, what: str) -> None:
112
+ if isinstance(v, int) and abs(v) > MAX_INT:
113
+ raise ValueError(f"expected an integer within ±2**53, got one of {len(str(abs(v)))} digits")
114
+ if not math.isfinite(v):
115
+ raise ValueError(f"expected a finite {what}")
116
+ if positive and not v > 0:
117
+ raise ValueError(f"expected a positive {what}, got {v!r}")
118
+ if lo is not None and v < lo:
119
+ raise ValueError(f"expected at least {lo}, got {v!r}")
120
+ if hi is not None and v > hi:
121
+ raise ValueError(f"expected at most {hi}, got {v!r}")
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Validator objects
126
+ # ---------------------------------------------------------------------------
127
+
128
+ #: The fields resolution reads, copied by :class:`Either` from its typed alternative and
129
+ #: by :class:`Custom` from the validator it resolves like.
130
+ _FIELDS = ("kind", "lo", "hi", "positive", "integer", "percent", "bare", "font", "scaled",
131
+ "units", "em", "inner", "choices", "alternatives")
132
+
133
+
134
+ _UNSET: dict[str, Any] = {
135
+ "kind": None, "lo": None, "hi": None, "positive": False, "integer": False, "percent": None,
136
+ "bare": "px", "font": False, "scaled": True, "units": frozenset(), "em": None,
137
+ "inner": None, "choices": None, "alternatives": None}
138
+
139
+
140
+ class Validator:
141
+ """What a setting accepts. Immutable: fields are given when it is made."""
142
+
143
+ __slots__ = _FIELDS
144
+ kind: str | None
145
+ lo: float | None
146
+ hi: float | None
147
+ positive: bool
148
+ integer: bool
149
+ percent: str | None
150
+ bare: str
151
+ font: bool
152
+ scaled: bool
153
+ units: frozenset[str]
154
+ em: str | None
155
+ inner: "Validator | None"
156
+ choices: tuple[str, ...] | None
157
+ alternatives: "tuple[Validator, ...] | None"
158
+
159
+ def __init__(self, **fields: Any) -> None:
160
+ for k, v in {**_UNSET, **fields}.items():
161
+ object.__setattr__(self, k, v)
162
+
163
+ def __setattr__(self, name: str, value: Any) -> None:
164
+ raise AttributeError(f"{type(self).__name__} is immutable")
165
+
166
+ #: What the values are, for ``var()``: ``"length"``, ``"angle"``, ``"number"``,
167
+ #: ``"bool"``, ``"choice"``, ``"text"``, or ``None`` (not known).
168
+ sort: str | None = None
169
+
170
+ @property
171
+ def noun(self) -> str:
172
+ """The values, in words (``"a length"``)."""
173
+ return _NOUNS.get(self.sort or "", "a value")
174
+
175
+ def accepts(self, other: "Validator") -> bool | None:
176
+ """Whether the final values of a setting checked by ``other`` are values of this
177
+ one as they are (``True``), never are (``False``), or must be parsed again
178
+ (``None``)."""
179
+ if other is self:
180
+ return True
181
+ if isinstance(other, Either):
182
+ return _all(self.accepts(o) for o in other.alternatives or ())
183
+ a, b = self.sort, other.sort
184
+ if a is None or b is None:
185
+ return None
186
+ return a == b
187
+
188
+ def finish(self, x: Any, ctx: Any) -> Any:
189
+ """``x``, the final value of a ``var()``, ``calc()`` or unit of this setting,
190
+ checked: finite, within the bounds; ``ctx.error`` otherwise."""
191
+ if self.kind is None:
192
+ return x
193
+ v = real(x)
194
+ if v is None:
195
+ if self.strict:
196
+ raise ctx.error(f"expected {self.noun}, got {x!r}")
197
+ return x
198
+ if not math.isfinite(v):
199
+ raise ctx.error(f"resolves to {v}, expected a finite value")
200
+ if self.kind == "angle":
201
+ lo, hi = self.lo, self.hi
202
+ if (lo is not None and v < lo) or (hi is not None and v > hi):
203
+ raise ctx.error(f"{v:g}° is outside {lo:g}…{hi:g}")
204
+ return x
205
+ if self.integer and not v.is_integer():
206
+ raise ctx.error(f"resolves to {v:g}, expected an integer")
207
+ if self.positive and not v > 0:
208
+ raise ctx.error(f"resolves to {v:g}, expected a positive {self.kind}")
209
+ if self.lo is not None and v < self.lo - 1e-9:
210
+ raise ctx.error(f"resolves to {v:g}, expected at least {self.lo:g}")
211
+ if self.hi is not None and v > self.hi + 1e-9:
212
+ raise ctx.error(f"resolves to {v:g}, expected at most {self.hi:g}")
213
+ if self.kind == "length" and self.bare == "px" and ctx.unit == "px" \
214
+ and abs(v) > MAX_PX:
215
+ raise ctx.error(f"resolves to {v:g} px, expected at most {MAX_PX:g} px")
216
+ return x
217
+
218
+ #: whether :meth:`finish` refuses values that are not real numbers (a validator of
219
+ #: its own; a :class:`Custom` passes them)
220
+ strict = True
221
+
222
+ #: The type of the values given, as source text of ``plot_io/types.py``.
223
+ annotation = "Any"
224
+
225
+ def describe(self, name: str = "") -> str:
226
+ """The value type, in words, appended to the meaning of setting ``name`` in docs/api.md:
227
+ lengths (what their ``%`` is of, the unit of bare numbers) and angles."""
228
+ check: Validator | None = self.inner if self.kind is None else self
229
+ kind = None if check is None else check.kind
230
+ if check is None or kind == "angle":
231
+ return "Angle (bare: degrees)." if kind == "angle" else ""
232
+ if kind != "length":
233
+ return ""
234
+ parts = ["Length"]
235
+ if check.em is not None:
236
+ parts.append(f"`em` and `%`: of `{check.em}`")
237
+ elif check.percent == "em" and name == "font_size":
238
+ parts.append("`%`: of the parent's font size")
239
+ elif check.percent is not None:
240
+ parts.append(f"`%`: of {percent_meaning(check.percent)}")
241
+ if check.bare == "data":
242
+ parts.append("bare numbers: data units")
243
+ elif "d" in check.units:
244
+ parts.append("`d`: data units")
245
+ return "; ".join(parts) + "."
246
+
247
+ def _like(self, other: "Validator") -> dict[str, Any]:
248
+ """The fields of ``other``, to resolve as it does."""
249
+ return {k: getattr(other, k) for k in _FIELDS}
250
+
251
+ @property
252
+ def typed(self) -> bool:
253
+ """Whether resolution treats values as this validator's kind or items."""
254
+ return self.kind is not None or self.inner is not None
255
+
256
+ def parse(self, v: Any) -> Any:
257
+ """``v`` checked and normalized; ``ValueError`` when it is not accepted."""
258
+ raise NotImplementedError
259
+
260
+ def __call__(self, v: Any) -> Any:
261
+ return self.parse(v)
262
+
263
+ def __repr__(self) -> str:
264
+ return f"{type(self).__name__}()"
265
+
266
+
267
+ class Number(Validator):
268
+ """A number between ``lo`` and ``hi`` (``positive``: above 0; ``integer``: an integer);
269
+ ``calc()`` and ``var()`` kept."""
270
+
271
+ __slots__ = ()
272
+
273
+ def __init__(self, lo: float | None = None, hi: float | None = None,
274
+ positive: bool = False, integer: bool = False) -> None:
275
+ super().__init__(kind="number", lo=lo, hi=hi, positive=positive, integer=integer)
276
+
277
+ sort = "number"
278
+ annotation = "SupportsFloat"
279
+
280
+ def parse(self, v: Any) -> Any:
281
+ if isinstance(v, str) and not self.integer:
282
+ e = parse(v, frozenset())
283
+ if e is not None:
284
+ return e
285
+ x = real(v)
286
+ if x is None:
287
+ raise ValueError(f"expected a number, got {v!r}")
288
+ if not self.integer:
289
+ _bound(x, self.lo, self.hi, self.positive, "number")
290
+ return x
291
+ n = integer(v)
292
+ if n is None:
293
+ raise ValueError(f"expected an integer, got {v!r}")
294
+ _bound(n, self.lo, self.hi, self.positive, "number")
295
+ return n
296
+
297
+ def __repr__(self) -> str:
298
+ return f"Number(lo={self.lo!r}, hi={self.hi!r})"
299
+
300
+
301
+ class Length(Validator):
302
+ """A length.
303
+
304
+ ``percent``: what ``%`` is of: a key of :data:`~plot_io.units.REFERENTS` or the name
305
+ of another setting of the object; ``None``: ``%`` is not accepted. ``bare``: unit of
306
+ bare numbers (``"px"`` or ``"data"``). ``data``: ``data`` units and durations are
307
+ accepted. ``font``: font-size keywords are accepted. ``scaled``: absolute values
308
+ follow the figure's ``scaling``. ``fr``: ``fr`` units are accepted. ``em``: the
309
+ font-size setting ``em`` is of, instead of the object's ``font_size``
310
+ (``"tick_label_font_size"`` for tick spacing).
311
+ """
312
+
313
+ __slots__ = ()
314
+
315
+ def __init__(self, lo: float | None = None, positive: bool = False, *, percent: Any = None,
316
+ bare: str = "px", data: bool = False, font: bool = False, scaled: bool = True,
317
+ fr: bool = False, em: str | None = None) -> None:
318
+ units = set(ABSOLUTE) | {"em", "rem"}
319
+ if percent is not None:
320
+ units.add("%")
321
+ if data:
322
+ units |= {"data"} | set(DURATIONS)
323
+ if fr:
324
+ units.add("fr")
325
+ super().__init__(kind="length", lo=lo, positive=positive, percent=percent, bare=bare,
326
+ font=font, scaled=scaled, units=frozenset(units), em=em)
327
+
328
+ sort = "length"
329
+
330
+ @property
331
+ def annotation(self) -> str: # type: ignore[override]
332
+ return "SupportsFloat | str | timedelta" if "data" in self.units \
333
+ else "SupportsFloat | str"
334
+
335
+ def _sane(self, px: float, v: Any) -> None:
336
+ if self.bare == "px" and abs(px) > MAX_PX:
337
+ raise ValueError(f"expected a length of at most {MAX_PX:g} px, got {v!r}")
338
+
339
+ def parse(self, v: Any) -> Any:
340
+ lo, positive, allowed = self.lo, self.positive, self.units
341
+ x = real(v)
342
+ if x is not None:
343
+ _bound(x, lo, None, positive, "length")
344
+ self._sane(x, v)
345
+ return x
346
+ if isinstance(v, datetime.timedelta) and "data" in allowed:
347
+ return Dim(str(v), v.total_seconds(), "s")
348
+ if isinstance(v, Value):
349
+ return v
350
+ if not isinstance(v, str):
351
+ raise ValueError(f"expected a length, got {v!r}")
352
+ s = v.strip()
353
+ if self.font and (s.lower() in FONT_KEYWORDS or s.lower() in _RELATIVE_FONT):
354
+ return Dim(v, None, s.lower())
355
+ e = parse(s, allowed)
356
+ if e is not None:
357
+ return e
358
+ try:
359
+ number, unit = split_length(s, allowed | {""})
360
+ except ValueError:
361
+ hint = ", ".join(sorted(u for u in allowed if u not in DURATIONS))
362
+ raise ValueError(f"invalid length {v!r} (units: {hint})") from None
363
+ if not unit:
364
+ _bound(number, lo, None, positive, "length")
365
+ self._sane(number, v)
366
+ return number
367
+ if unit in ABSOLUTE:
368
+ _bound(number * PX_PER_UNIT[unit], lo, None, positive, "length")
369
+ self._sane(number * PX_PER_UNIT[unit], v)
370
+ else:
371
+ _bound(number, 0.0 if lo is not None and lo >= 0 else None, None, positive,
372
+ "length")
373
+ return Dim(v, number, unit)
374
+
375
+ def __repr__(self) -> str:
376
+ return f"Length(lo={self.lo!r}, percent={self.percent!r})"
377
+
378
+
379
+ _ANGLES = frozenset(ANGLE_UNITS)
380
+
381
+
382
+ class Angle(Validator):
383
+ """An angle between ``lo`` and ``hi`` degrees: degrees, or ``deg``, ``grad``, ``rad``,
384
+ ``turn``."""
385
+
386
+ __slots__ = ()
387
+
388
+ def __init__(self, lo: float | None = None, hi: float | None = None) -> None:
389
+ super().__init__(kind="angle", lo=lo, hi=hi)
390
+
391
+ sort = "angle"
392
+ annotation = "SupportsFloat | str"
393
+
394
+ def parse(self, v: Any) -> Any:
395
+ x = real(v)
396
+ if x is not None:
397
+ _bound(x, self.lo, self.hi, False, "angle")
398
+ return x
399
+ if isinstance(v, Value):
400
+ return v
401
+ if not isinstance(v, str):
402
+ raise ValueError(f"expected an angle, got {v!r}")
403
+ e = parse(v, _ANGLES)
404
+ if e is not None:
405
+ return e
406
+ try:
407
+ number, unit = split_length(v, _ANGLES | {""})
408
+ except ValueError:
409
+ raise ValueError(f"invalid angle {v!r} (units: deg, grad, rad, turn)") from None
410
+ deg = number * ANGLE_UNITS[unit] if unit else number
411
+ _bound(deg, self.lo, self.hi, False, "angle")
412
+ return Dim(v, number, unit) if unit else number
413
+
414
+ def __repr__(self) -> str:
415
+ return f"Angle(lo={self.lo!r}, hi={self.hi!r})"
416
+
417
+
418
+ class Choice(Validator):
419
+ """One of ``choices`` (case, ``-`` and ``_`` aside; an ``enum`` member by its value);
420
+ ``aliases`` maps other words to them."""
421
+
422
+ __slots__ = ("aliases",)
423
+ aliases: dict[str, str]
424
+
425
+ def __init__(self, *choices: str, aliases: dict[str, str] | None = None) -> None:
426
+ super().__init__(choices=choices)
427
+ object.__setattr__(self, "aliases", dict(aliases or {}))
428
+
429
+ sort = "choice"
430
+
431
+ @property
432
+ def noun(self) -> str:
433
+ return "one of " + ", ".join(map(repr, self.choices or ()))
434
+
435
+ @property
436
+ def annotation(self) -> str: # type: ignore[override]
437
+ words = (*(self.choices or ()), *self.aliases)
438
+ return "Literal[" + ", ".join(map(repr, words)) + "]"
439
+
440
+ def accepts(self, other: Validator) -> bool | None:
441
+ if isinstance(other, Choice):
442
+ return True if set(other.choices or ()) <= set(self.choices or ()) else None
443
+ return super().accepts(other)
444
+
445
+ def parse(self, v: Any) -> Any:
446
+ if isinstance(v, enum.Enum):
447
+ v = v.value
448
+ choices = self.choices or ()
449
+ if isinstance(v, str):
450
+ key = v.strip().lower().replace("-", "_")
451
+ key = self.aliases.get(key, key)
452
+ if key in choices:
453
+ return key
454
+ raise ValueError(f"expected one of {', '.join(map(repr, choices))}, got {v!r}")
455
+
456
+ def __repr__(self) -> str:
457
+ return f"Choice{self.choices!r}"
458
+
459
+
460
+ class Exact(Validator):
461
+ """One of ``values``, equal and of the same type."""
462
+
463
+ __slots__ = ("values",)
464
+ values: tuple[Any, ...]
465
+
466
+ def __init__(self, *values: Any) -> None:
467
+ super().__init__()
468
+ object.__setattr__(self, "values", values)
469
+
470
+ @property
471
+ def annotation(self) -> str: # type: ignore[override]
472
+ if all(w is None or isinstance(w, (str, int, bool)) for w in self.values):
473
+ return "Literal[" + ", ".join(map(repr, self.values)) + "]"
474
+ return "Any"
475
+
476
+ def parse(self, v: Any) -> Any:
477
+ for w in self.values:
478
+ if v == w and type(v) is type(w):
479
+ return v
480
+ raise ValueError(f"expected one of {', '.join(map(repr, self.values))}, got {v!r}")
481
+
482
+ def __repr__(self) -> str:
483
+ return f"Exact{self.values!r}"
484
+
485
+
486
+ class Bool(Validator):
487
+ """``True`` or ``False`` (numpy's too)."""
488
+
489
+ __slots__ = ()
490
+ sort = "bool"
491
+ annotation = "bool"
492
+
493
+ def parse(self, v: Any) -> bool:
494
+ b = as_bool(v)
495
+ if b is None:
496
+ raise ValueError(f"expected True or False, got {v!r}")
497
+ return b
498
+
499
+
500
+ class Text(Validator):
501
+ """A string or rich content (a list of strings and spans); other values as text."""
502
+
503
+ __slots__ = ()
504
+ sort = "text"
505
+ annotation = "str | Span | Sequence[str | Span] | SupportsFloat"
506
+
507
+ def parse(self, v: Any) -> Any:
508
+ if isinstance(v, str):
509
+ return v
510
+ if isinstance(v, (list, tuple)):
511
+ for x in v:
512
+ if not isinstance(x, (str, Span)):
513
+ raise ValueError(f"expected a string or a list of strings and spans, "
514
+ f"got {v!r}")
515
+ return list(v)
516
+ if isinstance(v, Span):
517
+ return [v]
518
+ return str(v)
519
+
520
+
521
+ class Family(Validator):
522
+ """A font family: a name, or names separated by commas (the first installed is used)."""
523
+
524
+ __slots__ = ()
525
+ sort = "text"
526
+ annotation = "str"
527
+
528
+ @property
529
+ def noun(self) -> str:
530
+ return "a font family"
531
+
532
+ def parse(self, v: Any) -> str:
533
+ if not isinstance(v, str) or not v.strip(" ,"):
534
+ raise ValueError(f"expected a font family name, got {v!r}")
535
+ return v
536
+
537
+
538
+ class Pair(Validator):
539
+ """Two values, each checked by ``item``."""
540
+
541
+ __slots__ = ()
542
+
543
+ def __init__(self, item: Validator) -> None:
544
+ super().__init__(inner=item)
545
+
546
+ def parse(self, v: Any) -> tuple[Any, Any]:
547
+ if not isinstance(v, (tuple, list)) or len(v) != 2:
548
+ raise ValueError(f"expected a pair, got {v!r}")
549
+ item = self.inner
550
+ assert item is not None
551
+ return (item(v[0]), item(v[1]))
552
+
553
+ @property
554
+ def noun(self) -> str:
555
+ return "a pair"
556
+
557
+ @property
558
+ def annotation(self) -> str: # type: ignore[override]
559
+ assert self.inner is not None
560
+ a = self.inner.annotation
561
+ return f"tuple[{a}, {a}] | list[{a}]"
562
+
563
+ def accepts(self, other: Validator) -> bool | None:
564
+ if other is self:
565
+ return True
566
+ if isinstance(other, Pair):
567
+ assert self.inner is not None and other.inner is not None
568
+ return self.inner.accepts(other.inner)
569
+ return None
570
+
571
+ def finish(self, x: Any, ctx: Any) -> Any:
572
+ item = self.inner
573
+ if item is None or not isinstance(x, (tuple, list)):
574
+ return x
575
+ return type(x)(item.finish(i, ctx) for i in x)
576
+
577
+ def __repr__(self) -> str:
578
+ return f"Pair({self.inner!r})"
579
+
580
+
581
+ class Either(Validator):
582
+ """The first of ``alternatives`` that accepts the value. Resolved as its first typed
583
+ alternative (a length, an angle, a number, or one with items)."""
584
+
585
+ __slots__ = ()
586
+
587
+ def __init__(self, *alternatives: Validator) -> None:
588
+ typed = next((c for c in alternatives if c.typed), None)
589
+ fields = self._like(typed) if typed is not None else {}
590
+ fields["alternatives"] = alternatives
591
+ super().__init__(**fields)
592
+
593
+ def parse(self, v: Any) -> Any:
594
+ errors = []
595
+ for c in self.alternatives or ():
596
+ try:
597
+ return c(v)
598
+ except (TypeError, ValueError) as e:
599
+ errors.append(str(e))
600
+ raise ValueError("; ".join(errors))
601
+
602
+ @property
603
+ def sort(self) -> str | None: # type: ignore[override]
604
+ sorts = {a.sort for a in self.alternatives or ()}
605
+ return sorts.pop() if len(sorts) == 1 else None
606
+
607
+ @property
608
+ def noun(self) -> str:
609
+ return " or ".join(a.noun for a in self.alternatives or ())
610
+
611
+ @property
612
+ def annotation(self) -> str: # type: ignore[override]
613
+ return union(*(a.annotation for a in self.alternatives or ()))
614
+
615
+ def accepts(self, other: Validator) -> bool | None:
616
+ if other is self:
617
+ return True
618
+ return _any(a.accepts(other) for a in self.alternatives or ())
619
+
620
+ def finish(self, x: Any, ctx: Any) -> Any:
621
+ """``x`` finished by the alternative it is a value of: the typed one for a number."""
622
+ if real(x) is not None:
623
+ typed = next((a for a in self.alternatives or () if a.typed), None)
624
+ return x if typed is None else typed.finish(x, ctx)
625
+ return x
626
+
627
+ def __repr__(self) -> str:
628
+ return f"Either{self.alternatives!r}"
629
+
630
+
631
+ _MULTIPLE = re.compile(r"\s*([0-9]*\.?[0-9]+)\s*x\s*", re.I)
632
+ _POSITIVE = Number(positive=True, hi=MAX_PX)
633
+
634
+
635
+ class Dpi(Validator):
636
+ """A resolution in dots per inch: a number, or a multiple of 96 written ``"2x"``
637
+ (192 dpi); ``calc()`` and ``var()`` kept. ``flag``: ``True`` and ``False`` too;
638
+ ``auto``: ``"auto"`` too."""
639
+
640
+ __slots__ = ("flag", "auto")
641
+ flag: bool
642
+ auto: bool
643
+
644
+ def __init__(self, flag: bool = False, auto: bool = False) -> None:
645
+ super().__init__()
646
+ object.__setattr__(self, "flag", flag)
647
+ object.__setattr__(self, "auto", auto)
648
+
649
+ sort = "number"
650
+
651
+ @property
652
+ def noun(self) -> str:
653
+ return "a resolution"
654
+
655
+ @property
656
+ def annotation(self) -> str: # type: ignore[override]
657
+ return "SupportsFloat | str | bool" if self.flag else "SupportsFloat | str"
658
+
659
+ def finish(self, x: Any, ctx: Any) -> Any:
660
+ if isinstance(x, bool) or x == "auto":
661
+ return x
662
+ return _POSITIVE.finish(x, ctx)
663
+
664
+ def parse(self, v: Any) -> Any:
665
+ if self.auto and v == "auto":
666
+ return "auto"
667
+ if self.flag and isinstance(v, bool):
668
+ return v
669
+ if isinstance(v, str):
670
+ m = _MULTIPLE.fullmatch(v)
671
+ if m is not None:
672
+ return _POSITIVE(96.0 * float(m.group(1)))
673
+ d = _POSITIVE(v)
674
+ return d if isinstance(d, Value) else float(d)
675
+
676
+ def __repr__(self) -> str:
677
+ return f"Dpi(flag={self.flag!r}, auto={self.auto!r})"
678
+
679
+
680
+ class Color(Validator):
681
+ """A color, checked by ``fn(v)`` (:func:`plot_io.color.values.color`)."""
682
+
683
+ __slots__ = ("fn",)
684
+ fn: Callable[[Any], Any]
685
+
686
+ def __init__(self, fn: Callable[[Any], Any]) -> None:
687
+ super().__init__()
688
+ object.__setattr__(self, "fn", fn)
689
+
690
+ sort = "color"
691
+ annotation = "ColorLike"
692
+
693
+ @property
694
+ def noun(self) -> str:
695
+ return "a color"
696
+
697
+ def parse(self, v: Any) -> Any:
698
+ return self.fn(v)
699
+
700
+ def __repr__(self) -> str:
701
+ return "Color()"
702
+
703
+
704
+ class Custom(Validator):
705
+ """A validator made of a function ``fn(v)``. ``like``: the validator whose fields it
706
+ takes (it resolves as that one); ``inner``: the validator of its items;
707
+ ``annotation``: the type of the values given (default: the one :func:`typed` gave
708
+ ``fn``, else ``Any``)."""
709
+
710
+ __slots__ = ("fn", "_annotation")
711
+ fn: Callable[[Any], Any]
712
+ _annotation: str | None
713
+
714
+ def __init__(self, fn: Callable[[Any], Any], *, like: Validator | None = None,
715
+ inner: Validator | None = None, annotation: str | None = None) -> None:
716
+ fields = self._like(like) if like is not None else {}
717
+ if inner is not None:
718
+ fields["inner"] = inner
719
+ super().__init__(**fields)
720
+ object.__setattr__(self, "fn", fn)
721
+ object.__setattr__(self, "_annotation", annotation)
722
+
723
+ strict = False
724
+
725
+ @property
726
+ def annotation(self) -> str: # type: ignore[override]
727
+ if self._annotation is not None:
728
+ return self._annotation
729
+ return getattr(self.fn, "value_type", "Any")
730
+
731
+ @property
732
+ def sort(self) -> str | None: # type: ignore[override]
733
+ return self.kind
734
+
735
+ def accepts(self, other: Validator) -> bool | None:
736
+ if isinstance(other, Custom) and other.fn is self.fn:
737
+ return True
738
+ return super().accepts(other)
739
+
740
+ def parse(self, v: Any) -> Any:
741
+ return self.fn(v)
742
+
743
+ def __repr__(self) -> str:
744
+ return f"Custom({getattr(self.fn, '__name__', self.fn)!r})"
745
+
746
+
747
+ _NOUNS = {"length": "a length", "angle": "an angle", "number": "a number",
748
+ "bool": "True or False", "choice": "a choice", "text": "text"}
749
+
750
+
751
+ _F = TypeVar("_F", bound=Callable[..., Any])
752
+
753
+
754
+ def typed(annotation: str) -> Callable[[_F], _F]:
755
+ """Decorator of a function validator: ``annotation`` is the type of the values it
756
+ accepts, as source text of ``plot_io/types.py`` (read by :class:`Custom`)."""
757
+ def mark(fn: _F) -> _F:
758
+ setattr(fn, "value_type", annotation)
759
+ return fn
760
+ return mark
761
+
762
+
763
+ def union(*annotations: str) -> str:
764
+ """The union of type annotations (source text), each member once; ``Any`` absorbs the
765
+ others."""
766
+ out: list[str] = []
767
+ for a in annotations:
768
+ depth, start = 0, 0
769
+ for i, ch in enumerate(a + "|"):
770
+ if ch == "[":
771
+ depth += 1
772
+ elif ch == "]":
773
+ depth -= 1
774
+ elif ch == "|" and depth == 0:
775
+ part = a[start:i].strip()
776
+ start = i + 1
777
+ if part and part not in out:
778
+ out.append(part)
779
+ return "Any" if "Any" in out else " | ".join(out)
780
+
781
+
782
+ def _all(results: Any) -> bool | None:
783
+ """``True`` when every result is, ``False`` when every result is, else ``None``."""
784
+ rs = set(results)
785
+ return rs.pop() if len(rs) == 1 else None
786
+
787
+
788
+ def _any(results: Any) -> bool | None:
789
+ """``True`` when a result is, ``False`` when every result is, else ``None``."""
790
+ rs = set(results)
791
+ if True in rs:
792
+ return True
793
+ return False if rs == {False} else None
794
+
795
+
796
+ def as_validator(check: Any) -> Validator:
797
+ """``check`` as a validator: itself, a function wrapped in :class:`Custom`, or (``None``)
798
+ :data:`anything`."""
799
+ if check is None:
800
+ return anything
801
+ if isinstance(check, Validator):
802
+ return check
803
+ return Custom(check)
804
+
805
+
806
+ # ---------------------------------------------------------------------------
807
+ # Constructors and instances
808
+ # ---------------------------------------------------------------------------
809
+
810
+ def number(lo: float | None = None, hi: float | None = None, positive: bool = False,
811
+ integer: bool = False) -> Number:
812
+ return Number(lo, hi, positive, integer)
813
+
814
+
815
+ def length(lo: float | None = None, positive: bool = False, *, percent: Any = None,
816
+ bare: str = "px", data: bool = False, font: bool = False, scaled: bool = True,
817
+ fr: bool = False, em: str | None = None) -> Length:
818
+ """Validator of a length (see :class:`Length`)."""
819
+ return Length(lo, positive, percent=percent, bare=bare, data=data, font=font,
820
+ scaled=scaled, fr=fr, em=em)
821
+
822
+
823
+ def angle(lo: float | None = None, hi: float | None = None) -> Angle:
824
+ """Validator of an angle: degrees, or ``deg``, ``grad``, ``rad``, ``turn``."""
825
+ return Angle(lo, hi)
826
+
827
+
828
+ def one_of(*choices: str, aliases: dict[str, str] | None = None) -> Choice:
829
+ return Choice(*choices, aliases=aliases)
830
+
831
+
832
+ def literal(*values: Any) -> Exact:
833
+ return Exact(*values)
834
+
835
+
836
+ def either(*checks: Any) -> Either:
837
+ return Either(*(as_validator(c) for c in checks))
838
+
839
+
840
+ def pair(check: Any) -> Pair:
841
+ return Pair(as_validator(check))
842
+
843
+
844
+ boolean = Bool()
845
+ text = Text()
846
+ family = Family()
847
+ anything = Custom(lambda v: v)
848
+ dpi = Dpi()
849
+ dpi_or_bool = Dpi(flag=True)
850
+
851
+
852
+ class SimplifyValue(Dpi):
853
+ """``simplify``: what :class:`Dpi` takes (flag and ``"auto"``), or a
854
+ :class:`vec_io.Simplify` or a dict of its fields (checked, given unchanged)."""
855
+
856
+ __slots__ = ()
857
+
858
+ @property
859
+ def annotation(self) -> str: # type: ignore[override]
860
+ return "SupportsFloat | str | bool | Simplify | Mapping[str, Any]"
861
+
862
+ def finish(self, x: Any, ctx: Any) -> Any:
863
+ if isinstance(x, (Simplify, dict)):
864
+ return x
865
+ return super().finish(x, ctx)
866
+
867
+ def parse(self, v: Any) -> Any:
868
+ if isinstance(v, Simplify):
869
+ return v
870
+ if isinstance(v, dict):
871
+ try:
872
+ Simplify(**v)
873
+ except ValueError as e: # a field's value (OptionError)
874
+ raise ValueError(str(e)) from e
875
+ except TypeError as e: # an unknown field
876
+ raise ValueError(f"expected Simplify fields (dpi, tolerance), got "
877
+ f"{sorted(v)!r}") from e
878
+ return dict(v)
879
+ return super().parse(v)
880
+
881
+ def __repr__(self) -> str:
882
+ return "SimplifyValue()"
883
+
884
+
885
+ simplify_value = SimplifyValue(flag=True, auto=True)
886
+
887
+
888
+ @typed("Mapping[str, Any]")
889
+ def _custom_vars(v: Any) -> dict[str, Any]:
890
+ if not isinstance(v, dict):
891
+ raise ValueError(f"expected a dict of variables, got {v!r}")
892
+ out = {}
893
+ for k, x in v.items():
894
+ if not isinstance(k, str) or not k.strip("-"):
895
+ raise ValueError(f"invalid variable name {k!r}")
896
+ if x is None:
897
+ raise ValueError(f"variable {k!r}: None is not a value")
898
+ out[k.lstrip("-")] = x
899
+ return out
900
+
901
+
902
+ #: Custom variables: ``{"accent": "#c00", "gap": "4pt"}`` (names with or without the
903
+ #: leading ``--``), read by ``var(--name)``.
904
+ custom_vars = Custom(_custom_vars)
905
+
906
+
907
+ # ---------------------------------------------------------------------------
908
+ # Fonts and time zones
909
+ # ---------------------------------------------------------------------------
910
+
911
+ @typed("Literal['normal', 'bold'] | SupportsFloat | str")
912
+ def font_weight(v: Any) -> Any:
913
+ """``normal``, ``bold`` or a weight 1–1000 (a number or a numeric string)."""
914
+ if isinstance(v, str) and v.strip().lower() in ("normal", "bold"):
915
+ return v.strip().lower()
916
+ if not isinstance(v, bool):
917
+ try:
918
+ w = float(v)
919
+ except (TypeError, ValueError, OverflowError):
920
+ w = math.nan
921
+ if 1 <= w <= 1000:
922
+ return v.strip() if isinstance(v, str) else v
923
+ raise ValueError(f"expected 'normal', 'bold' or a weight 1-1000, got {v!r}")
924
+
925
+
926
+ _OBLIQUE = Angle(-90, 90)
927
+
928
+
929
+ @typed("Literal['normal', 'italic', 'oblique'] | str")
930
+ def font_style(v: Any) -> str:
931
+ """``normal``, ``italic``, ``oblique`` or ``"oblique <angle>"`` (−90°–90°)."""
932
+ if isinstance(v, str):
933
+ words = v.strip().lower().split(None, 1)
934
+ if len(words) == 1 and words[0] in ("normal", "italic", "oblique"):
935
+ return words[0]
936
+ if len(words) == 2 and words[0] == "oblique":
937
+ try:
938
+ _OBLIQUE(words[1])
939
+ except ValueError:
940
+ pass
941
+ else:
942
+ return f"oblique {words[1]}"
943
+ raise ValueError(f"expected 'normal', 'italic', 'oblique' or 'oblique <angle>', got {v!r}")
944
+
945
+
946
+ _STRETCHES = ("ultra-condensed", "extra-condensed", "condensed", "semi-condensed", "normal",
947
+ "semi-expanded", "expanded", "extra-expanded", "ultra-expanded")
948
+
949
+
950
+ @typed("str")
951
+ def font_stretch(v: Any) -> str:
952
+ """A CSS ``font-stretch``: a keyword (``condensed``, …) or a percentage 50–200 %."""
953
+ if isinstance(v, str):
954
+ key = v.strip().lower().replace("_", "-")
955
+ if key in _STRETCHES:
956
+ return key
957
+ if key.endswith("%"):
958
+ try:
959
+ pct = float(key[:-1])
960
+ except ValueError:
961
+ pct = math.nan
962
+ if 50 <= pct <= 200:
963
+ return key
964
+ raise ValueError(f"expected a font-stretch keyword or a percentage 50%-200%, got {v!r}")
965
+
966
+
967
+ #: The largest line height, as a multiple of the font size.
968
+ _MAX_LINE_HEIGHT = 1000.0
969
+
970
+
971
+ @typed("Literal['normal'] | SupportsFloat | str")
972
+ def line_height(v: Any) -> Any:
973
+ """A CSS ``line-height``: ``"normal"`` (the font's ascent and descent), a multiple of
974
+ the font size (``1.2``) or a percentage of it (``"120%"``), at most 1000 times it."""
975
+ if isinstance(v, str):
976
+ key = v.strip().lower()
977
+ if key == "normal":
978
+ return key
979
+ if key.endswith("%"):
980
+ try:
981
+ pct = float(key[:-1])
982
+ except ValueError:
983
+ pct = math.nan
984
+ if 0 < pct <= 100 * _MAX_LINE_HEIGHT:
985
+ return pct / 100
986
+ elif (x := real(v)) is not None and 0 < x <= _MAX_LINE_HEIGHT:
987
+ return x
988
+ raise ValueError(f"expected 'normal', a positive number or a percentage (at most "
989
+ f"{_MAX_LINE_HEIGHT:g} times the font size), got {v!r}")
990
+
991
+
992
+ _VARIATION_RE = re.compile(r"""\s*(["'])(.{4})\1\s+([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*""",
993
+ re.I)
994
+
995
+
996
+ @typed("Mapping[str, SupportsFloat] | str")
997
+ def font_variations(v: Any) -> Any:
998
+ """Font variation settings: ``{"GRAD": 50}`` or ``'"GRAD" 50, "wght" 650'`` (axis tags
999
+ of 4 characters), or ``None``."""
1000
+ if v is None:
1001
+ return None
1002
+ if isinstance(v, dict):
1003
+ out = {}
1004
+ for tag, x in v.items():
1005
+ n = real(x)
1006
+ if not (isinstance(tag, str) and len(tag) == 4) or n is None:
1007
+ raise ValueError(f"expected {{axis tag: number}}, got {v!r}")
1008
+ out[tag] = n
1009
+ return out
1010
+ if isinstance(v, str):
1011
+ if v.strip().lower() == "normal":
1012
+ return None
1013
+ if all(_VARIATION_RE.fullmatch(part) for part in v.split(",")):
1014
+ return v.strip()
1015
+ raise ValueError(f"expected font variations such as {{'GRAD': 50}} or '\"GRAD\" 50', got {v!r}")
1016
+
1017
+
1018
+ @typed("tzinfo | str")
1019
+ def time_zone(v: Any) -> Any:
1020
+ """A ``tzinfo``, or a zone name (``"Europe/Paris"``) as a ``zoneinfo.ZoneInfo``."""
1021
+ if isinstance(v, datetime.tzinfo):
1022
+ return v
1023
+ if isinstance(v, str) and v.strip():
1024
+ import zoneinfo
1025
+ try:
1026
+ return zoneinfo.ZoneInfo(v.strip())
1027
+ except (zoneinfo.ZoneInfoNotFoundError, ValueError):
1028
+ raise ValueError(f"unknown time zone {v!r}") from None
1029
+ raise ValueError(f"expected a tzinfo or a time zone name, got {v!r}")