plot-io 0.12.0__py3-none-win_arm64.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.
- plot_io/__init__.py +73 -0
- plot_io/_methods.py +1691 -0
- plot_io/_native/__init__.py +199 -0
- plot_io/_native/__main__.py +197 -0
- plot_io/_native/_api.py +457 -0
- plot_io/_native/bin/windows-arm64.dll +0 -0
- plot_io/_native/kernels.py +335 -0
- plot_io/_native/manifest.json +49 -0
- plot_io/axis.py +1347 -0
- plot_io/boxes.py +1156 -0
- plot_io/canvas.py +389 -0
- plot_io/color/__init__.py +3 -0
- plot_io/color/data.py +1646 -0
- plot_io/color/maps.py +922 -0
- plot_io/color/values.py +295 -0
- plot_io/color/variants.py +1246 -0
- plot_io/colorbar.py +1426 -0
- plot_io/columns.py +614 -0
- plot_io/decorations.py +920 -0
- plot_io/defaults.py +809 -0
- plot_io/density.py +113 -0
- plot_io/ext.py +68 -0
- plot_io/facets.py +240 -0
- plot_io/figure.py +788 -0
- plot_io/formatters.py +97 -0
- plot_io/genome/__init__.py +17 -0
- plot_io/genome/adapters.py +356 -0
- plot_io/genome/combine.py +123 -0
- plot_io/genome/contacts.py +508 -0
- plot_io/genome/intervals.py +750 -0
- plot_io/genome/lanes.py +191 -0
- plot_io/genome/layout.py +184 -0
- plot_io/genome/links.py +364 -0
- plot_io/genome/pack.py +27 -0
- plot_io/genome/readers.py +145 -0
- plot_io/genome/regions.py +228 -0
- plot_io/genome/scale.py +349 -0
- plot_io/genome/series.py +344 -0
- plot_io/genome/signal.py +621 -0
- plot_io/genome/sites.py +782 -0
- plot_io/genome/sources.py +459 -0
- plot_io/genome/testing.py +161 -0
- plot_io/geometry.py +289 -0
- plot_io/groups.py +308 -0
- plot_io/guides.py +503 -0
- plot_io/helpers/__init__.py +17 -0
- plot_io/helpers/clustermap.py +202 -0
- plot_io/helpers/dendrogram.py +172 -0
- plot_io/helpers/grids.py +304 -0
- plot_io/helpers/qq.py +334 -0
- plot_io/helpers/raincloud.py +249 -0
- plot_io/helpers/tree.py +214 -0
- plot_io/helpers/volcano.py +505 -0
- plot_io/hints.py +125 -0
- plot_io/kernels.py +740 -0
- plot_io/labels.py +566 -0
- plot_io/layers/__init__.py +100 -0
- plot_io/layers/base.py +630 -0
- plot_io/layers/context.py +87 -0
- plot_io/layers/core.py +446 -0
- plot_io/layers/geoms.py +1362 -0
- plot_io/layers/grammar.py +586 -0
- plot_io/layers/paint.py +614 -0
- plot_io/layers/pipeline.py +170 -0
- plot_io/layers/positions.py +431 -0
- plot_io/layers/stats.py +1088 -0
- plot_io/layout.py +1381 -0
- plot_io/legend.py +605 -0
- plot_io/levels.py +509 -0
- plot_io/limits.py +124 -0
- plot_io/lines.py +42 -0
- plot_io/mapped.py +522 -0
- plot_io/markers.py +110 -0
- plot_io/markup.py +128 -0
- plot_io/numbers.py +354 -0
- plot_io/occupancy.py +295 -0
- plot_io/plot.py +597 -0
- plot_io/presets/__init__.py +51 -0
- plot_io/presets/_util.py +153 -0
- plot_io/presets/area.py +145 -0
- plot_io/presets/bar.py +261 -0
- plot_io/presets/box.py +400 -0
- plot_io/presets/catpoints.py +177 -0
- plot_io/presets/contour.py +353 -0
- plot_io/presets/curves.py +30 -0
- plot_io/presets/dots.py +355 -0
- plot_io/presets/ecdf.py +97 -0
- plot_io/presets/errorbar.py +106 -0
- plot_io/presets/errors.py +296 -0
- plot_io/presets/fill.py +74 -0
- plot_io/presets/heatmap.py +322 -0
- plot_io/presets/hexbin.py +198 -0
- plot_io/presets/hist.py +391 -0
- plot_io/presets/hist2d.py +87 -0
- plot_io/presets/kde.py +162 -0
- plot_io/presets/kde2d.py +147 -0
- plot_io/presets/line.py +143 -0
- plot_io/presets/pie.py +502 -0
- plot_io/presets/regression.py +115 -0
- plot_io/presets/scatter.py +194 -0
- plot_io/presets/stem.py +100 -0
- plot_io/presets/violin.py +323 -0
- plot_io/py.typed +0 -0
- plot_io/registry.py +82 -0
- plot_io/render.py +480 -0
- plot_io/rows.py +491 -0
- plot_io/scales.py +103 -0
- plot_io/seams.py +138 -0
- plot_io/settings.py +807 -0
- plot_io/shapes.py +66 -0
- plot_io/sharing.py +232 -0
- plot_io/sizemaps.py +97 -0
- plot_io/spec.py +240 -0
- plot_io/states.py +216 -0
- plot_io/styles/__init__.py +307 -0
- plot_io/styles/library.py +397 -0
- plot_io/tables.py +138 -0
- plot_io/text.py +412 -0
- plot_io/ticks.py +400 -0
- plot_io/times.py +287 -0
- plot_io/titles.py +225 -0
- plot_io/transform.py +114 -0
- plot_io/types.py +13604 -0
- plot_io/units.py +768 -0
- plot_io/validators.py +1029 -0
- plot_io/values.py +190 -0
- plot_io-0.12.0.dist-info/METADATA +215 -0
- plot_io-0.12.0.dist-info/RECORD +130 -0
- plot_io-0.12.0.dist-info/WHEEL +4 -0
- 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}")
|