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,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
+ [![PyPI version](https://img.shields.io/pypi/v/xarray-annotated)](https://pypi.org/project/xarray-annotated/)
31
+ [![Python versions](https://img.shields.io/pypi/pyversions/xarray-annotated)](https://pypi.org/project/xarray-annotated/)
32
+ [![CI](https://github.com/jmarshrossney/xarray-annotated/actions/workflows/ci.yml/badge.svg)](https://github.com/jmarshrossney/xarray-annotated/actions/workflows/ci.yml)
33
+ [![Docs](https://github.com/jmarshrossney/xarray-annotated/actions/workflows/docs.yml/badge.svg)](https://jmarshrossney.github.io/xarray-annotated)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.