xarray-annotated 0.5.2__py3-none-any.whl → 0.7.0__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.
@@ -1,50 +1,60 @@
1
- """xarray-annotated: validate xarray DataArray properties declared via ``typing.Annotated``.
1
+ """xarray-annotated: validate DataArray properties declared via ``typing.Annotated``.
2
2
 
3
3
  Annotation is the unifying technology: a declared property is read off an
4
- ``Annotated[DataArray, ...]`` hint and validated at runtime. Each kind of
4
+ ``Annotated[DataArray, ...]`` hint and enforced at runtime. Each kind of
5
5
  property is a domain subpackage, imported explicitly:
6
6
 
7
- * ``xarray_annotated.units`` — physical units (pint / CF), the mature domain.
7
+ * ``xarray_annotated.units`` — physical units (pint / CF).
8
8
  * ``xarray_annotated.schema`` — structural properties (dims, coords, dtype);
9
9
  validate-only (never mutates).
10
10
  * ``xarray_annotated.temporal`` — the frequency (and phase) of a time axis;
11
11
  validate-only.
12
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.
13
+ One decorator, ``enforce``, applies every facet declared on a signature, units first:
14
+
15
+ import xarray_annotated as xra
16
+ from xarray_annotated import Dims, Freq, Unit
17
+
18
+ @xra.enforce
19
+ def f(p: Annotated[xr.DataArray, Unit("Pa"), Dims("time"), Freq("D")]): ...
20
+
21
+ The top level is deliberately thin — the domains' checker names stay in their
22
+ subpackages. The exceptions are the markers (``Coords``, ``Dims``, ``Dtype``,
23
+ ``Freq``, ``Unit``), which are unique across domains and are combined freely in one
24
+ hint, and the domain-*agnostic*
25
+ names: ``enforce``; the ``enabled`` switch; the shared
26
+ declaration *writer* ``annotate`` and its inverse *readers*
27
+ ``declaration_from_hint`` and ``declarations_from_signature`` (which read every facet
28
+ declared on a hint or a signature into one uniform ``Declared`` value).
29
29
  """
30
30
 
31
31
  from importlib.metadata import version as _version
32
32
 
33
33
  from . import schema, temporal, units
34
- from ._annotations import unwrap_annotated, walk_signature
35
- from ._reader import Declared, declarations_from_signature
34
+ from ._config import enabled
35
+ from ._enforce import enforce
36
+ from ._reader import Declared, declaration_from_hint, declarations_from_signature
36
37
  from ._writer import annotate
38
+ from .schema import Coords, Dims, Dtype
39
+ from .temporal import Freq
40
+ from .units import Unit
37
41
 
38
42
  __version__ = _version("xarray-annotated")
39
43
 
40
44
  __all__ = [
45
+ "Coords",
41
46
  "Declared",
47
+ "Dims",
48
+ "Dtype",
49
+ "Freq",
50
+ "Unit",
42
51
  "__version__",
43
52
  "annotate",
53
+ "declaration_from_hint",
44
54
  "declarations_from_signature",
55
+ "enabled",
56
+ "enforce",
45
57
  "schema",
46
58
  "temporal",
47
59
  "units",
48
- "unwrap_annotated",
49
- "walk_signature",
50
60
  ]
@@ -1,20 +1,19 @@
1
1
  """Shared `typing.Annotated` introspection for xarray-annotated.
2
2
 
3
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.
4
+ the "declaration side" common to every domain. An *extractor* turns one
5
+ `Annotated[...]` hint into a declaration payload;
6
+ `_walk_signature` drives that extractor over a whole function signature, handling
7
+ the `TypedDict` / dataclass / single-value return shapes once, for everyone.
9
8
  """
10
9
 
11
10
  import dataclasses
12
11
  import types
13
- from collections.abc import Callable
12
+ from collections.abc import Callable, Iterable
14
13
  from typing import (
15
14
  Annotated,
16
15
  Any,
17
- TypeVar,
16
+ TypeAliasType,
18
17
  Union,
19
18
  get_args,
20
19
  get_origin,
@@ -24,96 +23,157 @@ from typing import (
24
23
 
25
24
  import xarray as xr
26
25
 
27
- _T = TypeVar("_T")
28
26
 
27
+ def _single_marker[M](metadata: Iterable[Any], marker_type: type[M]) -> M | None:
28
+ """Return the one `marker_type` marker in `metadata`, or `None` if absent.
29
29
 
