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,50 @@
1
+ """xarray-annotated: validate xarray DataArray properties declared via ``typing.Annotated``.
2
+
3
+ Annotation is the unifying technology: a declared property is read off an
4
+ ``Annotated[DataArray, ...]`` hint and validated at runtime. Each kind of
5
+ property is a domain subpackage, imported explicitly:
6
+
7
+ * ``xarray_annotated.units`` — physical units (pint / CF), the mature domain.
8
+ * ``xarray_annotated.schema`` — structural properties (dims, coords, dtype);
9
+ validate-only (never mutates).
10
+ * ``xarray_annotated.temporal`` — the frequency (and phase) of a time axis;
11
+ validate-only.
12
+
13
+ The top level is deliberately thin — nothing domain-specific is re-exported
14
+ here, so the domains never collide in a shared namespace::
15
+
16
+ from xarray_annotated import schema, temporal, units
17
+ from xarray_annotated.units import declare_units, check_units
18
+ from xarray_annotated.schema import declare_schema, Dims, Dtype
19
+ from xarray_annotated.temporal import declare_freq, Freq
20
+
21
+ The only names surfaced at the top level are domain-*agnostic* helpers that
22
+ belong to no single domain: the shared declaration *writer* ``annotate`` and its
23
+ inverse *reader* ``declarations_from_signature`` (which reads every facet declared
24
+ on a signature into one uniform ``Declared`` value), plus the ``Annotated``
25
+ introspection kernel (``unwrap_annotated``, ``walk_signature``).
26
+ ``walk_signature`` is the shared driver behind every domain's reader, so a
27
+ third-party facet author can use it to build their own ``*_from_signature``
28
+ reader. No domain-specific name is re-exported here.
29
+ """
30
+
31
+ from importlib.metadata import version as _version
32
+
33
+ from . import schema, temporal, units
34
+ from ._annotations import unwrap_annotated, walk_signature
35
+ from ._reader import Declared, declarations_from_signature
36
+ from ._writer import annotate
37
+
38
+ __version__ = _version("xarray-annotated")
39
+
40
+ __all__ = [
41
+ "Declared",
42
+ "__version__",
43
+ "annotate",
44
+ "declarations_from_signature",
45
+ "schema",
46
+ "temporal",
47
+ "units",
48
+ "unwrap_annotated",
49
+ "walk_signature",
50
+ ]
@@ -0,0 +1,119 @@
1
+ """Shared `typing.Annotated` introspection for xarray-annotated.
2
+
3
+ Pure `typing` machinery with no dependency on any domain's registry or config —
4
+ the "declaration side" common to every domain. A domain supplies an *extractor*
5
+ that turns one `Annotated[...]` hint into its own declaration payload (a unit
6
+ string, a list of schema markers, …); `walk_signature` drives that extractor over
7
+ a whole function signature, handling the `TypedDict` / dataclass / single-value
8
+ return shapes once, for everyone.
9
+ """
10
+
11
+ import dataclasses
12
+ import types
13
+ from collections.abc import Callable
14
+ from typing import (
15
+ Annotated,
16
+ Any,
17
+ TypeVar,
18
+ Union,
19
+ get_args,
20
+ get_origin,
21
+ get_type_hints,
22
+ is_typeddict,
23
+ )
24
+
25
+ import xarray as xr
26
+
27
+ _T = TypeVar("_T")
28
+
29
+
30
+ def unwrap_annotated(hint: Any) -> Any:
31
+ """Return the underlying type of an `Annotated` hint, else the hint itself.
32
+
33
+ `Annotated[DataArray, ...]` → `DataArray`; a non-`Annotated` hint is returned
34
+ unchanged. Lets type comparisons (e.g. `t is DataArray`) see through the
35
+ metadata that declarations attach to parameters.
36
+
37
+ Args:
38
+ hint: A type hint, possibly `Annotated`.
39
+
40
+ Returns:
41
+ The base type if `hint` is `Annotated`; otherwise `hint` unchanged.
42
+ """
43
+ return get_args(hint)[0] if get_origin(hint) is Annotated else hint
44
+
45
+
46
+ def _is_dataarray_type(tp: Any) -> bool:
47
+ """Return whether `tp` is `DataArray` (possibly wrapped in a Union).
48
+
49
+ Accepts a bare `DataArray` as well as `DataArray | None` /
50
+ `Optional[DataArray]` (and any other union that includes `DataArray`), so an
51
+ optional DataArray parameter can still carry a declaration. Anything else
52
+ (scalars, `str`, `bool`, `Dataset`, …) is not a declaration-bearing type.
53
+
54
+ Args:
55
+ tp: A type annotation to inspect.
56
+
57
+ Returns:
58
+ `True` if `tp` is or contains `DataArray`.
59
+ """
60
+ if tp is xr.DataArray:
61
+ return True
62
+ if get_origin(tp) in (Union, types.UnionType):
63
+ return any(_is_dataarray_type(arg) for arg in get_args(tp))
64
+ return False
65
+
66
+
67
+ def walk_signature(
68
+ func: object,
69
+ extract: Callable[[Any], _T | None],
70
+ ) -> tuple[dict[str, _T], dict[str, _T] | _T | None]:
71
+ """Extract per-parameter and return declarations from a function's hints.
72
+
73
+ Reads `get_type_hints(func, include_extras=True)` and maps each hint through
74
+ `extract`, which returns a domain's declaration payload for that hint or
75
+ `None` when the hint carries no declaration. The `is not None` filter is the
76
+ single "no declaration" rule shared by every domain.
77
+
78
+ Args:
79
+ func: A callable whose parameters/return may carry `Annotated` metadata.
80
+ extract: Turns one hint into a declaration payload, or `None`. For units
81
+ this is `annotated_unit` (payload `str`); for schema `annotated_schema`
82
+ (payload `list[marker]`).
83
+
84
+ Returns:
85
+ An `(inputs, output)` pair:
86
+
87
+ * `inputs` — `dict[str, T]` mapping each declared parameter to its payload.
88
+ * `output` — a `dict[str, T]` (per-field) if the return hint is a
89
+ `TypedDict` or dataclass; a single `T` if the return is one declared
90
+ `Annotated[DataArray, ...]`; `None` otherwise.
91
+ """
92
+ hints = get_type_hints(func, include_extras=True)
93
+ ret = hints.pop("return", None)
94
+
95
+ inputs = {
96
+ name: payload
97
+ for name, hint in hints.items()
98
+ if (payload := extract(hint)) is not None
99
+ }
100
+
101
+ output: dict[str, _T] | _T | None
102
+ if is_typeddict(ret):
103
+ ret_hints = get_type_hints(ret, include_extras=True)
104
+ output = {
105
+ name: payload
106
+ for name, hint in ret_hints.items()
107
+ if (payload := extract(hint)) is not None
108
+ }
109
+ elif dataclasses.is_dataclass(ret):
110
+ ret_hints = get_type_hints(ret, include_extras=True)
111
+ output = {
112
+ f.name: payload
113
+ for f in dataclasses.fields(ret)
114
+ if (payload := extract(ret_hints.get(f.name))) is not None
115
+ }
116
+ else:
117
+ output = extract(ret)
118
+
119
+ return inputs, output
@@ -0,0 +1,101 @@
1
+ """Shared configuration kernel for xarray-annotated.
2
+
3
+ Machinery common to every annotation-validation domain (currently `units` and
4
+ `schema`), so the domains never drift on how a policy axis resolves:
5
+
6
+ - `_resolve` — the shared precedence for one axis: environment variable (parsed),
7
+ then a process override, then a module default.
8
+ - the **`enabled`** master switch — package-wide (it gates *every* domain, not just
9
+ one), so it lives here rather than in any single domain. Each domain's `Policy`
10
+ carries an `enabled` field but resolves it through `resolve_enabled` / the
11
+ override accessors below, so toggling it in one domain toggles it everywhere.
12
+ - `_Unset` / `_UNSET` — the sentinel that lets a `set_policy` keyword distinguish
13
+ "argument omitted" from "explicitly set to `None`" (which *clears* an override).
14
+
15
+ Domain-specific axes (units' `on_missing` / `on_inexact`, schema's `on_mismatch`)
16
+ stay in the respective domain's `_config.py`; only the genuinely shared pieces
17
+ live here.
18
+ """
19
+
20
+ import os
21
+ from collections.abc import Callable
22
+ from typing import TypeVar
23
+
24
+ #: Master switch default. Validation is on unless explicitly disabled.
25
+ DEFAULT_ENABLED: bool = True
26
+
27
+ #: Master switch is package-wide (gates every annotation-validation domain), so it
28
+ #: is deliberately un-namespaced; each domain's behavioural knobs are namespaced.
29
+ ENABLED_ENV_VAR = "XARRAY_ANNOTATED_ENABLED"
30
+
31
+ #: String values (lower-cased) accepted for the boolean `enabled` env var.
32
+ _TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"})
33
+ _FALSEY: frozenset[str] = frozenset({"0", "false", "no", "off"})
34
+
35
+ #: Process-wide override for the master switch, shared across all domains.
36
+ _process_enabled: bool | None = None
37
+
38
+ _T = TypeVar("_T")
39
+
40
+
41
+ class _Unset:
42
+ """Sentinel distinguishing 'argument omitted' from 'explicitly set to None'."""
43
+
44
+ def __repr__(self) -> str:
45
+ return "<unset>"
46
+
47
+
48
+ _UNSET = _Unset()
49
+
50
+
51
+ def _resolve(
52
+ env_var: str,
53
+ process_value: _T | None,
54
+ default: _T,
55
+ parse: Callable[[str], _T],
56
+ ) -> _T:
57
+ """Resolve one axis: env var (parsed) → process override → default."""
58
+ env = os.environ.get(env_var)
59
+ if env:
60
+ return parse(env)
61
+ if process_value is not None:
62
+ return process_value
63
+ return default
64
+
65
+
66
+ def _parse_enabled_env(value: str) -> bool:
67
+ lowered = value.lower()
68
+ if lowered in _TRUTHY:
69
+ return True
70
+ if lowered in _FALSEY:
71
+ return False
72
+ raise ValueError(
73
+ f"Invalid {ENABLED_ENV_VAR} value {value!r}. "
74
+ f"Use one of {sorted(_TRUTHY | _FALSEY)}."
75
+ )
76
+
77
+
78
+ def resolve_enabled() -> bool:
79
+ """Resolve the package-wide master switch (env → process override → default)."""
80
+ return _resolve(
81
+ ENABLED_ENV_VAR, _process_enabled, DEFAULT_ENABLED, _parse_enabled_env
82
+ )
83
+
84
+
85
+ def get_enabled_override() -> bool | None:
86
+ """Return the current process override for the master switch (`None` if unset).
87
+
88
+ Used by each domain's `policy` context manager to save/restore the shared
89
+ switch around a temporary override.
90
+ """
91
+ return _process_enabled
92
+
93
+
94
+ def set_enabled_override(value: bool | None) -> None:
95
+ """Set (or, with `None`, clear) the process override for the master switch.
96
+
97
+ Shared by every domain's `set_policy`, so disabling validation in one domain
98
+ disables it package-wide.
99
+ """
100
+ global _process_enabled
101
+ _process_enabled = None if value is None else bool(value)
@@ -0,0 +1,110 @@
1
+ """Reading *all* declared facets off a signature — the unified reader.
2
+
3
+ Every domain ships its own reader (`units_from_signature`, `schema_from_signature`,
4
+ `freq_from_signature`) returning a domain-shaped payload; this is the one
5
+ cross-domain reader that collapses them into a single uniform value. Where the
6
+ writer `annotate` turns facet values into an `Annotated` hint,
7
+ `declarations_from_signature` turns a whole signature back into a facet value per
8
+ declared DataArray — it is the read-side counterpart to `annotate`, and their
9
+ round-trip is exact.
10
+
11
+ The unified value `Declared` is deliberately *homogeneous*: every slot holds a
12
+ marker (`Unit` / `Dims` / `Dtype` / `Coords` / `Freq`) or `None`. Markers are the
13
+ package's public currency — the schema and temporal markers are load-bearing (they
14
+ carry strictness that has no bare-string form) — so a facet-generic consumer wants
15
+ one marker per slot rather than a mix of strings and objects. A bare-string unit
16
+ shorthand is normalised to a `Unit`, so the shape does not depend on how the unit
17
+ was spelled.
18
+
19
+ Like `annotate`, this is cross-domain — it assembles every domain's markers into one
20
+ value — so it lives at the package root rather than in any one domain.
21
+ """
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ from ._annotations import walk_signature
27
+ from .schema import Coords, Dims, Dtype
28
+ from .schema._annotations import annotated_schema
29
+ from .temporal import Freq
30
+ from .temporal._annotations import annotated_freq
31
+ from .units import Unit
32
+ from .units._annotations import annotated_unit
33
+
34
+ __all__ = ["Declared", "declarations_from_signature"]
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Declared:
39
+ """Every facet declared on one DataArray hint, one marker (or `None`) per slot.
40
+
41
+ The uniform value produced by `declarations_from_signature`: a homogeneous
42
+ bag of markers, so facet-generic code can treat each slot identically. Each
43
+ slot holds the corresponding marker or `None` when that facet was not
44
+ declared; the unit is always a `Unit` (its bare-string shorthand is
45
+ normalised on read), so `.unit.unit` recovers the string.
46
+
47
+ The field order (`unit`, `dims`, `dtype`, `coords`, `freq`) matches `annotate`'s
48
+ keyword arguments, so `annotate(unit=d.unit, dims=d.dims, dtype=d.dtype,
49
+ coords=d.coords, freq=d.freq)` rebuilds the hint this was read from.
50
+ """
51
+
52
+ unit: Unit | None = None
53
+ dims: Dims | None = None
54
+ dtype: Dtype | None = None
55
+ coords: Coords | None = None
56
+ freq: Freq | None = None
57
+
58
+
59
+ def _annotated_declaration(hint: Any) -> Declared | None:
60
+ """Combine the per-domain extractors into one `Declared`, or `None`.
61
+
62
+ Delegates to the existing `annotated_unit`, `annotated_schema` and
63
+ `annotated_freq` so the "is this an `Annotated[DataArray, ...]` hint?" logic is
64
+ not duplicated, then folds their payloads into a single homogeneous value.
65
+ Returns `None` when the hint declares no facet, which is `walk_signature`'s "no
66
+ declaration" signal.
67
+ """
68
+ unit = annotated_unit(hint)
69
+ markers = annotated_schema(hint) or ()
70
+ dims = next((m for m in markers if isinstance(m, Dims)), None)
71
+ dtype = next((m for m in markers if isinstance(m, Dtype)), None)
72
+ coords = next((m for m in markers if isinstance(m, Coords)), None)
73
+ freq = annotated_freq(hint)
74
+ if all(facet is None for facet in (unit, dims, dtype, coords, freq)):
75
+ return None
76
+ return Declared(
77
+ unit=Unit(unit) if unit is not None else None,
78
+ dims=dims,
79
+ dtype=dtype,
80
+ coords=coords,
81
+ freq=freq,
82
+ )
83
+
84
+
85
+ def declarations_from_signature(
86
+ func: object,
87
+ ) -> tuple[dict[str, Declared], dict[str, Declared] | Declared | None]:
88
+ """Read every declared facet off a function's signature in one uniform shape.
89
+
90
+ The all-facets counterpart to `units_from_signature` / `schema_from_signature`:
91
+ a single reader whose payload is a `Declared` carrying the unit, dims, dtype,
92
+ and coords declared on a DataArray hint at once. A consumer that would
93
+ otherwise call both per-domain readers and merge their differently-shaped
94
+ results can call this once and route a uniform value instead.
95
+
96
+ Args:
97
+ func: A callable whose parameters/return may carry
98
+ `Annotated[DataArray, ...]` metadata.
99
+
100
+ Returns:
101
+ An `(inputs, output)` pair, mirroring `walk_signature` with a `Declared`
102
+ payload:
103
+
104
+ * `inputs` — `dict[str, Declared]` mapping each declared parameter to its
105
+ facets. Parameters carrying no declaration are omitted.
106
+ * `output` — a `dict[str, Declared]` (per-field) for a `TypedDict` or
107
+ dataclass return, a single `Declared` for one declared DataArray
108
+ return, or `None` when the return carries no declaration.
109
+ """
110
+ return walk_signature(func, _annotated_declaration)
@@ -0,0 +1,99 @@
1
+ """Writing declarations *onto* an annotation — the inverse of the readers.
2
+
3
+ Every domain ships a reader (`Annotated → declaration`: `units_from_signature`,
4
+ `schema_from_signature`, `freq_from_signature`); this is the one shared *writer*
5
+ (`declaration → Annotated`). A tool that builds functions dynamically (codegen,
6
+ graph nodes) needs to stamp a declared contract onto a generated signature;
7
+ `annotate` returns a real `Annotated` object it can assign to
8
+ `fn.__annotations__["return"]`, so the ordinary `declare_units` / `declare_schema` /
9
+ `declare_freq` decorators then read it straight back.
10
+
11
+ It is deliberately cross-domain — it assembles the units `Unit` marker, the schema
12
+ `Dims` / `Dtype` / `Coords` markers, and the temporal `Freq` marker — so it lives at
13
+ the package root rather than in any one domain, next to the domain-agnostic
14
+ annotation helpers.
15
+ """
16
+
17
+ from collections.abc import Iterable
18
+ from typing import Annotated, Any
19
+
20
+ import xarray as xr
21
+
22
+ from .schema import Coords, Dims, Dtype
23
+ from .temporal import Freq
24
+ from .units import Unit
25
+
26
+ __all__ = ["annotate"]
27
+
28
+
29
+ def annotate(
30
+ base: Any = xr.DataArray,
31
+ *,
32
+ unit: str | Unit | None = None,
33
+ dims: Iterable[str] | Dims | None = None,
34
+ dtype: str | Dtype | None = None,
35
+ coords: Iterable[str] | Coords | None = None,
36
+ freq: str | Freq | None = None,
37
+ ) -> Any:
38
+ """Build an `Annotated[base, <markers>]` hint from declared facet values.
39
+
40
+ The inverse of the `*_from_signature` readers: given facet values it returns a
41
+ real `Annotated` object carrying the corresponding markers, in a fixed order
42
+ (unit, dims, dtype, coords, freq). Assign it to a function's return/parameter
43
+ annotation and the `declare_units` / `declare_schema` / `declare_freq` decorators
44
+ read it back exactly as they would a hand-written one.
45
+
46
+ Each facet accepts either a raw value or an already-built marker, so a caller
47
+ holding a mix (e.g. a `Unit` object but bare dim-name tuples) can pass both
48
+ without unwrapping:
49
+
50
+ * `unit` — a unit string (`"Pa"`) or a `Unit`.
51
+ * `dims` — an iterable of dim names (`("time", "x")`) or a `Dims`.
52
+ * `dtype` — a dtype string (`"float64"`) or a `Dtype`.
53
+ * `coords` — an iterable of coord names or a `Coords`.
54
+ * `freq` — an offset string (`"7D"`) or a `Freq`.
55
+
56
+ A facet left as `None` contributes no marker. When no facet is given, `base`
57
+ is returned unchanged (no `Annotated` wrapper), so `annotate()` is a safe
58
+ no-op default.
59
+
60
+ Note the `freq` string is a *writer* convenience only: there is no bare-string
61
+ shorthand on the read side, so a frequency must be spelled as a `Freq` marker in
62
+ a hand-written annotation.
63
+
64
+ Args:
65
+ base: The base type to annotate (default `xarray.DataArray`).
66
+ unit: Declared unit, or `None`.
67
+ dims: Declared dimensions, or `None`.
68
+ dtype: Declared dtype, or `None`.
69
+ coords: Declared coordinates, or `None`.
70
+ freq: Declared temporal frequency, or `None`.
71
+
72
+ Returns:
73
+ `Annotated[base, <markers>]` if any facet was given; otherwise `base`.
74
+
75
+ Examples:
76
+ >>> from typing import Annotated, get_args, get_origin
77
+ >>> import xarray as xr
78
+ >>> from xarray_annotated import annotate
79
+ >>> from xarray_annotated.units import Unit
80
+ >>> hint = annotate(unit="Pa", dims=("time", "x"), dtype="float64")
81
+ >>> get_origin(hint) is Annotated
82
+ True
83
+ >>> Unit("Pa") in get_args(hint)
84
+ True
85
+ >>> annotate() is xr.DataArray
86
+ True
87
+ """
88
+ markers: list[Any] = []
89
+ if unit is not None:
90
+ markers.append(unit if isinstance(unit, Unit) else Unit(unit))
91
+ if dims is not None:
92
+ markers.append(dims if isinstance(dims, Dims) else Dims(*dims))
93
+ if dtype is not None:
94
+ markers.append(dtype if isinstance(dtype, Dtype) else Dtype(dtype))
95
+ if coords is not None:
96
+ markers.append(coords if isinstance(coords, Coords) else Coords(*coords))
97
+ if freq is not None:
98
+ markers.append(freq if isinstance(freq, Freq) else Freq(freq))
99
+ return Annotated[(base, *markers)] if markers else base
File without changes
@@ -0,0 +1,62 @@
1
+ """Structural-property domain for xarray-annotated.
2
+
3
+ Declares and validates a DataArray's *structural* properties — the ones every
4
+ DataArray possesses regardless of physical units: its dimensions (``Dims``),
5
+ coordinates (``Coords``), and dtype (``Dtype``). The structural counterpart to
6
+ ``xarray_annotated.units``, but simpler: structural validation only *asserts* (it
7
+ never converts or mutates).
8
+
9
+ Declare several at once inside ``Annotated`` and apply them with ``declare_schema``::
10
+
11
+ from typing import Annotated
12
+ import xarray as xr
13
+ from xarray_annotated.schema import declare_schema, Dims, Dtype
14
+
15
+ @declare_schema
16
+ def f(x: Annotated[xr.DataArray, Dims("time", "x"), Dtype("float64")]) -> xr.DataArray:
17
+ ...
18
+ """
19
+
20
+ from ._annotations import (
21
+ Coords,
22
+ Dims,
23
+ Dtype,
24
+ annotated_schema,
25
+ schema_from_signature,
26
+ )
27
+ from ._check import (
28
+ SchemaError,
29
+ SchemaWarning,
30
+ assert_valid_schema,
31
+ check_schema,
32
+ dims_compatible,
33
+ dtype_compatible,
34
+ )
35
+ from ._config import (
36
+ OnMismatch,
37
+ Policy,
38
+ get_policy,
39
+ policy,
40
+ set_policy,
41
+ )
42
+ from ._decorator import declare_schema
43
+
44
+ __all__ = [
45
+ "Coords",
46
+ "Dims",
47
+ "Dtype",
48
+ "OnMismatch",
49
+ "Policy",
50
+ "SchemaError",
51
+ "SchemaWarning",
52
+ "annotated_schema",
53
+ "assert_valid_schema",
54
+ "check_schema",
55
+ "declare_schema",
56
+ "dims_compatible",
57
+ "dtype_compatible",
58
+ "get_policy",
59
+ "policy",
60
+ "schema_from_signature",
61
+ "set_policy",
62
+ ]