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,237 @@
|
|
|
1
|
+
"""Process-wide validation policy: four orthogonal axes.
|
|
2
|
+
|
|
3
|
+
`check_units` handles three distinct events, and the policy exposes one independent
|
|
4
|
+
control per event, plus one axis governing how declared *outputs* are treated:
|
|
5
|
+
|
|
6
|
+
- `enabled` — master switch; when `False` no validation happens at all (a true
|
|
7
|
+
no-op, at every layer). This is the **package-wide** switch shared with every
|
|
8
|
+
other domain; it lives in `xarray_annotated._config` and is merely surfaced on
|
|
9
|
+
this domain's `Policy`.
|
|
10
|
+
- `on_missing` — what to do when a `DataArray` has no parseable unit to check
|
|
11
|
+
against (an absent or unparseable `units` attribute): `"error"` / `"warn"` /
|
|
12
|
+
`"ignore"`.
|
|
13
|
+
- `on_inexact` — what to do when the declared and actual units are dimensionally
|
|
14
|
+
compatible but *value-changing* (e.g. `"hPa"` where `"Pa"` is declared):
|
|
15
|
+
`"convert"` / `"warn"` / `"error"`.
|
|
16
|
+
- `on_output` — whether a declared return value's own `units` attribute is
|
|
17
|
+
verified before being overwritten: `"stamp"` / `"strict"`. See
|
|
18
|
+
`apply_output_units` for why the default cannot be otherwise.
|
|
19
|
+
|
|
20
|
+
A fourth case — a *dimensional* mismatch (e.g. a mass where a pressure is declared)
|
|
21
|
+
— is deliberately not configurable **on inputs**: it always raises, because the
|
|
22
|
+
values cannot be converted and continuing would corrupt results silently.
|
|
23
|
+
|
|
24
|
+
Each axis resolves independently: environment variable, then a process override set
|
|
25
|
+
via `set_policy` / the `policy` context manager, then the module default. The
|
|
26
|
+
shared `_resolve` helper encodes that precedence.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
from .._config import (
|
|
34
|
+
DEFAULT_ENABLED,
|
|
35
|
+
ENABLED_ENV_VAR,
|
|
36
|
+
_resolve,
|
|
37
|
+
_Unset,
|
|
38
|
+
_UNSET,
|
|
39
|
+
get_enabled_override,
|
|
40
|
+
resolve_enabled,
|
|
41
|
+
set_enabled_override,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# `ENABLED_ENV_VAR` / `DEFAULT_ENABLED` are re-exported (the master switch is
|
|
45
|
+
# shared but callers still reach it as `units._config.ENABLED_ENV_VAR`).
|
|
46
|
+
__all__ = ["ENABLED_ENV_VAR", "Policy", "get_policy", "policy", "set_policy"]
|
|
47
|
+
|
|
48
|
+
OnMissing = Literal["error", "warn", "ignore"]
|
|
49
|
+
OnInexact = Literal["convert", "warn", "error"]
|
|
50
|
+
OnOutput = Literal["stamp", "strict"]
|
|
51
|
+
|
|
52
|
+
_VALID_ON_MISSING: frozenset[str] = frozenset({"error", "warn", "ignore"})
|
|
53
|
+
_VALID_ON_INEXACT: frozenset[str] = frozenset({"convert", "warn", "error"})
|
|
54
|
+
_VALID_ON_OUTPUT: frozenset[str] = frozenset({"stamp", "strict"})
|
|
55
|
+
|
|
56
|
+
#: When a `DataArray` carries no parseable unit, `warn` flags it without
|
|
57
|
+
#: failing — non-breaking for inputs that lack unit metadata, and dev-friendly.
|
|
58
|
+
DEFAULT_ON_MISSING: OnMissing = "warn"
|
|
59
|
+
|
|
60
|
+
#: A dimensionally compatible but value-changing unit is silently converted by
|
|
61
|
+
#: default; `warn` also converts but says so, `error` refuses.
|
|
62
|
+
DEFAULT_ON_INEXACT: OnInexact = "convert"
|
|
63
|
+
|
|
64
|
+
#: Outputs are stamped, not verified, by default: a body that does its own unit
|
|
65
|
+
#: arithmetic leaves `attrs` stale, and re-converting would corrupt the values.
|
|
66
|
+
DEFAULT_ON_OUTPUT: OnOutput = "stamp"
|
|
67
|
+
|
|
68
|
+
ON_MISSING_ENV_VAR = "XARRAY_ANNOTATED_UNITS_ON_MISSING"
|
|
69
|
+
ON_INEXACT_ENV_VAR = "XARRAY_ANNOTATED_UNITS_ON_INEXACT"
|
|
70
|
+
ON_OUTPUT_ENV_VAR = "XARRAY_ANNOTATED_UNITS_ON_OUTPUT"
|
|
71
|
+
|
|
72
|
+
_process_on_missing: OnMissing | None = None
|
|
73
|
+
_process_on_inexact: OnInexact | None = None
|
|
74
|
+
_process_on_output: OnOutput | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class Policy:
|
|
79
|
+
"""The resolved validation policy — the four axes as a single value.
|
|
80
|
+
|
|
81
|
+
Returned by `get_policy`. Build overrides via `set_policy` or the `policy`
|
|
82
|
+
context manager rather than constructing this directly.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
enabled: Master switch; `False` makes all validation a no-op.
|
|
86
|
+
on_missing: Behaviour when a `DataArray` has no parseable unit
|
|
87
|
+
(`"error"`, `"warn"`, or `"ignore"`).
|
|
88
|
+
on_inexact: Behaviour for a value-changing conversion
|
|
89
|
+
(`"convert"`, `"warn"`, or `"error"`).
|
|
90
|
+
on_output: How much to trust a declared return value (`"stamp"` or
|
|
91
|
+
`"strict"`).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
enabled: bool = DEFAULT_ENABLED
|
|
95
|
+
on_missing: OnMissing = DEFAULT_ON_MISSING
|
|
96
|
+
on_inexact: OnInexact = DEFAULT_ON_INEXACT
|
|
97
|
+
on_output: OnOutput = DEFAULT_ON_OUTPUT
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _validate_on_missing(value: str) -> OnMissing:
|
|
101
|
+
if value not in _VALID_ON_MISSING:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Invalid on_missing {value!r}. Choose one of {sorted(_VALID_ON_MISSING)}."
|
|
104
|
+
)
|
|
105
|
+
return value # type: ignore[return-value]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_on_inexact(value: str) -> OnInexact:
|
|
109
|
+
if value not in _VALID_ON_INEXACT:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Invalid on_inexact {value!r}. Choose one of {sorted(_VALID_ON_INEXACT)}."
|
|
112
|
+
)
|
|
113
|
+
return value # type: ignore[return-value]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validate_on_output(value: str) -> OnOutput:
|
|
117
|
+
if value not in _VALID_ON_OUTPUT:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Invalid on_output {value!r}. Choose one of {sorted(_VALID_ON_OUTPUT)}."
|
|
120
|
+
)
|
|
121
|
+
return value # type: ignore[return-value]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_policy() -> Policy:
|
|
125
|
+
"""Resolve the active validation policy (env → process → default, per axis).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The resolved `Policy`.
|
|
129
|
+
"""
|
|
130
|
+
return Policy(
|
|
131
|
+
enabled=resolve_enabled(),
|
|
132
|
+
on_missing=_resolve(
|
|
133
|
+
ON_MISSING_ENV_VAR,
|
|
134
|
+
_process_on_missing,
|
|
135
|
+
DEFAULT_ON_MISSING,
|
|
136
|
+
lambda v: _validate_on_missing(v.lower()),
|
|
137
|
+
),
|
|
138
|
+
on_inexact=_resolve(
|
|
139
|
+
ON_INEXACT_ENV_VAR,
|
|
140
|
+
_process_on_inexact,
|
|
141
|
+
DEFAULT_ON_INEXACT,
|
|
142
|
+
lambda v: _validate_on_inexact(v.lower()),
|
|
143
|
+
),
|
|
144
|
+
on_output=_resolve(
|
|
145
|
+
ON_OUTPUT_ENV_VAR,
|
|
146
|
+
_process_on_output,
|
|
147
|
+
DEFAULT_ON_OUTPUT,
|
|
148
|
+
lambda v: _validate_on_output(v.lower()),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def set_policy(
|
|
154
|
+
*,
|
|
155
|
+
enabled: bool | None | _Unset = _UNSET,
|
|
156
|
+
on_missing: OnMissing | None | _Unset = _UNSET,
|
|
157
|
+
on_inexact: OnInexact | None | _Unset = _UNSET,
|
|
158
|
+
on_output: OnOutput | None | _Unset = _UNSET,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Set process-wide policy overrides for one or more axes.
|
|
161
|
+
|
|
162
|
+
Only the axes you pass are changed. Pass a value to set it, `None` to
|
|
163
|
+
*clear* that axis's override (so its env var / default applies again), or
|
|
164
|
+
omit it to leave it untouched.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
enabled: Override the (package-wide) master switch, or `None` to clear.
|
|
168
|
+
on_missing: Override the on-missing axis, or `None` to clear.
|
|
169
|
+
on_inexact: Override the on-inexact axis, or `None` to clear.
|
|
170
|
+
on_output: Override the on-output axis, or `None` to clear.
|
|
171
|
+
"""
|
|
172
|
+
global _process_on_missing, _process_on_inexact, _process_on_output
|
|
173
|
+
if not isinstance(enabled, _Unset):
|
|
174
|
+
set_enabled_override(enabled)
|
|
175
|
+
if not isinstance(on_missing, _Unset):
|
|
176
|
+
_process_on_missing = (
|
|
177
|
+
None if on_missing is None else _validate_on_missing(on_missing)
|
|
178
|
+
)
|
|
179
|
+
if not isinstance(on_inexact, _Unset):
|
|
180
|
+
_process_on_inexact = (
|
|
181
|
+
None if on_inexact is None else _validate_on_inexact(on_inexact)
|
|
182
|
+
)
|
|
183
|
+
if not isinstance(on_output, _Unset):
|
|
184
|
+
_process_on_output = (
|
|
185
|
+
None if on_output is None else _validate_on_output(on_output)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@contextmanager
|
|
190
|
+
def policy(
|
|
191
|
+
*,
|
|
192
|
+
enabled: bool | None | _Unset = _UNSET,
|
|
193
|
+
on_missing: OnMissing | None | _Unset = _UNSET,
|
|
194
|
+
on_inexact: OnInexact | None | _Unset = _UNSET,
|
|
195
|
+
on_output: OnOutput | None | _Unset = _UNSET,
|
|
196
|
+
):
|
|
197
|
+
"""Temporarily override policy axes, restoring all of them on exit.
|
|
198
|
+
|
|
199
|
+
Sets every axis you pass in one go, and restores the previous process
|
|
200
|
+
overrides afterwards even if an exception is raised.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
enabled: Override the (package-wide) master switch, or `None` to clear.
|
|
204
|
+
on_missing: Override the on-missing axis, or `None` to clear.
|
|
205
|
+
on_inexact: Override the on-inexact axis, or `None` to clear.
|
|
206
|
+
on_output: Override the on-output axis, or `None` to clear.
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
>>> from xarray_annotated.units import policy
|
|
210
|
+
>>> import xarray as xr
|
|
211
|
+
>>> da = xr.DataArray([1.0], attrs={"units": "Pa"})
|
|
212
|
+
>>> with policy(on_missing="error"):
|
|
213
|
+
... pass # policy restored after block
|
|
214
|
+
"""
|
|
215
|
+
global _process_on_missing, _process_on_inexact, _process_on_output
|
|
216
|
+
saved = (
|
|
217
|
+
get_enabled_override(),
|
|
218
|
+
_process_on_missing,
|
|
219
|
+
_process_on_inexact,
|
|
220
|
+
_process_on_output,
|
|
221
|
+
)
|
|
222
|
+
set_policy(
|
|
223
|
+
enabled=enabled,
|
|
224
|
+
on_missing=on_missing,
|
|
225
|
+
on_inexact=on_inexact,
|
|
226
|
+
on_output=on_output,
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
yield
|
|
230
|
+
finally:
|
|
231
|
+
(
|
|
232
|
+
enabled_saved,
|
|
233
|
+
_process_on_missing,
|
|
234
|
+
_process_on_inexact,
|
|
235
|
+
_process_on_output,
|
|
236
|
+
) = saved
|
|
237
|
+
set_enabled_override(enabled_saved)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Signature-driven unit declaration decorator.
|
|
2
|
+
|
|
3
|
+
`declare_units` wires the declaration side (`_annotations`) to the checking side
|
|
4
|
+
(`_check`): it reads a function's `Annotated[DataArray, Unit(...)]` hints once, at
|
|
5
|
+
decoration time, then on every call validates/converts the declared inputs and
|
|
6
|
+
stamps the declared outputs.
|
|
7
|
+
|
|
8
|
+
This is deliberately the *convenience* layer, and deliberately a plain function
|
|
9
|
+
decorator with no subclassing hierarchy. A tool that needs different behaviour
|
|
10
|
+
(build-time/static checks, custom value types, alternative policies) should build
|
|
11
|
+
its own consumer from the same public primitives — `units_from_signature`,
|
|
12
|
+
`check_units`, `assert_valid_unit` — which is exactly, and all, that this decorator
|
|
13
|
+
does. The two knobs most callers want are exposed as keyword arguments below.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import functools
|
|
18
|
+
import inspect
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import xarray as xr
|
|
23
|
+
|
|
24
|
+
from ._annotations import units_from_signature
|
|
25
|
+
from ._check import apply_output_units, assert_valid_unit, check_units
|
|
26
|
+
from ._config import (
|
|
27
|
+
OnInexact,
|
|
28
|
+
OnMissing,
|
|
29
|
+
OnOutput,
|
|
30
|
+
_validate_on_inexact,
|
|
31
|
+
_validate_on_missing,
|
|
32
|
+
_validate_on_output,
|
|
33
|
+
get_policy,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _set_field(result: Any, name: str, value: xr.DataArray, qualname: str) -> None:
|
|
38
|
+
"""Write a converted output back onto a dataclass return value.
|
|
39
|
+
|
|
40
|
+
Only reached when applying the declared unit produced a *new* array, which
|
|
41
|
+
for a dataclass means a pint-quantified field that needed converting — a
|
|
42
|
+
plain field is stamped in place and never comes through here.
|
|
43
|
+
|
|
44
|
+
A frozen dataclass cannot take the write. `setattr` raises
|
|
45
|
+
`FrozenInstanceError` (an `AttributeError`, despite reading like a type
|
|
46
|
+
error), which on its own says nothing about units; re-raise it with the
|
|
47
|
+
cause and the way out.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
result: The returned dataclass instance.
|
|
51
|
+
name: The field to write.
|
|
52
|
+
value: The converted array.
|
|
53
|
+
qualname: The decorated function, for the error message.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
dataclasses.FrozenInstanceError: If `result` is frozen.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
setattr(result, name, value)
|
|
60
|
+
except dataclasses.FrozenInstanceError as exc:
|
|
61
|
+
raise dataclasses.FrozenInstanceError(
|
|
62
|
+
f"[{qualname}] output {name!r} is pint-quantified and needs converting "
|
|
63
|
+
f"to its declared unit, but the returned dataclass is frozen so the "
|
|
64
|
+
f"converted array cannot be written back. Dequantify the field in the "
|
|
65
|
+
f"function body (`.pint.dequantify()`), or drop `frozen=True`."
|
|
66
|
+
) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def declare_units(
|
|
70
|
+
func: Callable[..., Any] | None = None,
|
|
71
|
+
*,
|
|
72
|
+
on_missing: OnMissing | None = None,
|
|
73
|
+
on_inexact: OnInexact | None = None,
|
|
74
|
+
on_output: OnOutput | None = None,
|
|
75
|
+
) -> Callable[..., Any]:
|
|
76
|
+
"""Apply a function's signature-declared units at runtime.
|
|
77
|
+
|
|
78
|
+
Reads the decorated function's own type annotations once, via
|
|
79
|
+
`units_from_signature`: parameters annotated `Annotated[DataArray, Unit(...)]`
|
|
80
|
+
(or the bare-string shorthand) declare input units, and a `TypedDict` return
|
|
81
|
+
(or a bare `Annotated[DataArray, Unit(...)]` return) declares output units.
|
|
82
|
+
Those annotations are the single source of truth, so a unit is never written
|
|
83
|
+
twice.
|
|
84
|
+
|
|
85
|
+
On each call, under the active `Policy` (`get_policy`), the wrapper:
|
|
86
|
+
|
|
87
|
+
1. validates/converts every declared `DataArray` input to its unit via
|
|
88
|
+
`check_units`;
|
|
89
|
+
2. runs the wrapped function;
|
|
90
|
+
3. applies each declared output unit to the returned `DataArray` (a `dict`
|
|
91
|
+
return is handled per key; a single `DataArray` return takes the bare
|
|
92
|
+
declared unit).
|
|
93
|
+
|
|
94
|
+
Note the asymmetry in step 3: an output is **stamped**, not converted. A
|
|
95
|
+
body that does its own unit arithmetic (`return p * 100.0`) leaves `attrs`
|
|
96
|
+
holding the *input's* unit, so converting on the strength of that label would
|
|
97
|
+
scale correct values a second time. The `on_output` axis can require the
|
|
98
|
+
label to match instead (`"strict"`), which suits only bodies that maintain
|
|
99
|
+
their own units; see `apply_output_units` for the trade-off.
|
|
100
|
+
|
|
101
|
+
Only `DataArray` values are touched; other arguments and returns pass
|
|
102
|
+
through unchanged. When the policy is **disabled** (`enabled=False`) the
|
|
103
|
+
wrapper is a total no-op: inputs are not converted and outputs are not
|
|
104
|
+
stamped.
|
|
105
|
+
|
|
106
|
+
Usable bare (`@declare_units`) or called
|
|
107
|
+
(`@declare_units(on_missing="error")`).
|
|
108
|
+
|
|
109
|
+
Every declared unit string is checked against the registry **at decoration
|
|
110
|
+
time**, so a malformed or undefined unit fails fast at import — regardless
|
|
111
|
+
of policy — rather than only when the function first runs.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
func: The function to decorate (when used bare). `None` when called
|
|
115
|
+
with keyword arguments, in which case a parametrised decorator is
|
|
116
|
+
returned.
|
|
117
|
+
on_missing: Override the on-missing axis for the decorated function.
|
|
118
|
+
When `None` (default) resolved per call from `get_policy`.
|
|
119
|
+
on_inexact: Override the on-inexact axis for the decorated function.
|
|
120
|
+
When `None` (default) resolved per call from `get_policy`.
|
|
121
|
+
on_output: Override the on-output axis for the decorated function.
|
|
122
|
+
When `None` (default) resolved per call from `get_policy`.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
A wrapped function that validates inputs and stamps outputs according
|
|
126
|
+
to the active policy.
|
|
127
|
+
"""
|
|
128
|
+
if on_missing is not None:
|
|
129
|
+
_validate_on_missing(on_missing)
|
|
130
|
+
if on_inexact is not None:
|
|
131
|
+
_validate_on_inexact(on_inexact)
|
|
132
|
+
if on_output is not None:
|
|
133
|
+
_validate_on_output(on_output)
|
|
134
|
+
|
|
135
|
+
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
136
|
+
input_units, output_units = units_from_signature(fn)
|
|
137
|
+
sig = inspect.signature(fn)
|
|
138
|
+
qualname = getattr(fn, "__qualname__", repr(fn))
|
|
139
|
+
|
|
140
|
+
# Fail fast on unparseable declarations (independent of policy).
|
|
141
|
+
for name, unit in input_units.items():
|
|
142
|
+
assert_valid_unit(unit, f"{qualname} input {name!r}")
|
|
143
|
+
if isinstance(output_units, str):
|
|
144
|
+
assert_valid_unit(output_units, f"{qualname} output")
|
|
145
|
+
elif isinstance(output_units, dict):
|
|
146
|
+
for name, unit in output_units.items():
|
|
147
|
+
assert_valid_unit(unit, f"{qualname} output {name!r}")
|
|
148
|
+
|
|
149
|
+
def _stamp(result: Any, eff_output: OnOutput | None) -> Any:
|
|
150
|
+
def apply(value: xr.DataArray, declared: str, name: str) -> xr.DataArray:
|
|
151
|
+
# Returns `value` itself for the attrs path (stamped in place),
|
|
152
|
+
# but a *converted* array for a quantified one -- so the result
|
|
153
|
+
# must always be written back, never discarded.
|
|
154
|
+
return apply_output_units(value, declared, name, eff_output, qualname)
|
|
155
|
+
|
|
156
|
+
if isinstance(output_units, str):
|
|
157
|
+
if isinstance(result, xr.DataArray):
|
|
158
|
+
return apply(result, output_units, "return")
|
|
159
|
+
elif isinstance(output_units, dict):
|
|
160
|
+
if isinstance(result, dict):
|
|
161
|
+
for name, value in result.items():
|
|
162
|
+
declared = output_units.get(name)
|
|
163
|
+
if declared is not None and isinstance(value, xr.DataArray):
|
|
164
|
+
result[name] = apply(value, declared, name)
|
|
165
|
+
elif dataclasses.is_dataclass(result):
|
|
166
|
+
for name, declared in output_units.items():
|
|
167
|
+
value = getattr(result, name, None)
|
|
168
|
+
if isinstance(value, xr.DataArray):
|
|
169
|
+
applied = apply(value, declared, name)
|
|
170
|
+
if applied is not value:
|
|
171
|
+
_set_field(result, name, applied, qualname)
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
@functools.wraps(fn)
|
|
175
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
176
|
+
pol = get_policy()
|
|
177
|
+
if not pol.enabled:
|
|
178
|
+
# Master switch off: a total no-op (no conversion, no stamping).
|
|
179
|
+
return fn(*args, **kwargs)
|
|
180
|
+
if input_units:
|
|
181
|
+
eff_missing = on_missing if on_missing is not None else pol.on_missing
|
|
182
|
+
eff_inexact = on_inexact if on_inexact is not None else pol.on_inexact
|
|
183
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
184
|
+
# bind_partial omits parameters left at their default; apply
|
|
185
|
+
# them so a declared default is converted too (the converted
|
|
186
|
+
# value is then passed explicitly via bound.args/bound.kwargs).
|
|
187
|
+
bound.apply_defaults()
|
|
188
|
+
for name, val in list(bound.arguments.items()):
|
|
189
|
+
declared = input_units.get(name)
|
|
190
|
+
if declared is not None and isinstance(val, xr.DataArray):
|
|
191
|
+
bound.arguments[name] = check_units(
|
|
192
|
+
val, declared, name, eff_missing, eff_inexact, qualname
|
|
193
|
+
)
|
|
194
|
+
args, kwargs = bound.args, bound.kwargs
|
|
195
|
+
eff_output = on_output if on_output is not None else pol.on_output
|
|
196
|
+
return _stamp(fn(*args, **kwargs), eff_output)
|
|
197
|
+
|
|
198
|
+
wrapper.__annotations__ = dict(fn.__annotations__)
|
|
199
|
+
return wrapper
|
|
200
|
+
|
|
201
|
+
# @declare_units → func is the decorated function;
|
|
202
|
+
# @declare_units(on_missing=...) → func is None, return the parametrised decorator.
|
|
203
|
+
return decorate if func is None else decorate(func)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
r"""Pluggable pint registry selection.
|
|
2
|
+
|
|
3
|
+
The active pint registry is **process-global**. By default nothing special
|
|
4
|
+
happens at import time: `get_registry` falls back to
|
|
5
|
+
`pint.get_application_registry()`, so plain pint unit strings work out of the
|
|
6
|
+
box. CF-convention strings such as `"umol m-2 s-1"` or `"g m-2 d-1"` require
|
|
7
|
+
cf-xarray's UDUNITS-aware registry, activated once via `use_cf_units`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
|
|
12
|
+
import pint
|
|
13
|
+
import pint_xarray
|
|
14
|
+
|
|
15
|
+
_UREG: pint.UnitRegistry | pint.registry.ApplicationRegistry | None = None
|
|
16
|
+
_using_cf: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_registry() -> pint.UnitRegistry | pint.registry.ApplicationRegistry:
|
|
20
|
+
"""Return the active pint `UnitRegistry`.
|
|
21
|
+
|
|
22
|
+
Defaults to `pint.get_application_registry()` (plain pint) until
|
|
23
|
+
`set_registry` or `use_cf_units` is called.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
The process-global pint `UnitRegistry`.
|
|
27
|
+
"""
|
|
28
|
+
if _UREG is not None:
|
|
29
|
+
return _UREG
|
|
30
|
+
return pint.get_application_registry()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_registry(ureg: pint.UnitRegistry | pint.registry.ApplicationRegistry) -> None:
|
|
34
|
+
"""Set the process-wide pint registry used by this module and pint-xarray.
|
|
35
|
+
|
|
36
|
+
Also calls `pint_xarray.setup_registry(ureg)` so the `.pint` accessor and
|
|
37
|
+
this module's parse/compat helpers never drift apart, and
|
|
38
|
+
`pint.set_application_registry(ureg)` so pint's global application registry
|
|
39
|
+
(which pint-xarray follows for `.pint.quantify()`) stays in sync with this
|
|
40
|
+
module's choice. Without the latter, switching back to plain pint after
|
|
41
|
+
`use_cf_units` would leave the application registry pointing at the CF one.
|
|
42
|
+
|
|
43
|
+
pint has a single process-global application registry, so this is a
|
|
44
|
+
one-time, startup choice, not a per-array setting: quantities created under
|
|
45
|
+
two different registries cannot be mixed (pint raises).
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
ureg: The `UnitRegistry` to install as the process-global registry.
|
|
49
|
+
"""
|
|
50
|
+
global _UREG, _using_cf
|
|
51
|
+
_UREG = ureg
|
|
52
|
+
_using_cf = False
|
|
53
|
+
pint_xarray.setup_registry(ureg)
|
|
54
|
+
pint.set_application_registry(ureg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def use_cf_units() -> None:
|
|
58
|
+
"""Activate cf-xarray's UDUNITS-aware registry.
|
|
59
|
+
|
|
60
|
+
Lazily imports `cf_xarray.units` (from the `[cf]` extra) and installs its
|
|
61
|
+
registry via `set_registry`, so CF-convention unit strings such as
|
|
62
|
+
`"umol m-2 s-1"` and `"g m-2 d-1"` parse.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ImportError: If `cf-xarray` is not installed.
|
|
66
|
+
"""
|
|
67
|
+
global _using_cf
|
|
68
|
+
try:
|
|
69
|
+
import cf_xarray.units
|
|
70
|
+
except ImportError as exc:
|
|
71
|
+
raise ImportError(
|
|
72
|
+
"use_cf_units() requires cf-xarray. Install it with "
|
|
73
|
+
"`pip install xarray-annotated[cf]`."
|
|
74
|
+
) from exc
|
|
75
|
+
set_registry(cf_xarray.units.units)
|
|
76
|
+
_using_cf = True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cf_hint(unit: str) -> str:
|
|
80
|
+
"""Suggest `use_cf_units` when a unit likely needs the CF registry.
|
|
81
|
+
|
|
82
|
+
Returns a parenthetical hint only when cf-xarray is installed but not yet
|
|
83
|
+
active, i.e. exactly the case where switching registries would fix a parse
|
|
84
|
+
failure. Empty string otherwise.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
unit: The unit string that failed to parse.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
A hint string, or `""` when the hint is not applicable.
|
|
91
|
+
"""
|
|
92
|
+
if _using_cf or importlib.util.find_spec("cf_xarray") is None:
|
|
93
|
+
return ""
|
|
94
|
+
return f" (call use_cf_units() to enable CF/UDUNITS units like {unit!r})"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xarray-annotated
|
|
3
|
+
Version: 0.5.2
|
|
4
|
+
Summary: Run-time validation of xarray DataArray properties (units, dims/coords/dtype) declared via typing.Annotated
|
|
5
|
+
Keywords: xarray,pint,units,validation,annotations
|
|
6
|
+
Author: jmarshrossney
|
|
7
|
+
Author-email: jmarshrossney <17361029+jmarshrossney@users.noreply.github.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Requires-Dist: pandas>=2.2
|
|
17
|
+
Requires-Dist: pint>=0.25.3
|
|
18
|
+
Requires-Dist: pint-xarray>=0.6.1
|
|
19
|
+
Requires-Dist: xarray>=2026.2.0
|
|
20
|
+
Requires-Dist: cf-xarray>=0.11.3 ; extra == 'cf'
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Project-URL: Homepage, https://jmarshrossney.github.io/xarray-annotated
|
|
23
|
+
Project-URL: Repository, https://github.com/jmarshrossney/xarray-annotated
|
|
24
|
+
Project-URL: Documentation, https://jmarshrossney.github.io/xarray-annotated
|
|
25
|
+
Provides-Extra: cf
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# xarray-annotated
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/xarray-annotated/)
|
|
31
|
+
[](https://pypi.org/project/xarray-annotated/)
|
|
32
|
+
[](https://github.com/jmarshrossney/xarray-annotated/actions/workflows/ci.yml)
|
|
33
|
+
[](https://jmarshrossney.github.io/xarray-annotated)
|
|
34
|
+
[](https://github.com/jmarshrossney/xarray-annotated/blob/main/LICENSE)
|
|
35
|
+
|
|
36
|
+
`xarray-annotated` enables run-time validation of `xarray.DataArray` properties declared in function signatures via [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated).
|
|
37
|
+
|
|
38
|
+
The main idea is that annotations serve as a single source of truth for
|
|
39
|
+
|
|
40
|
+
- structural schema (dims, coords, dtype),
|
|
41
|
+
- physical units,
|
|
42
|
+
- frequency (and phase) of a time axis,
|
|
43
|
+
|
|
44
|
+
from which
|
|
45
|
+
|
|
46
|
+
- static checkers and run-time validation can derive expected properties,
|
|
47
|
+
- documentation can be generated automatically,
|
|
48
|
+
- automated tools (coding agents) receive steering.
|
|
49
|
+
|
|
50
|
+
For validating and automatically converting physical units we lean on the excellent [pint](https://pint.readthedocs.io/en/stable/) and [pint-xarray](https://pint-xarray.readthedocs.io).
|
|
51
|
+
([cf-xarray](https://cf-xarray.readthedocs.io) is an optional dependency for CF/UDUNITS unit strings.)
|
|
52
|
+
|
|
53
|
+
For full user documentation please visit **[https://jmarshrossney.github.io/xarray-annotated/](https://jmarshrossney.github.io/xarray-annotated/)**.
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
Either using `uv` (recommended) or `pip`:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
uv add xarray-annotated
|
|
61
|
+
# or
|
|
62
|
+
pip install xarray-annotated
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
CF-convention / UDUNITS unit strings (e.g. `"umol m-2 s-1"`) need the optional `cf` extra, which pulls in [cf-xarray](https://cf-xarray.readthedocs.io).
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
uv add "xarray-annotated[cf]"
|
|
69
|
+
# or
|
|
70
|
+
pip install "xarray-annotated[cf]"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## A short example
|
|
74
|
+
|
|
75
|
+
Here is a function that converts barometric pressure to altitude.
|
|
76
|
+
It assumes a unit (pascals) and says so in the docstring but doesn't check or enforce it.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import xarray as xr
|
|
80
|
+
|
|
81
|
+
def altitude(p: xr.DataArray) -> xr.DataArray:
|
|
82
|
+
"""Barometric altitude. Assumes p is in Pa."""
|
|
83
|
+
return 44330.0 * (1.0 - (p / 101325.0) ** 0.1903)
|
|
84
|
+
|
|
85
|
+
p = xr.DataArray([1013.0, 1000.0], dims=["time"], attrs={"units": "hPa"})
|
|
86
|
+
altitude(p)
|
|
87
|
+
```
|
|
88
|
+
```
|
|
89
|
+
<xarray.DataArray (time: 2)> Size: 16B
|
|
90
|
+
array([25876.55998663, 25921.86206292])
|
|
91
|
+
Dimensions without coordinates: time
|
|
92
|
+
Attributes:
|
|
93
|
+
units: hPa
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The input units are wrong (hPa vs Pa) but the onus is on the user to check, and in reality we all know that such bugs can go unnoticed for a terrifyingly long time.
|
|
97
|
+
The returned altitude is numerically wrong and carries a completely bogus hPa units in `attrs`.
|
|
98
|
+
|
|
99
|
+
Here we use `xarray-annotated` to declare expected properties in the signature and enforce them at run-time with minimal boilerplate (just decorators).
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from typing import Annotated
|
|
103
|
+
import xarray as xr
|
|
104
|
+
from xarray_annotated.schema import declare_schema, Dims
|
|
105
|
+
from xarray_annotated.units import declare_units, Unit
|
|
106
|
+
|
|
107
|
+
@declare_units
|
|
108
|
+
@declare_schema
|
|
109
|
+
def altitude(
|
|
110
|
+
p: Annotated[xr.DataArray, Dims("time"), Unit("Pa")],
|
|
111
|
+
) -> Annotated[xr.DataArray, Dims("time"), Unit("m")]:
|
|
112
|
+
"""Barometric altitude."""
|
|
113
|
+
return 44330.0 * (1.0 - (p / 101325.0) ** 0.1903)
|
|
114
|
+
|
|
115
|
+
p = xr.DataArray([1013.0, 1000.0], dims=["time"], attrs={"units": "hPa"})
|
|
116
|
+
altitude(p)
|
|
117
|
+
```
|
|
118
|
+
```
|
|
119
|
+
<xarray.DataArray (time: 2)> Size: 16B
|
|
120
|
+
array([ 2.08162886, 110.90398059])
|
|
121
|
+
Dimensions without coordinates: time
|
|
122
|
+
Attributes:
|
|
123
|
+
units: m
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Now the outputs are physical and carry the correct units attribute.
|
|
127
|
+
The `hPa` input was converted to `Pa` before the body ran, and the result was stamped with the unit the signature promised.
|
|
128
|
+
The docstring no longer needs to say "assumes Pa", because the signature does --- and unlike the docstring, it is actually enforced.
|
|
129
|
+
|
|
130
|
+
If we had handed the function an array whose units were _dimensionally_ wrong, or with an extra dimension, this would have failed gracefully:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
altitude(xr.DataArray([1.0, 2.0], dims=["time"], attrs={"units": "kg"}))
|
|
134
|
+
# DimensionalityError: Cannot convert from 'kilogram' ([mass])
|
|
135
|
+
# to 'pascal' ([mass] / [length] / [time] ** 2)
|
|
136
|
+
|
|
137
|
+
altitude(xr.DataArray([[1013.0, 1000.0]], dims=["run", "time"], attrs={"units": "hPa"}))
|
|
138
|
+
# SchemaError: [altitude] 'p' dims mismatch: expected ('time',) in any order, got ('run', 'time')
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Contributing
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
uv sync # install the package + dev dependencies into .venv
|
|
145
|
+
just # lint, typecheck, test, build docs
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
See the `justfile` for individual targets (`just lint`, `just test`, `just test-cov`, ...).
|
|
149
|
+
|
|
150
|
+
A `.pre-commit-config.yaml` is included to run the same linting (ruff) and type-checking (pyright) steps on every commit. Install the hooks with:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
uv run pre-commit install
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Contributions are welcome --- open an issue or pull request on GitHub.
|