30
- def unwrap_annotated(hint: Any) -> Any:
31
- """Return the underlying type of an `Annotated` hint, else the hint itself.
30
+ A hint declares each facet at most once. Two markers of one type are either
31
+ contradictory (`Dims("x")` with `Dims("y")`) or redundant, so a second one is a
32
+ malformed declaration rather than something to resolve by position.
32
33
 
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.
34
+ Raises:
35
+ ValueError: If `metadata` holds more than one `marker_type` marker.
42
36
  """
43
- return get_args(hint)[0] if get_origin(hint) is Annotated else hint
37
+ found = [m for m in metadata if isinstance(m, marker_type)]
38
+ if len(found) > 1:
39
+ raise ValueError(
40
+ f"one Annotated hint declares {len(found)} {marker_type.__name__} "
41
+ f"markers ({', '.join(map(repr, found))}); declare at most one"
42
+ )
43
+ return found[0] if found else None
44
44
 
45
45
 
46
46
  def _is_dataarray_type(tp: Any) -> bool:
47
- """Return whether `tp` is `DataArray` (possibly wrapped in a Union).
47
+ """Return whether `tp` is `DataArray` (possibly wrapped or in a Union).
48
48
 
49
49
  Accepts a bare `DataArray` as well as `DataArray | None` /
50
50
  `Optional[DataArray]` (and any other union that includes `DataArray`), so an
51
51
  optional DataArray parameter can still carry a declaration. Anything else
52
52
  (scalars, `str`, `bool`, `Dataset`, …) is not a declaration-bearing type.
53
53
 
54
+ Also accepts any *array-type wrapper* that exposes the underlying array class
55
+ as an `array_type` attribute — notably jaxtyping's `Float[DataArray, "t x"]`,
56
+ which occupies the base-type slot of an `Annotated` hint rather than competing
57
+ with it. This is a duck-typed check: jaxtyping is never imported, and any
58
+ other library following the same convention composes for free. See
59
+ `docs/guides/jaxtyping.md`.
60
+
54
61
  Args:
55
62
  tp: A type annotation to inspect.
56
63
 
57
64
  Returns:
58
- `True` if `tp` is or contains `DataArray`.
65
+ `True` if `tp` is, wraps, or contains `DataArray`.
59
66
  """
60
67
  if tp is xr.DataArray:
61
68
  return True
69
+ inner = getattr(tp, "array_type", None)
70
+ if isinstance(inner, type) and issubclass(inner, xr.DataArray):
71
+ return True
62
72
  if get_origin(tp) in (Union, types.UnionType):
63
73
  return any(_is_dataarray_type(arg) for arg in get_args(tp))
64
74
  return False
65
75
 
66
76
 
67
- def walk_signature(
77
+ def _strip_return_hint(
78
+ hint: Any, path: frozenset[Any] = frozenset()
79
+ ) -> tuple[Any, bool]:
80
+ """Strip `type` aliases, `Annotated` and `| None` off a return hint.
81
+
82
+ An alias that refers to itself is left in place: it resolves to nothing. The reader
83
+ raises on one, except a generic one, as in `type G[T] = G[T] | None`, whose `G[int]`
84
+ it does not enter.
85
+
86
+ Returns:
87
+ What remains, and whether a `| None` was stripped on the way to it.
88
+ """
89
+ origin = get_origin(hint)
90
+ alias = next((a for a in (hint, origin) if isinstance(a, TypeAliasType)), None)
91
+ if alias is not None:
92
+ if alias in path:
93
+ return hint, False
94
+ return _strip_return_hint(alias.__value__, path | {alias})
95
+ if origin is Annotated:
96
+ return _strip_return_hint(get_args(hint)[0], path)
97
+ if origin in (Union, types.UnionType):
98
+ members = [arg for arg in get_args(hint) if arg is not types.NoneType]
99
+ if len(members) == 1:
100
+ return _strip_return_hint(members[0], path)[0], True
101
+ return hint, False
102
+
103
+
104
+ def _record_return(hint: Any) -> tuple[Any, bool]:
105
+ """Read a return hint's `TypedDict` or dataclass, and whether it may be `None`.
106
+
107
+ Returns:
108
+ The class, or `None` if the hint resolves to no such class, and whether the
109
+ hint allows `None`.
110
+ """
111
+ base, optional = _strip_return_hint(hint)
112
+ record = get_origin(base) or base
113
+ is_record = is_typeddict(record) or dataclasses.is_dataclass(record)
114
+ return (record if isinstance(record, type) and is_record else None), optional
115
+
116
+
117
+ def _walk_signature[T](
68
118
  func: object,
69
- extract: Callable[[Any], _T | None],
70
- ) -> tuple[dict[str, _T], dict[str, _T] | _T | None]:
119
+ extract: Callable[[Any], T | None],
120
+ ) -> tuple[dict[str, T], dict[str, T] | T | None]:
71
121
  """Extract per-parameter and return declarations from a function's hints.
