xarray-annotated 0.5.2__py3-none-any.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.
- xarray_annotated/__init__.py +50 -0
- xarray_annotated/_annotations.py +119 -0
- xarray_annotated/_config.py +101 -0
- xarray_annotated/_reader.py +110 -0
- xarray_annotated/_writer.py +99 -0
- xarray_annotated/py.typed +0 -0
- xarray_annotated/schema/__init__.py +62 -0
- xarray_annotated/schema/_annotations.py +254 -0
- xarray_annotated/schema/_check.py +232 -0
- xarray_annotated/schema/_config.py +147 -0
- xarray_annotated/schema/_decorator.py +124 -0
- xarray_annotated/temporal/__init__.py +60 -0
- xarray_annotated/temporal/_annotations.py +154 -0
- xarray_annotated/temporal/_check.py +326 -0
- xarray_annotated/temporal/_config.py +182 -0
- xarray_annotated/temporal/_decorator.py +133 -0
- xarray_annotated/units/__init__.py +53 -0
- xarray_annotated/units/_annotations.py +169 -0
- xarray_annotated/units/_check.py +592 -0
- xarray_annotated/units/_config.py +237 -0
- xarray_annotated/units/_decorator.py +203 -0
- xarray_annotated/units/_registry.py +94 -0
- xarray_annotated-0.5.2.dist-info/METADATA +156 -0
- xarray_annotated-0.5.2.dist-info/RECORD +26 -0
- xarray_annotated-0.5.2.dist-info/WHEEL +4 -0
- xarray_annotated-0.5.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Typed markers declaring a DataArray's structural properties, and the reader.
|
|
2
|
+
|
|
3
|
+
The structural counterpart to ``units._annotations``: the properties every
|
|
4
|
+
``DataArray`` possesses regardless of physical units — its dimensions
|
|
5
|
+
(``Dims``), coordinates (``Coords``), and dtype (``Dtype``). Used inside
|
|
6
|
+
``Annotated`` exactly as ``Unit`` is, and — unlike units — **several at once**::
|
|
7
|
+
|
|
8
|
+
Annotated[xr.DataArray, Dims("time", "lat", "lon")]
|
|
9
|
+
Annotated[xr.DataArray, Dims("time", "x"), Dtype("float64")]
|
|
10
|
+
|
|
11
|
+
Each marker carries the *strictness* of its own check (dims order, exact dtype)
|
|
12
|
+
and an optional per-marker ``on_mismatch`` severity override, because the three
|
|
13
|
+
properties have different natural defaults and a wrong dtype is often less severe
|
|
14
|
+
than a wrong set of dims. The markers are immutable (``__slots__``) and hashable
|
|
15
|
+
so an annotation carrying them stays hashable/cacheable; ``eq``/``repr`` cover all
|
|
16
|
+
fields, so ``eval(repr(m)) == m``.
|
|
17
|
+
|
|
18
|
+
There is deliberately **no bare-string shorthand** (a string in the metadata is a
|
|
19
|
+
description, never a declaration): only these typed markers are read.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from typing import (
|
|
23
|
+
Annotated,
|
|
24
|
+
Any,
|
|
25
|
+
get_args,
|
|
26
|
+
get_origin,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .._annotations import _is_dataarray_type, walk_signature
|
|
30
|
+
from ._config import OnMismatch, _validate_on_mismatch
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_on_mismatch(value: OnMismatch | None) -> OnMismatch | None:
|
|
34
|
+
"""Validate a marker's optional ``on_mismatch`` override at construction."""
|
|
35
|
+
return None if value is None else _validate_on_mismatch(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Dims:
|
|
39
|
+
"""Declare the expected dimension names of a DataArray.
|
|
40
|
+
|
|
41
|
+
``Dims("time", "lat", "lon")`` declares an array over exactly those dims.
|
|
42
|
+
By default the *set* of dims must match (extra or missing dims fail) but their
|
|
43
|
+
order is free — xarray operations are order-independent until you drop to
|
|
44
|
+
numpy. Pass ``ordered=True`` to also pin the order (e.g. before ``.values``,
|
|
45
|
+
``.stack`` or ``apply_ufunc``).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_names: tuple[str, ...]
|
|
49
|
+
_ordered: bool
|
|
50
|
+
_on_mismatch: OnMismatch | None
|
|
51
|
+
__slots__ = ("_names", "_ordered", "_on_mismatch")
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*names: str,
|
|
56
|
+
ordered: bool = False,
|
|
57
|
+
on_mismatch: OnMismatch | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._names = tuple(names)
|
|
60
|
+
self._ordered = ordered
|
|
61
|
+
self._on_mismatch = _check_on_mismatch(on_mismatch)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def names(self) -> tuple[str, ...]:
|
|
65
|
+
"""The declared dimension names, in the order given."""
|
|
66
|
+
return self._names
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def ordered(self) -> bool:
|
|
70
|
+
"""Whether dim order (not just the set) must match."""
|
|
71
|
+
return self._ordered
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def on_mismatch(self) -> OnMismatch | None:
|
|
75
|
+
"""Per-marker severity override, or `None` to use the policy default."""
|
|
76
|
+
return self._on_mismatch
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
parts = [repr(n) for n in self._names]
|
|
80
|
+
if self._ordered:
|
|
81
|
+
parts.append("ordered=True")
|
|
82
|
+
if self._on_mismatch is not None:
|
|
83
|
+
parts.append(f"on_mismatch={self._on_mismatch!r}")
|
|
84
|
+
return f"Dims({', '.join(parts)})"
|
|
85
|
+
|
|
86
|
+
def __eq__(self, other: object) -> bool:
|
|
87
|
+
if isinstance(other, Dims):
|
|
88
|
+
return (self._names, self._ordered, self._on_mismatch) == (
|
|
89
|
+
other._names,
|
|
90
|
+
other._ordered,
|
|
91
|
+
other._on_mismatch,
|
|
92
|
+
)
|
|
93
|
+
return NotImplemented
|
|
94
|
+
|
|
95
|
+
def __hash__(self) -> int:
|
|
96
|
+
return hash((self._names, self._ordered, self._on_mismatch))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Coords:
|
|
100
|
+
"""Declare coordinate variables a DataArray must carry.
|
|
101
|
+
|
|
102
|
+
``Coords("time", "lat")`` declares that those coordinates are present (as
|
|
103
|
+
*labels*, not merely dims — a dim can exist without coordinate values).
|
|
104
|
+
Extra coordinates are allowed; only the declared ones must be present.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
_names: tuple[str, ...]
|
|
108
|
+
_on_mismatch: OnMismatch | None
|
|
109
|
+
__slots__ = ("_names", "_on_mismatch")
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
*names: str,
|
|
114
|
+
on_mismatch: OnMismatch | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self._names = tuple(names)
|
|
117
|
+
self._on_mismatch = _check_on_mismatch(on_mismatch)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def names(self) -> tuple[str, ...]:
|
|
121
|
+
"""The declared coordinate names."""
|
|
122
|
+
return self._names
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def on_mismatch(self) -> OnMismatch | None:
|
|
126
|
+
"""Per-marker severity override, or `None` to use the policy default."""
|
|
127
|
+
return self._on_mismatch
|
|
128
|
+
|
|
129
|
+
def __repr__(self) -> str:
|
|
130
|
+
parts = [repr(n) for n in self._names]
|
|
131
|
+
if self._on_mismatch is not None:
|
|
132
|
+
parts.append(f"on_mismatch={self._on_mismatch!r}")
|
|
133
|
+
return f"Coords({', '.join(parts)})"
|
|
134
|
+
|
|
135
|
+
def __eq__(self, other: object) -> bool:
|
|
136
|
+
if isinstance(other, Coords):
|
|
137
|
+
return (self._names, self._on_mismatch) == (
|
|
138
|
+
other._names,
|
|
139
|
+
other._on_mismatch,
|
|
140
|
+
)
|
|
141
|
+
return NotImplemented
|
|
142
|
+
|
|
143
|
+
def __hash__(self) -> int:
|
|
144
|
+
return hash((self._names, self._on_mismatch))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Dtype:
|
|
148
|
+
"""Declare the expected dtype of a DataArray, e.g. ``Dtype("float64")``.
|
|
149
|
+
|
|
150
|
+
By default the check is by *kind*: any float satisfies ``Dtype("float64")``,
|
|
151
|
+
any integer satisfies ``Dtype("int32")`` — enough to catch an int/float or
|
|
152
|
+
bool/float mix-up without firing on ``f8`` vs ``f4``. Pass ``exact=True`` to
|
|
153
|
+
require the precise dtype (e.g. to pin memory footprint or a typed sink).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
_dtype: str
|
|
157
|
+
_exact: bool
|
|
158
|
+
_on_mismatch: OnMismatch | None
|
|
159
|
+
__slots__ = ("_dtype", "_exact", "_on_mismatch")
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
dtype: str,
|
|
164
|
+
*,
|
|
165
|
+
exact: bool = False,
|
|
166
|
+
on_mismatch: OnMismatch | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
self._dtype = dtype
|
|
169
|
+
self._exact = exact
|
|
170
|
+
self._on_mismatch = _check_on_mismatch(on_mismatch)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def dtype(self) -> str:
|
|
174
|
+
"""The declared dtype string."""
|
|
175
|
+
return self._dtype
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def exact(self) -> bool:
|
|
179
|
+
"""Whether the exact dtype (not just its kind) must match."""
|
|
180
|
+
return self._exact
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def on_mismatch(self) -> OnMismatch | None:
|
|
184
|
+
"""Per-marker severity override, or `None` to use the policy default."""
|
|
185
|
+
return self._on_mismatch
|
|
186
|
+
|
|
187
|
+
def __repr__(self) -> str:
|
|
188
|
+
parts = [repr(self._dtype)]
|
|
189
|
+
if self._exact:
|
|
190
|
+
parts.append("exact=True")
|
|
191
|
+
if self._on_mismatch is not None:
|
|
192
|
+
parts.append(f"on_mismatch={self._on_mismatch!r}")
|
|
193
|
+
return f"Dtype({', '.join(parts)})"
|
|
194
|
+
|
|
195
|
+
def __eq__(self, other: object) -> bool:
|
|
196
|
+
if isinstance(other, Dtype):
|
|
197
|
+
return (self._dtype, self._exact, self._on_mismatch) == (
|
|
198
|
+
other._dtype,
|
|
199
|
+
other._exact,
|
|
200
|
+
other._on_mismatch,
|
|
201
|
+
)
|
|
202
|
+
return NotImplemented
|
|
203
|
+
|
|
204
|
+
def __hash__(self) -> int:
|
|
205
|
+
return hash((self._dtype, self._exact, self._on_mismatch))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
#: The marker types the reader recognises in `Annotated` metadata.
|
|
209
|
+
SchemaMarker = Dims | Coords | Dtype
|
|
210
|
+
_SCHEMA_MARKERS = (Dims, Coords, Dtype)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def annotated_schema(hint: Any) -> list[SchemaMarker] | None:
|
|
214
|
+
"""Return every schema marker carried by an `Annotated` hint, or `None`.
|
|
215
|
+
|
|
216
|
+
Collects *all* `Dims` / `Coords` / `Dtype` markers in the metadata (a hint may
|
|
217
|
+
declare several structural properties at once), but only when the annotated
|
|
218
|
+
base type is a `DataArray` (incl. `DataArray | None`). Non-`Annotated` hints,
|
|
219
|
+
hints on non-DataArray types, and hints with no schema marker return `None`
|
|
220
|
+
(so `walk_signature`'s "no declaration" filter drops them).
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
hint: A type hint, typically from `get_type_hints(..., include_extras=True)`.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
A non-empty list of markers in annotation order, or `None`.
|
|
227
|
+
"""
|
|
228
|
+
if get_origin(hint) is not Annotated:
|
|
229
|
+
return None
|
|
230
|
+
args = get_args(hint)
|
|
231
|
+
if not _is_dataarray_type(args[0]):
|
|
232
|
+
return None
|
|
233
|
+
markers = [m for m in args[1:] if isinstance(m, _SCHEMA_MARKERS)]
|
|
234
|
+
return markers or None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def schema_from_signature(
|
|
238
|
+
func: object,
|
|
239
|
+
) -> tuple[
|
|
240
|
+
dict[str, list[SchemaMarker]],
|
|
241
|
+
dict[str, list[SchemaMarker]] | list[SchemaMarker] | None,
|
|
242
|
+
]:
|
|
243
|
+
"""Extract declared schema markers from a function's type annotations.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
func: A callable whose hints carry `Annotated[DataArray, <markers>]`.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
An `(inputs, output)` pair, where each declaration is the list of markers
|
|
250
|
+
on that parameter/field; `output` is a per-field dict for a `TypedDict` or
|
|
251
|
+
dataclass return, a single list for one declared `DataArray` return, or
|
|
252
|
+
`None`.
|
|
253
|
+
"""
|
|
254
|
+
return walk_signature(func, annotated_schema)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Runtime structural validation of a DataArray against declared markers.
|
|
2
|
+
|
|
3
|
+
The "usage side" of the schema domain: given a `DataArray` and the `Dims` /
|
|
4
|
+
`Coords` / `Dtype` markers declared for it, check each one under the active policy.
|
|
5
|
+
Unlike the units domain this **never mutates** — it asserts and (per `on_mismatch`)
|
|
6
|
+
raises, warns, or ignores, then returns the array unchanged, so a decorator can
|
|
7
|
+
drop it into an argument-validation pass symmetrically with `check_units`.
|
|
8
|
+
|
|
9
|
+
The per-property checks are independent functions dispatched by marker type, so a
|
|
10
|
+
new structural property is added by writing one checker and one dispatch entry —
|
|
11
|
+
nothing else here changes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import warnings
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import xarray as xr
|
|
20
|
+
|
|
21
|
+
from ._annotations import Coords, Dims, Dtype, SchemaMarker
|
|
22
|
+
from ._config import OnMismatch, _validate_on_mismatch, get_policy
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SchemaWarning(UserWarning):
|
|
26
|
+
"""Warning issued for a structural mismatch under ``on_mismatch="warn"``."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SchemaError(Exception):
|
|
30
|
+
"""Raised for a structural mismatch under ``on_mismatch="error"``.
|
|
31
|
+
|
|
32
|
+
Deliberately **not** a ``ValueError``: a structural mismatch (the *data* fails
|
|
33
|
+
the declaration) is a distinct event from a malformed *declaration* (an
|
|
34
|
+
unparseable dtype, duplicate dim names), which raises ``ValueError`` from
|
|
35
|
+
``assert_valid_schema``. Keeping them on separate hierarchies means catching
|
|
36
|
+
one never silently swallows the other.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def assert_valid_schema(marker: SchemaMarker, context: str) -> None:
|
|
41
|
+
"""Validate a marker *declaration* itself, independent of any DataArray.
|
|
42
|
+
|
|
43
|
+
Used at decoration time to fail fast on a malformed declaration (e.g. an
|
|
44
|
+
unparseable dtype string or duplicate dim names) rather than only when the
|
|
45
|
+
decorated function first runs.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
marker: The declared `Dims`, `Coords`, or `Dtype` marker.
|
|
49
|
+
context: A label for the error message (e.g. ``"f input 'x'"``).
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If the declaration is malformed.
|
|
53
|
+
"""
|
|
54
|
+
kind = type(marker).__name__
|
|
55
|
+
if isinstance(marker, Dtype):
|
|
56
|
+
try:
|
|
57
|
+
np.dtype(marker.dtype)
|
|
58
|
+
except TypeError as exc:
|
|
59
|
+
raise ValueError(f"{context}: invalid dtype {marker.dtype!r}.") from exc
|
|
60
|
+
else: # Dims or Coords: a tuple of names
|
|
61
|
+
for name in marker.names:
|
|
62
|
+
if not isinstance(name, str) or not name:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"{context}: {kind} names must be non-empty strings, "
|
|
65
|
+
f"got {marker.names!r}."
|
|
66
|
+
)
|
|
67
|
+
if len(set(marker.names)) != len(marker.names):
|
|
68
|
+
raise ValueError(f"{context}: {kind} has duplicate names {marker.names!r}.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check_dims(da: xr.DataArray, marker: Dims) -> tuple[bool, str]:
|
|
72
|
+
"""Check a DataArray's dims against a `Dims` marker. Returns (ok, detail)."""
|
|
73
|
+
actual = tuple(da.dims)
|
|
74
|
+
if marker.ordered:
|
|
75
|
+
ok = actual == marker.names
|
|
76
|
+
detail = f"dims order mismatch: expected {marker.names}, got {actual}"
|
|
77
|
+
else:
|
|
78
|
+
ok = set(actual) == set(marker.names)
|
|
79
|
+
detail = f"dims mismatch: expected {marker.names} in any order, got {actual}"
|
|
80
|
+
return ok, detail
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _check_coords(da: xr.DataArray, marker: Coords) -> tuple[bool, str]:
|
|
84
|
+
"""Check a DataArray carries the declared coords. Returns (ok, detail)."""
|
|
85
|
+
actual = set(map(str, da.coords))
|
|
86
|
+
missing = [n for n in marker.names if n not in actual]
|
|
87
|
+
detail = f"missing coords {missing}; present: {sorted(actual)}"
|
|
88
|
+
return not missing, detail
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_dtype(da: xr.DataArray, marker: Dtype) -> tuple[bool, str]:
|
|
92
|
+
"""Check a DataArray's dtype against a `Dtype` marker. Returns (ok, detail)."""
|
|
93
|
+
declared = np.dtype(marker.dtype)
|
|
94
|
+
actual = da.dtype
|
|
95
|
+
if marker.exact:
|
|
96
|
+
ok = actual == declared
|
|
97
|
+
detail = f"dtype mismatch: expected exactly {declared}, got {actual}"
|
|
98
|
+
else:
|
|
99
|
+
ok = actual.kind == declared.kind
|
|
100
|
+
detail = (
|
|
101
|
+
f"dtype kind mismatch: expected kind {declared.kind!r} "
|
|
102
|
+
f"(like {declared}), got {actual}"
|
|
103
|
+
)
|
|
104
|
+
return ok, detail
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def dims_compatible(a: Dims, b: Dims) -> bool:
|
|
108
|
+
"""Return whether two `Dims` declarations can describe the same array.
|
|
109
|
+
|
|
110
|
+
The marker-vs-marker counterpart to validating an array against a `Dims`
|
|
111
|
+
declaration (no array in hand), for a static / build-time check of a
|
|
112
|
+
producer/consumer edge: two declarations are
|
|
113
|
+
*provably inconsistent* only if their dim **sets** differ, or if both pin the
|
|
114
|
+
order (`ordered=True`) and the orders disagree. A loose declaration on either
|
|
115
|
+
side can always be satisfied, so it never conflicts.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
a: A `Dims` marker.
|
|
119
|
+
b: Another `Dims` marker.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
`True` unless the two are provably inconsistent.
|
|
123
|
+
|
|
124
|
+
Examples:
|
|
125
|
+
>>> from xarray_annotated.schema import Dims, dims_compatible
|
|
126
|
+
>>> dims_compatible(Dims("x", "y"), Dims("y", "x"))
|
|
127
|
+
True
|
|
128
|
+
>>> dims_compatible(Dims("x"), Dims("x", "y"))
|
|
129
|
+
False
|
|
130
|
+
>>> dims_compatible(Dims("x", "y", ordered=True), Dims("y", "x", ordered=True))
|
|
131
|
+
False
|
|
132
|
+
"""
|
|
133
|
+
if set(a.names) != set(b.names):
|
|
134
|
+
return False
|
|
135
|
+
return not (a.ordered and b.ordered and a.names != b.names)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def dtype_compatible(a: Dtype, b: Dtype) -> bool:
|
|
139
|
+
"""Return whether two `Dtype` declarations can describe the same array.
|
|
140
|
+
|
|
141
|
+
The marker-vs-marker counterpart to validating an array against a `Dtype`
|
|
142
|
+
declaration (no array in hand): two
|
|
143
|
+
declarations are *provably inconsistent* only if their numpy **kinds** differ
|
|
144
|
+
(float vs int), or if both require the exact dtype (`exact=True`) and those
|
|
145
|
+
dtypes differ (`f8` vs `f4`). A kind-only declaration matches any width, so
|
|
146
|
+
it never conflicts on width alone.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
a: A `Dtype` marker.
|
|
150
|
+
b: Another `Dtype` marker.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
`True` unless the two are provably inconsistent.
|
|
154
|
+
|
|
155
|
+
Examples:
|
|
156
|
+
>>> from xarray_annotated.schema import Dtype, dtype_compatible
|
|
157
|
+
>>> dtype_compatible(Dtype("float64"), Dtype("float32"))
|
|
158
|
+
True
|
|
159
|
+
>>> dtype_compatible(Dtype("float64"), Dtype("int64"))
|
|
160
|
+
False
|
|
161
|
+
>>> dtype_compatible(Dtype("float64", exact=True), Dtype("float32", exact=True))
|
|
162
|
+
False
|
|
163
|
+
"""
|
|
164
|
+
if np.dtype(a.dtype).kind != np.dtype(b.dtype).kind:
|
|
165
|
+
return False
|
|
166
|
+
return not (a.exact and b.exact and np.dtype(a.dtype) != np.dtype(b.dtype))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# There is deliberately no `coords_compatible`: two `Coords` declarations are
|
|
170
|
+
# lower bounds (each merely requires its names be present, extras allowed), so
|
|
171
|
+
# they can never be *proven* inconsistent — a build-time edge check skips coords.
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
_CHECKERS: dict[type, Callable[[xr.DataArray, Any], tuple[bool, str]]] = {
|
|
175
|
+
Dims: _check_dims,
|
|
176
|
+
Coords: _check_coords,
|
|
177
|
+
Dtype: _check_dtype,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _report(ok: bool, severity: OnMismatch, message: str) -> None:
|
|
182
|
+
"""Act on a check result under the resolved severity."""
|
|
183
|
+
if ok or severity == "ignore":
|
|
184
|
+
return
|
|
185
|
+
if severity == "error":
|
|
186
|
+
raise SchemaError(message)
|
|
187
|
+
# stacklevel=3 targets the declare_schema call path (user -> wrapper ->
|
|
188
|
+
# check_schema -> _report); one frame too far for a direct check_schema call.
|
|
189
|
+
warnings.warn(message, SchemaWarning, stacklevel=3)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check_schema(
|
|
193
|
+
da: xr.DataArray,
|
|
194
|
+
declared: SchemaMarker | list[SchemaMarker],
|
|
195
|
+
name: str,
|
|
196
|
+
on_mismatch: OnMismatch | None = None,
|
|
197
|
+
qualname: str | None = None,
|
|
198
|
+
) -> xr.DataArray:
|
|
199
|
+
"""Validate `da` against its declared schema marker(s); return it unchanged.
|
|
200
|
+
|
|
201
|
+
Runs each marker's checker under the effective severity, resolved per marker
|
|
202
|
+
as: the marker's own `on_mismatch` override, else the `on_mismatch` argument,
|
|
203
|
+
else the policy default (`get_policy().on_mismatch`). When the master switch
|
|
204
|
+
is off (`enabled=False`) this is a total no-op.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
da: The DataArray to validate.
|
|
208
|
+
declared: A single marker or a list of markers declared for `da`.
|
|
209
|
+
name: The parameter/field name, for error messages.
|
|
210
|
+
on_mismatch: Per-call default severity, overriding the policy default but
|
|
211
|
+
overridden by a marker's own `on_mismatch`.
|
|
212
|
+
qualname: Optional qualified function name, prefixed to messages.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
`da`, unchanged (validation never mutates).
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
SchemaError: On a mismatch when the effective severity is ``"error"``.
|
|
219
|
+
"""
|
|
220
|
+
pol = get_policy()
|
|
221
|
+
if not pol.enabled:
|
|
222
|
+
return da
|
|
223
|
+
if on_mismatch is not None:
|
|
224
|
+
on_mismatch = _validate_on_mismatch(on_mismatch)
|
|
225
|
+
|
|
226
|
+
markers = [declared] if isinstance(declared, (Dims, Coords, Dtype)) else declared
|
|
227
|
+
prefix = f"[{qualname}] {name!r}" if qualname else f"{name!r}"
|
|
228
|
+
for marker in markers:
|
|
229
|
+
ok, detail = _CHECKERS[type(marker)](da, marker)
|
|
230
|
+
severity = marker.on_mismatch or on_mismatch or pol.on_mismatch
|
|
231
|
+
_report(ok, severity, f"{prefix} {detail}")
|
|
232
|
+
return da
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Process-wide validation policy for the structural (schema) domain.
|
|
2
|
+
|
|
3
|
+
Simpler than the units policy because structural validation never converts — it
|
|
4
|
+
only asserts. Two axes:
|
|
5
|
+
|
|
6
|
+
- `enabled` — the **package-wide** master switch (shared with every domain via
|
|
7
|
+
`xarray_annotated._config`); `False` makes all validation a no-op.
|
|
8
|
+
- `on_mismatch` — what to do when a DataArray's structure does not match a
|
|
9
|
+
declaration (`"error"` / `"warn"` / `"ignore"`). This is the *default*; an
|
|
10
|
+
individual marker may override it (e.g. error on wrong dims but only warn on
|
|
11
|
+
wrong dtype).
|
|
12
|
+
|
|
13
|
+
Each axis resolves independently: environment variable, then a process override
|
|
14
|
+
set via `set_policy` / the `policy` context manager, then the module default. The
|
|
15
|
+
shared `_resolve` helper encodes that precedence.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
from .._config import (
|
|
23
|
+
DEFAULT_ENABLED,
|
|
24
|
+
ENABLED_ENV_VAR,
|
|
25
|
+
_resolve,
|
|
26
|
+
_Unset,
|
|
27
|
+
_UNSET,
|
|
28
|
+
get_enabled_override,
|
|
29
|
+
resolve_enabled,
|
|
30
|
+
set_enabled_override,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# `ENABLED_ENV_VAR` is re-exported (the master switch is shared but callers still
|
|
34
|
+
# reach it as `schema._config.ENABLED_ENV_VAR`).
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ENABLED_ENV_VAR",
|
|
37
|
+
"OnMismatch",
|
|
38
|
+
"Policy",
|
|
39
|
+
"get_policy",
|
|
40
|
+
"policy",
|
|
41
|
+
"set_policy",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
OnMismatch = Literal["error", "warn", "ignore"]
|
|
45
|
+
|
|
46
|
+
_VALID_ON_MISMATCH: frozenset[str] = frozenset({"error", "warn", "ignore"})
|
|
47
|
+
|
|
48
|
+
#: A structural mismatch is usually a genuine bug (wrong array wired in), so the
|
|
49
|
+
#: default is to raise. Soften it per-marker (`Dims(..., on_mismatch="warn")`) or
|
|
50
|
+
#: package-wide via the policy.
|
|
51
|
+
DEFAULT_ON_MISMATCH: OnMismatch = "error"
|
|
52
|
+
|
|
53
|
+
ON_MISMATCH_ENV_VAR = "XARRAY_ANNOTATED_SCHEMA_ON_MISMATCH"
|
|
54
|
+
|
|
55
|
+
_process_on_mismatch: OnMismatch | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class Policy:
|
|
60
|
+
"""The resolved schema-validation policy — the two axes as a single value.
|
|
61
|
+
|
|
62
|
+
Returned by `get_policy`. Build overrides via `set_policy` or the `policy`
|
|
63
|
+
context manager rather than constructing this directly.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
enabled: Master switch; `False` makes all validation a no-op.
|
|
67
|
+
on_mismatch: Default behaviour on a structural mismatch
|
|
68
|
+
(`"error"`, `"warn"`, or `"ignore"`); a marker may override it.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
enabled: bool = DEFAULT_ENABLED
|
|
72
|
+
on_mismatch: OnMismatch = DEFAULT_ON_MISMATCH
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _validate_on_mismatch(value: str) -> OnMismatch:
|
|
76
|
+
if value not in _VALID_ON_MISMATCH:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Invalid on_mismatch {value!r}. "
|
|
79
|
+
f"Choose one of {sorted(_VALID_ON_MISMATCH)}."
|
|
80
|
+
)
|
|
81
|
+
return value # type: ignore[return-value]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_policy() -> Policy:
|
|
85
|
+
"""Resolve the active schema policy (env → process → default, per axis).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The resolved `Policy`.
|
|
89
|
+
"""
|
|
90
|
+
return Policy(
|
|
91
|
+
enabled=resolve_enabled(),
|
|
92
|
+
on_mismatch=_resolve(
|
|
93
|
+
ON_MISMATCH_ENV_VAR,
|
|
94
|
+
_process_on_mismatch,
|
|
95
|
+
DEFAULT_ON_MISMATCH,
|
|
96
|
+
lambda v: _validate_on_mismatch(v.lower()),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def set_policy(
|
|
102
|
+
*,
|
|
103
|
+
enabled: bool | None | _Unset = _UNSET,
|
|
104
|
+
on_mismatch: OnMismatch | None | _Unset = _UNSET,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Set process-wide schema-policy overrides for one or more axes.
|
|
107
|
+
|
|
108
|
+
Only the axes you pass are changed. Pass a value to set it, `None` to
|
|
109
|
+
*clear* that axis's override (so its env var / default applies again), or
|
|
110
|
+
omit it to leave it untouched.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
enabled: Override the (package-wide) master switch, or `None` to clear.
|
|
114
|
+
on_mismatch: Override the on-mismatch axis, or `None` to clear.
|
|
115
|
+
"""
|
|
116
|
+
global _process_on_mismatch
|
|
117
|
+
if not isinstance(enabled, _Unset):
|
|
118
|
+
set_enabled_override(enabled)
|
|
119
|
+
if not isinstance(on_mismatch, _Unset):
|
|
120
|
+
_process_on_mismatch = (
|
|
121
|
+
None if on_mismatch is None else _validate_on_mismatch(on_mismatch)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def policy(
|
|
127
|
+
*,
|
|
128
|
+
enabled: bool | None | _Unset = _UNSET,
|
|
129
|
+
on_mismatch: OnMismatch | None | _Unset = _UNSET,
|
|
130
|
+
):
|
|
131
|
+
"""Temporarily override schema-policy axes, restoring them on exit.
|
|
132
|
+
|
|
133
|
+
Sets every axis you pass in one go, and restores the previous process
|
|
134
|
+
overrides afterwards even if an exception is raised.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
enabled: Override the (package-wide) master switch, or `None` to clear.
|
|
138
|
+
on_mismatch: Override the on-mismatch axis, or `None` to clear.
|
|
139
|
+
"""
|
|
140
|
+
global _process_on_mismatch
|
|
141
|
+
saved = (get_enabled_override(), _process_on_mismatch)
|
|
142
|
+
set_policy(enabled=enabled, on_mismatch=on_mismatch)
|
|
143
|
+
try:
|
|
144
|
+
yield
|
|
145
|
+
finally:
|
|
146
|
+
enabled_saved, _process_on_mismatch = saved
|
|
147
|
+
set_enabled_override(enabled_saved)
|