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,182 @@
|
|
|
1
|
+
"""Process-wide validation policy for the temporal (frequency) domain.
|
|
2
|
+
|
|
3
|
+
Like the schema policy, temporal validation never converts — it only asserts — but
|
|
4
|
+
it has a *second* behavioural axis, because a frequency declaration can fail in two
|
|
5
|
+
genuinely different ways:
|
|
6
|
+
|
|
7
|
+
- `on_mismatch` — the array's time axis has a frequency, and it is not the declared
|
|
8
|
+
one (the declaration is **violated**).
|
|
9
|
+
- `on_uninferable` — no frequency could be determined at all: fewer than three
|
|
10
|
+
timestamps, or irregular spacing (the declaration could not be **tested**). This
|
|
11
|
+
defaults to `"warn"` rather than `"error"`: a short axis is legitimate (a
|
|
12
|
+
two-timestep test fixture), but silently skipping a contract check deserves noise.
|
|
13
|
+
|
|
14
|
+
Plus `enabled` — the **package-wide** master switch (shared with every domain via
|
|
15
|
+
`xarray_annotated._config`); `False` makes all validation a no-op.
|
|
16
|
+
|
|
17
|
+
Each axis resolves independently: environment variable, then a process override set
|
|
18
|
+
via `set_policy` / the `policy` context manager, then the module default. The shared
|
|
19
|
+
`_resolve` helper encodes that precedence.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
from .._config import (
|
|
27
|
+
DEFAULT_ENABLED,
|
|
28
|
+
ENABLED_ENV_VAR,
|
|
29
|
+
_resolve,
|
|
30
|
+
_Unset,
|
|
31
|
+
_UNSET,
|
|
32
|
+
get_enabled_override,
|
|
33
|
+
resolve_enabled,
|
|
34
|
+
set_enabled_override,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# `ENABLED_ENV_VAR` is re-exported (the master switch is shared but callers still
|
|
38
|
+
# reach it as `temporal._config.ENABLED_ENV_VAR`).
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ENABLED_ENV_VAR",
|
|
41
|
+
"OnMismatch",
|
|
42
|
+
"OnUninferable",
|
|
43
|
+
"Policy",
|
|
44
|
+
"get_policy",
|
|
45
|
+
"policy",
|
|
46
|
+
"set_policy",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
OnMismatch = Literal["error", "warn", "ignore"]
|
|
50
|
+
OnUninferable = Literal["error", "warn", "ignore"]
|
|
51
|
+
|
|
52
|
+
_VALID_SEVERITIES: frozenset[str] = frozenset({"error", "warn", "ignore"})
|
|
53
|
+
|
|
54
|
+
#: A frequency mismatch is a genuine bug (a weekly series wired into a daily
|
|
55
|
+
#: consumer, or a resample landing on the wrong weekday), so the default is to raise.
|
|
56
|
+
DEFAULT_ON_MISMATCH: OnMismatch = "error"
|
|
57
|
+
|
|
58
|
+
#: An uninferable axis means the declaration was never *tested*, not that it was
|
|
59
|
+
#: violated — worth noise, but not fatal.
|
|
60
|
+
DEFAULT_ON_UNINFERABLE: OnUninferable = "warn"
|
|
61
|
+
|
|
62
|
+
ON_MISMATCH_ENV_VAR = "XARRAY_ANNOTATED_TEMPORAL_ON_MISMATCH"
|
|
63
|
+
ON_UNINFERABLE_ENV_VAR = "XARRAY_ANNOTATED_TEMPORAL_ON_UNINFERABLE"
|
|
64
|
+
|
|
65
|
+
_process_on_mismatch: OnMismatch | None = None
|
|
66
|
+
_process_on_uninferable: OnUninferable | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class Policy:
|
|
71
|
+
"""The resolved temporal-validation policy — the three axes as a single value.
|
|
72
|
+
|
|
73
|
+
Returned by `get_policy`. Build overrides via `set_policy` or the `policy`
|
|
74
|
+
context manager rather than constructing this directly.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
enabled: Master switch; `False` makes all validation a no-op.
|
|
78
|
+
on_mismatch: Default behaviour when the inferred frequency contradicts the
|
|
79
|
+
declaration (`"error"`, `"warn"`, or `"ignore"`); a marker may override it.
|
|
80
|
+
on_uninferable: Behaviour when no frequency can be inferred from the time
|
|
81
|
+
axis — too few points, or irregular spacing.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
enabled: bool = DEFAULT_ENABLED
|
|
85
|
+
on_mismatch: OnMismatch = DEFAULT_ON_MISMATCH
|
|
86
|
+
on_uninferable: OnUninferable = DEFAULT_ON_UNINFERABLE
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validate_severity(value: str, axis: str) -> str:
|
|
90
|
+
if value not in _VALID_SEVERITIES:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Invalid {axis} {value!r}. Choose one of {sorted(_VALID_SEVERITIES)}."
|
|
93
|
+
)
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_on_mismatch(value: str) -> OnMismatch:
|
|
98
|
+
return _validate_severity(value, "on_mismatch") # type: ignore[return-value]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate_on_uninferable(value: str) -> OnUninferable:
|
|
102
|
+
return _validate_severity(value, "on_uninferable") # type: ignore[return-value]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_policy() -> Policy:
|
|
106
|
+
"""Resolve the active temporal policy (env → process → default, per axis).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The resolved `Policy`.
|
|
110
|
+
"""
|
|
111
|
+
return Policy(
|
|
112
|
+
enabled=resolve_enabled(),
|
|
113
|
+
on_mismatch=_resolve(
|
|
114
|
+
ON_MISMATCH_ENV_VAR,
|
|
115
|
+
_process_on_mismatch,
|
|
116
|
+
DEFAULT_ON_MISMATCH,
|
|
117
|
+
lambda v: _validate_on_mismatch(v.lower()),
|
|
118
|
+
),
|
|
119
|
+
on_uninferable=_resolve(
|
|
120
|
+
ON_UNINFERABLE_ENV_VAR,
|
|
121
|
+
_process_on_uninferable,
|
|
122
|
+
DEFAULT_ON_UNINFERABLE,
|
|
123
|
+
lambda v: _validate_on_uninferable(v.lower()),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def set_policy(
|
|
129
|
+
*,
|
|
130
|
+
enabled: bool | None | _Unset = _UNSET,
|
|
131
|
+
on_mismatch: OnMismatch | None | _Unset = _UNSET,
|
|
132
|
+
on_uninferable: OnUninferable | None | _Unset = _UNSET,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Set process-wide temporal-policy overrides for one or more axes.
|
|
135
|
+
|
|
136
|
+
Only the axes you pass are changed. Pass a value to set it, `None` to *clear*
|
|
137
|
+
that axis's override (so its env var / default applies again), or omit it to
|
|
138
|
+
leave it untouched.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
enabled: Override the (package-wide) master switch, or `None` to clear.
|
|
142
|
+
on_mismatch: Override the on-mismatch axis, or `None` to clear.
|
|
143
|
+
on_uninferable: Override the on-uninferable axis, or `None` to clear.
|
|
144
|
+
"""
|
|
145
|
+
global _process_on_mismatch, _process_on_uninferable
|
|
146
|
+
if not isinstance(enabled, _Unset):
|
|
147
|
+
set_enabled_override(enabled)
|
|
148
|
+
if not isinstance(on_mismatch, _Unset):
|
|
149
|
+
_process_on_mismatch = (
|
|
150
|
+
None if on_mismatch is None else _validate_on_mismatch(on_mismatch)
|
|
151
|
+
)
|
|
152
|
+
if not isinstance(on_uninferable, _Unset):
|
|
153
|
+
_process_on_uninferable = (
|
|
154
|
+
None if on_uninferable is None else _validate_on_uninferable(on_uninferable)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@contextmanager
|
|
159
|
+
def policy(
|
|
160
|
+
*,
|
|
161
|
+
enabled: bool | None | _Unset = _UNSET,
|
|
162
|
+
on_mismatch: OnMismatch | None | _Unset = _UNSET,
|
|
163
|
+
on_uninferable: OnUninferable | None | _Unset = _UNSET,
|
|
164
|
+
):
|
|
165
|
+
"""Temporarily override temporal-policy axes, restoring them on exit.
|
|
166
|
+
|
|
167
|
+
Sets every axis you pass in one go, and restores the previous process overrides
|
|
168
|
+
afterwards even if an exception is raised.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
enabled: Override the (package-wide) master switch, or `None` to clear.
|
|
172
|
+
on_mismatch: Override the on-mismatch axis, or `None` to clear.
|
|
173
|
+
on_uninferable: Override the on-uninferable axis, or `None` to clear.
|
|
174
|
+
"""
|
|
175
|
+
global _process_on_mismatch, _process_on_uninferable
|
|
176
|
+
saved = (get_enabled_override(), _process_on_mismatch, _process_on_uninferable)
|
|
177
|
+
set_policy(enabled=enabled, on_mismatch=on_mismatch, on_uninferable=on_uninferable)
|
|
178
|
+
try:
|
|
179
|
+
yield
|
|
180
|
+
finally:
|
|
181
|
+
enabled_saved, _process_on_mismatch, _process_on_uninferable = saved
|
|
182
|
+
set_enabled_override(enabled_saved)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Signature-driven frequency-declaration decorator.
|
|
2
|
+
|
|
3
|
+
`declare_freq` wires the declaration side (`_annotations`) to the checking side
|
|
4
|
+
(`_check`): it reads a function's `Annotated[DataArray, Freq(...)]` hints once, at
|
|
5
|
+
decoration time, then on every call validates the declared inputs and outputs.
|
|
6
|
+
|
|
7
|
+
Like `schema.declare_schema` — whose scaffold this mirrors — it is the *convenience*
|
|
8
|
+
layer over the public primitives (`freq_from_signature`, `check_freq`,
|
|
9
|
+
`assert_valid_freq`), and it never mutates: inputs and outputs are *checked* and
|
|
10
|
+
passed through unchanged.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
import functools
|
|
15
|
+
import inspect
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import xarray as xr
|
|
20
|
+
|
|
21
|
+
from ._annotations import Freq, freq_from_signature
|
|
22
|
+
from ._check import assert_valid_freq, check_freq
|
|
23
|
+
from ._config import (
|
|
24
|
+
OnMismatch,
|
|
25
|
+
OnUninferable,
|
|
26
|
+
_validate_on_mismatch,
|
|
27
|
+
_validate_on_uninferable,
|
|
28
|
+
get_policy,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def declare_freq(
|
|
33
|
+
func: Callable[..., Any] | None = None,
|
|
34
|
+
*,
|
|
35
|
+
on_mismatch: OnMismatch | None = None,
|
|
36
|
+
on_uninferable: OnUninferable | None = None,
|
|
37
|
+
) -> Callable[..., Any]:
|
|
38
|
+
"""Apply a function's signature-declared frequencies at runtime.
|
|
39
|
+
|
|
40
|
+
Reads the decorated function's own type annotations once, via
|
|
41
|
+
`freq_from_signature`: parameters annotated `Annotated[DataArray, Freq(...)]`
|
|
42
|
+
declare an input's temporal frequency, and a `TypedDict`/dataclass return (or a
|
|
43
|
+
bare `Annotated[DataArray, Freq(...)]` return) declares an output's.
|
|
44
|
+
|
|
45
|
+
On each call, under the active `Policy` (`get_policy`), the wrapper validates every
|
|
46
|
+
declared `DataArray` input via `check_freq`, runs the wrapped function, then
|
|
47
|
+
validates every declared output the same way. Nothing is mutated; a mismatch
|
|
48
|
+
raises/warns/ignores per the effective `on_mismatch` (a marker's own override wins
|
|
49
|
+
over this decorator's, which wins over the policy default), and an axis whose
|
|
50
|
+
frequency cannot be inferred at all is reported under `on_uninferable`. When the
|
|
51
|
+
policy is **disabled** the wrapper is a total no-op.
|
|
52
|
+
|
|
53
|
+
Usable bare (`@declare_freq`) or called (`@declare_freq(on_mismatch=...)`). Every
|
|
54
|
+
declaration is validated **at decoration time** (`assert_valid_freq`), so a
|
|
55
|
+
malformed offset string fails fast at import.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
func: The function to decorate (when used bare); `None` when parametrised.
|
|
59
|
+
on_mismatch: Default mismatch severity for this function, overriding the policy
|
|
60
|
+
default. `None` (default) resolves per call from `get_policy`.
|
|
61
|
+
on_uninferable: Severity for an uninferable time axis, overriding the policy
|
|
62
|
+
default. `None` (default) resolves per call from `get_policy`.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A wrapped function that validates declared inputs and outputs.
|
|
66
|
+
"""
|
|
67
|
+
if on_mismatch is not None:
|
|
68
|
+
_validate_on_mismatch(on_mismatch)
|
|
69
|
+
if on_uninferable is not None:
|
|
70
|
+
_validate_on_uninferable(on_uninferable)
|
|
71
|
+
|
|
72
|
+
def decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
|
|
73
|
+
input_freqs, output_freqs = freq_from_signature(fn)
|
|
74
|
+
sig = inspect.signature(fn)
|
|
75
|
+
qualname = getattr(fn, "__qualname__", repr(fn))
|
|
76
|
+
|
|
77
|
+
# Fail fast on malformed declarations (independent of policy).
|
|
78
|
+
for pname, marker in input_freqs.items():
|
|
79
|
+
assert_valid_freq(marker, f"{qualname} input {pname!r}")
|
|
80
|
+
_assert_output_declarations(output_freqs, qualname)
|
|
81
|
+
|
|
82
|
+
def _check_output(result: Any, eff: OnMismatch, unin: OnUninferable) -> Any:
|
|
83
|
+
if isinstance(output_freqs, Freq):
|
|
84
|
+
if isinstance(result, xr.DataArray):
|
|
85
|
+
check_freq(result, output_freqs, "return", eff, unin, qualname)
|
|
86
|
+
elif isinstance(output_freqs, dict):
|
|
87
|
+
for key, marker in output_freqs.items():
|
|
88
|
+
if isinstance(result, dict):
|
|
89
|
+
value = result.get(key)
|
|
90
|
+
elif dataclasses.is_dataclass(result):
|
|
91
|
+
value = getattr(result, key, None)
|
|
92
|
+
else:
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(value, xr.DataArray):
|
|
95
|
+
check_freq(value, marker, key, eff, unin, qualname)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
@functools.wraps(fn)
|
|
99
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
100
|
+
pol = get_policy()
|
|
101
|
+
if not pol.enabled:
|
|
102
|
+
# Master switch off: a total no-op (no validation at all).
|
|
103
|
+
return fn(*args, **kwargs)
|
|
104
|
+
# Per-call defaults (a marker's own override still wins in check_freq).
|
|
105
|
+
eff = on_mismatch if on_mismatch is not None else pol.on_mismatch
|
|
106
|
+
unin = on_uninferable if on_uninferable is not None else pol.on_uninferable
|
|
107
|
+
if input_freqs:
|
|
108
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
109
|
+
# bind_partial omits parameters left at their default; apply
|
|
110
|
+
# them so a declared default is validated too.
|
|
111
|
+
bound.apply_defaults()
|
|
112
|
+
for pname, val in list(bound.arguments.items()):
|
|
113
|
+
marker = input_freqs.get(pname)
|
|
114
|
+
if marker is not None and isinstance(val, xr.DataArray):
|
|
115
|
+
check_freq(val, marker, pname, eff, unin, qualname)
|
|
116
|
+
return _check_output(fn(*args, **kwargs), eff, unin)
|
|
117
|
+
|
|
118
|
+
wrapper.__annotations__ = dict(fn.__annotations__)
|
|
119
|
+
return wrapper
|
|
120
|
+
|
|
121
|
+
return decorate if func is None else decorate(func)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _assert_output_declarations(
|
|
125
|
+
output_freqs: dict[str, Freq] | Freq | None,
|
|
126
|
+
qualname: str,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Fail-fast-validate declared output markers at decoration time."""
|
|
129
|
+
if isinstance(output_freqs, Freq):
|
|
130
|
+
assert_valid_freq(output_freqs, f"{qualname} output")
|
|
131
|
+
elif isinstance(output_freqs, dict):
|
|
132
|
+
for key, marker in output_freqs.items():
|
|
133
|
+
assert_valid_freq(marker, f"{qualname} output {key!r}")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Public API for the units domain of xarray-annotated.
|
|
2
|
+
|
|
3
|
+
Run-time validation of pint/CF units declared via ``typing.Annotated`` on xarray
|
|
4
|
+
DataArrays.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._annotations import Unit, annotated_unit, units_from_signature
|
|
8
|
+
from ._check import (
|
|
9
|
+
UnitsWarning,
|
|
10
|
+
apply_output_units,
|
|
11
|
+
assert_valid_unit,
|
|
12
|
+
check_units,
|
|
13
|
+
units_compatible,
|
|
14
|
+
units_equal,
|
|
15
|
+
)
|
|
16
|
+
from ._config import (
|
|
17
|
+
OnInexact,
|
|
18
|
+
OnMissing,
|
|
19
|
+
OnOutput,
|
|
20
|
+
Policy,
|
|
21
|
+
get_policy,
|
|
22
|
+
policy,
|
|
23
|
+
set_policy,
|
|
24
|
+
)
|
|
25
|
+
from ._decorator import declare_units
|
|
26
|
+
from ._registry import (
|
|
27
|
+
get_registry,
|
|
28
|
+
set_registry,
|
|
29
|
+
use_cf_units,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"OnInexact",
|
|
34
|
+
"OnMissing",
|
|
35
|
+
"OnOutput",
|
|
36
|
+
"Policy",
|
|
37
|
+
"Unit",
|
|
38
|
+
"UnitsWarning",
|
|
39
|
+
"annotated_unit",
|
|
40
|
+
"apply_output_units",
|
|
41
|
+
"assert_valid_unit",
|
|
42
|
+
"check_units",
|
|
43
|
+
"declare_units",
|
|
44
|
+
"get_policy",
|
|
45
|
+
"get_registry",
|
|
46
|
+
"policy",
|
|
47
|
+
"set_policy",
|
|
48
|
+
"set_registry",
|
|
49
|
+
"units_compatible",
|
|
50
|
+
"units_equal",
|
|
51
|
+
"units_from_signature",
|
|
52
|
+
"use_cf_units",
|
|
53
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Reading declared units off `typing.Annotated` type hints.
|
|
2
|
+
|
|
3
|
+
Pure `typing` introspection with no dependency on the active registry or
|
|
4
|
+
configuration: this is the "declaration side" of the package, turning
|
|
5
|
+
`Annotated[DataArray, Unit("degC")]` metadata into plain unit strings that the
|
|
6
|
+
checking layer later validates.
|
|
7
|
+
|
|
8
|
+
The canonical declaration is the self-identifying `Unit` marker. A bare unit
|
|
9
|
+
string is accepted as a shorthand under a **unit-first** convention: the unit
|
|
10
|
+
must precede any descriptive metadata, e.g.
|
|
11
|
+
`Annotated[DataArray, "m s-1", "description"]`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import (
|
|
15
|
+
Annotated,
|
|
16
|
+
Any,
|
|
17
|
+
get_args,
|
|
18
|
+
get_origin,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from .._annotations import (
|
|
22
|
+
_is_dataarray_type,
|
|
23
|
+
unwrap_annotated,
|
|
24
|
+
walk_signature,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Re-exported so `units._annotations.unwrap_annotated` stays a stable import.
|
|
28
|
+
__all__ = ["Unit", "annotated_unit", "units_from_signature", "unwrap_annotated"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Unit:
|
|
32
|
+
"""Typed marker declaring the expected unit of a DataArray.
|
|
33
|
+
|
|
34
|
+
This is the **recommended** way to declare a unit. Inside ``Annotated`` it is
|
|
35
|
+
self-identifying and independent of metadata ordering, so it composes cleanly
|
|
36
|
+
with schema markers or other ``Annotated``-based tooling::
|
|
37
|
+
|
|
38
|
+
Annotated[xr.DataArray, Unit("degC"), SomeOtherMarker(...)]
|
|
39
|
+
|
|
40
|
+
A bare string ``Annotated[xr.DataArray, "degC"]`` is also accepted as a
|
|
41
|
+
shorthand (the first string in metadata, by convention) and resolves to the
|
|
42
|
+
same unit. Prefer the marker whenever the annotation is shared: it removes
|
|
43
|
+
both the order dependence and the ambiguity a bare string has against a
|
|
44
|
+
description string.
|
|
45
|
+
|
|
46
|
+
The unit string is *not* validated here — parsing is deferred to
|
|
47
|
+
`assert_valid_unit` at decoration time, exactly as for the bare-string form.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
__slots__ = ("_unit",)
|
|
51
|
+
|
|
52
|
+
def __init__(self, unit: str) -> None:
|
|
53
|
+
self._unit = unit
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def unit(self) -> str:
|
|
57
|
+
"""The declared unit string."""
|
|
58
|
+
return self._unit
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
return f"Unit({self._unit!r})"
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
# The bare unit string, for readable interpolation (e.g. f"expected {m}").
|
|
65
|
+
return self._unit
|
|
66
|
+
|
|
67
|
+
def __eq__(self, other: object) -> bool:
|
|
68
|
+
if isinstance(other, Unit):
|
|
69
|
+
return self._unit == other._unit
|
|
70
|
+
return NotImplemented
|
|
71
|
+
|
|
72
|
+
def __hash__(self) -> int:
|
|
73
|
+
# Defining __eq__ drops the default __hash__; restore it so that an
|
|
74
|
+
# Annotated[...] hint carrying a Unit marker stays hashable (typing and
|
|
75
|
+
# downstream tools cache/hash annotations).
|
|
76
|
+
return hash(self._unit)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def annotated_unit(hint: Any) -> str | None:
|
|
80
|
+
"""Return the declared unit carried by an `Annotated` type hint, or `None`.
|
|
81
|
+
|
|
82
|
+
The unit is the first `Unit(...)` marker in the `Annotated` metadata, and —
|
|
83
|
+
falling back — the first `str`. So both spellings resolve identically:
|
|
84
|
+
|
|
85
|
+
`Annotated[DataArray, Unit("degC")]` → `"degC"`
|
|
86
|
+
`Annotated[DataArray, "degC"]` → `"degC"`
|
|
87
|
+
|
|
88
|
+
The typed `Unit` marker is preferred because it is *self-identifying* and
|
|
89
|
+
order-independent: it owns its slot regardless of what other metadata
|
|
90
|
+
(descriptions, other typed markers) shares the annotation, so
|
|
91
|
+
|
|
92
|
+
`Annotated[DataArray, "note", Unit("Pa")]` → `"Pa"` (marker wins)
|
|
93
|
+
|
|
94
|
+
The bare-string form keeps the extensible *unit-first* convention: the unit
|
|
95
|
+
may be followed by free-form annotations that are ignored here, so
|
|
96
|
+
|
|
97
|
+
`Annotated[DataArray, "m s-1", "z component of velocity"]` → `"m s-1"`
|
|
98
|
+
|
|
99
|
+
A description placed before the unit (and with no `Unit` marker present)
|
|
100
|
+
would be mis-read as the unit — but `assert_valid_unit` rejects it unless the
|
|
101
|
+
description itself parses as a valid unit, so the failure is loud. Non-string
|
|
102
|
+
metadata (ints, unrelated markers) is skipped regardless of position.
|
|
103
|
+
|
|
104
|
+
The metadata is only interpreted as a unit when the annotated base type
|
|
105
|
+
is a `DataArray` (the only type that carries units); a `Unit` marker or
|
|
106
|
+
descriptive string on a non-`DataArray` parameter (e.g.
|
|
107
|
+
`Annotated[bool, "toggles X"]`) is *not* a unit and yields `None`.
|
|
108
|
+
Non-`Annotated` hints, or `Annotated` hints whose metadata holds neither a
|
|
109
|
+
`Unit` marker nor a string, also return `None`.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
hint: A type hint, typically from `get_type_hints(..., include_extras=True)`.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The declared unit (first `Unit` marker, else first `str`) if the base
|
|
116
|
+
type is a `DataArray`; `None` otherwise.
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
>>> from typing import Annotated
|
|
120
|
+
>>> import xarray as xr
|
|
121
|
+
>>> from xarray_annotated.units._annotations import Unit, annotated_unit
|
|
122
|
+
>>> annotated_unit(Annotated[xr.DataArray, Unit("degC")])
|
|
123
|
+
'degC'
|
|
124
|
+
>>> annotated_unit(Annotated[xr.DataArray, "degC"]) # shorthand
|
|
125
|
+
'degC'
|
|
126
|
+
>>> annotated_unit(Annotated[bool, "toggles X"]) is None
|
|
127
|
+
True
|
|
128
|
+
"""
|
|
129
|
+
if get_origin(hint) is not Annotated:
|
|
130
|
+
return None
|
|
131
|
+
# get_args(Annotated[T, m1, m2, ...]) == (T, m1, m2, ...); skip the base type.
|
|
132
|
+
args = get_args(hint)
|
|
133
|
+
if not _is_dataarray_type(args[0]):
|
|
134
|
+
return None
|
|
135
|
+
metadata = args[1:]
|
|
136
|
+
# The typed marker wins over a bare string, regardless of relative order.
|
|
137
|
+
for meta in metadata:
|
|
138
|
+
if isinstance(meta, Unit):
|
|
139
|
+
return meta.unit
|
|
140
|
+
for meta in metadata:
|
|
141
|
+
if isinstance(meta, str):
|
|
142
|
+
return meta
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def units_from_signature(
|
|
147
|
+
func: object,
|
|
148
|
+
) -> tuple[dict[str, str], dict[str, str] | str | None]:
|
|
149
|
+
"""Extract declared units from a function's type annotations.
|
|
150
|
+
|
|
151
|
+
Reads `get_type_hints(func, include_extras=True)` and interprets a
|
|
152
|
+
`Unit(...)` marker (or its bare-string shorthand) in the `Annotated`
|
|
153
|
+
metadata as a unit declaration.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
func: A callable whose type hints carry `Annotated[DataArray, Unit(...)]`
|
|
157
|
+
metadata (or the bare-string shorthand).
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
A `(input_units, output_units)` pair:
|
|
161
|
+
|
|
162
|
+
* `input_units` — `dict[str, str]` mapping parameter names to their
|
|
163
|
+
declared unit strings. Only parameters whose hint carries a unit
|
|
164
|
+
declaration contribute.
|
|
165
|
+
* `output_units` — a `dict[str, str]` (per-field units) if the
|
|
166
|
+
return hint is a `TypedDict` or dataclass; a bare `str` if the return
|
|
167
|
+
is a single `Annotated[DataArray, Unit(...)]`; `None` otherwise.
|
|
168
|
+
"""
|
|
169
|
+
return walk_signature(func, annotated_unit)
|