72
122
 
73
123
  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.
124
+ `extract`, which returns a declaration payload for that hint or `None` when
125
+ the hint carries no declaration. `extract` also sees a whole return hint before
126
+ any of its fields, so it can refuse what it finds there.
77
127
 
78
128
  Args:
79
129
  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]`).
130
+ extract: Turns one hint into a declaration payload, or `None`. For every
131
+ built-in facet at once this is `declaration_from_hint` (payload
132
+ `Declared`).
83
133
 
84
134
  Returns:
85
135
  An `(inputs, output)` pair:
86
136
 
87
137
  * `inputs` — `dict[str, T]` mapping each declared parameter to its payload.
88
138
  * `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.
139
+ `TypedDict` or dataclass, read through a `type` alias, `Annotated`,
140
+ `| None` and generic parameters; a single `T` if the return is one
141
+ declared `Annotated[DataArray, ...]`; `None` otherwise.
91
142
  """
143
+ qualname = getattr(func, "__qualname__", repr(func))
144
+
145
+ def _extract(hint: Any, where: str) -> T | None:
146
+ try:
147
+ return extract(hint)
148
+ except ValueError as exc:
149
+ exc.add_note(f"in {qualname} {where}")
150
+ raise
151
+
92
152
  hints = get_type_hints(func, include_extras=True)
93
153
  ret = hints.pop("return", None)
94
154
 
95
155
  inputs = {
96
156
  name: payload
97
157
  for name, hint in hints.items()
98
- if (payload := extract(hint)) is not None
158
+ if (payload := _extract(hint, f"input {name!r}")) is not None
99
159
  }
100
160
 
101
- output: dict[str, _T] | _T | None
102
- if is_typeddict(ret):
103
- ret_hints = get_type_hints(ret, include_extras=True)
161
+ output: dict[str, T] | T | None = _extract(ret, "output")
162
+ record, _ = _record_return(ret)
163
+ if is_typeddict(record):
164
+ ret_hints = get_type_hints(record, include_extras=True)
104
165
  output = {
105
166
  name: payload
106
167
  for name, hint in ret_hints.items()
107
- if (payload := extract(hint)) is not None
168
+ if (payload := _extract(hint, f"output {name!r}")) is not None
108
169
  }
109
- elif dataclasses.is_dataclass(ret):
110
- ret_hints = get_type_hints(ret, include_extras=True)
170
+ elif record is not None:
171
+ ret_hints = get_type_hints(record, include_extras=True)
111
172
  output = {
112
173
  f.name: payload
113
- for f in dataclasses.fields(ret)
114
- if (payload := extract(ret_hints.get(f.name))) is not None
174
+ for f in dataclasses.fields(record)
175
+ if (payload := _extract(ret_hints.get(f.name), f"output {f.name!r}"))
176
+ is not None
115
177
  }
116
- else:
117
- output = extract(ret)
118
178
 
119
179
  return inputs, output
@@ -1,66 +1,33 @@
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.
1
+ """The package-wide master switch, the only global that changes how checks behave.
2
+
3
+ Behaviour is never configured globally, so that how a function validates can be read
4
+ off its signature. What remains global is whether xarray-annotated does anything at
5
+ all. The unit registry is process-wide too, but it decides which units parse, rather
6
+ than what a check does with them.
7
+
8
+ The switch resolves on every call as: an `enabled(...)` block, then the
9
+ `XARRAY_ANNOTATED_ENABLED` environment variable, then on. Code beats the
10
+ environment in both directions, so `enabled(True)` restores checking in a process
11
+ started with the variable set to false.
18
12
  """
19
13
 
20
14
  import os
21
- from collections.abc import Callable
22
- from typing import TypeVar
15
+ from collections.abc import Generator
16
+ from contextlib import contextmanager
17
+ from contextvars import ContextVar
18
+
19
+ import numpy as np
23
20
 
24
- #: Master switch default. Validation is on unless explicitly disabled.
25
- DEFAULT_ENABLED: bool = True
21
+ __all__ = ["ENABLED_ENV_VAR", "enabled", "resolve_enabled"]
26
22
 
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
23
  ENABLED_ENV_VAR = "XARRAY_ANNOTATED_ENABLED"
30
24
 
31
- #: String values (lower-cased) accepted for the boolean `enabled` env var.
32
25
  _TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"})
33
26
  _FALSEY: frozenset[str] = frozenset({"0", "false", "no", "off"})
34
27
 
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
28
+ _override: ContextVar[bool | None] = ContextVar(
29
+ "xarray_annotated_enabled", default=None
30
+ )
64
31
 
65
32
 
66
33
  def _parse_enabled_env(value: str) -> bool:
@@ -76,26 +43,58 @@ def _parse_enabled_env(value: str) -> bool:
76
43
 
77
44
 
78
45
  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
-
46
+ """Return whether xarray-annotated is active for this call.
84
47
 
