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.
@@ -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)