85
- def get_enabled_override() -> bool | None:
86
- """Return the current process override for the master switch (`None` if unset).
48
+ Returns:
49
+ The innermost `enabled(...)` value if inside one, else the parsed
50
+ `XARRAY_ANNOTATED_ENABLED`, else `True`.
87
51
 
88
- Used by each domain's `policy` context manager to save/restore the shared
89
- switch around a temporary override.
52
+ Raises:
53
+ ValueError: If the environment variable holds an unrecognised value.
90
54
  """
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.
55
+ override = _override.get()
56
+ if override is not None:
57
+ return override
58
+ env = os.environ.get(ENABLED_ENV_VAR)
59
+ if env:
60
+ return _parse_enabled_env(env)
61
+ return True
62
+
63
+
64
+ @contextmanager
65
+ def enabled(value: bool | np.bool_) -> Generator[None]:
66
+ """Switch xarray-annotated on or off for a block.
67
+
68
+ Off means no checks, so a body declaring `"Pa"` can receive `"hPa"`. Declarations
69
+ are still validated when a function is decorated.
70
+
71
+ The block beats `XARRAY_ANNOTATED_ENABLED` in both directions, and is
72
+ scoped to the current thread or async task. The variable accepts `1`,
73
+ `true`, `yes` or `on` and `0`, `false`, `no` or `off`, in any case; anything
74
+ else raises `ValueError` when a checked function is called.
75
+
76
+ Args:
77
+ value: `False` to switch off, `True` to switch on.
78
+
79
+ Raises:
80
+ TypeError: If `value` is not a bool, as `bool("false")` would leave checks on.
81
+
82
+ Examples:
83
+ >>> from typing import Annotated
84
+ >>> import xarray as xr
85
+ >>> import xarray_annotated as xra
86
+ >>> @xra.enforce
87
+ ... def f(p: Annotated[xr.DataArray, xra.Unit("Pa")]):
88
+ ... return p
89
+ >>> hpa = xr.DataArray([1.0], attrs={"units": "hPa"})
90
+ >>> with xra.enabled(False):
91
+ ... f(hpa).attrs["units"]
92
+ 'hPa'
99
93
  """
100
- global _process_enabled
101
- _process_enabled = None if value is None else bool(value)
94
+ if not isinstance(value, bool | np.bool_):
95
+ raise TypeError(f"enabled takes a bool, not {type(value).__name__}.")
96
+ token = _override.set(bool(value))
97
+ try:
98
+ yield
99
+ finally:
100
+ _override.reset(token)