xarray-annotated 0.6.0__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
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,19 +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,
16
+ TypeAliasType,
17
17
  Union,
18
18
  get_args,
19
19
  get_origin,
@@ -24,20 +24,23 @@ from typing import (
24
24
  import xarray as xr
25
25
 
26
26
 
27
- def unwrap_annotated(hint: Any) -> Any:
28
- """Return the underlying type of an `Annotated` hint, else the hint itself.
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
- `Annotated[DataArray, ...]` → `DataArray`; a non-`Annotated` hint is returned
31
- unchanged. Lets type comparisons (e.g. `t is DataArray`) see through the
32
- metadata that declarations attach to parameters.
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.
33
33
 
34
- Args:
35
- hint: A type hint, possibly `Annotated`.
36
-
37
- Returns:
38
- The base type if `hint` is `Annotated`; otherwise `hint` unchanged.
34
+ Raises:
35
+ ValueError: If `metadata` holds more than one `marker_type` marker.
39
36
  """
40
- 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
41
44
 
42
45
 
43
46
  def _is_dataarray_type(tp: Any) -> bool:
@@ -71,56 +74,106 @@ def _is_dataarray_type(tp: Any) -> bool:
71
74
  return False
72
75
 
73
76
 
74
- def walk_signature[T](
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](
75
118
  func: object,
76
119
  extract: Callable[[Any], T | None],
77
120
  ) -> tuple[dict[str, T], dict[str, T] | T | None]:
78
121
  """Extract per-parameter and return declarations from a function's hints.
79
122
 
80
123
  Reads `get_type_hints(func, include_extras=True)` and maps each hint through
81
- `extract`, which returns a domain's declaration payload for that hint or
82
- `None` when the hint carries no declaration. The `is not None` filter is the
83
- 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.
84
127
 
85
128
  Args:
86
129
  func: A callable whose parameters/return may carry `Annotated` metadata.
87
- extract: Turns one hint into a declaration payload, or `None`. For units
88
- this is `annotated_unit` (payload `str`); for schema `annotated_schema`
89
- (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`).
90
133
 
91
134
  Returns:
92
135
  An `(inputs, output)` pair:
93
136
 
94
137
  * `inputs` — `dict[str, T]` mapping each declared parameter to its payload.
95
138
  * `output` — a `dict[str, T]` (per-field) if the return hint is a
96
- `TypedDict` or dataclass; a single `T` if the return is one declared
97
- `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.
98
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
+
99
152
  hints = get_type_hints(func, include_extras=True)
100
153
  ret = hints.pop("return", None)
101
154
 
102
155
  inputs = {
103
156
  name: payload
104
157
  for name, hint in hints.items()
105
- if (payload := extract(hint)) is not None
158
+ if (payload := _extract(hint, f"input {name!r}")) is not None
106
159
  }
107
160
 
108
- output: dict[str, T] | T | None
109
- if is_typeddict(ret):
110
- 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)
111
165
  output = {
112
166
  name: payload
113
167
  for name, hint in ret_hints.items()
114
- if (payload := extract(hint)) is not None
168
+ if (payload := _extract(hint, f"output {name!r}")) is not None
115
169
  }
116
- elif dataclasses.is_dataclass(ret):
117
- 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)
118
172
  output = {
119
173
  f.name: payload
120
- for f in dataclasses.fields(ret)
121
- 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
122
177
  }
123
- else:
124
- output = extract(ret)
125
178
 
126
179
  return inputs, output
@@ -1,63 +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
15
+ from collections.abc import Generator
16
+ from contextlib import contextmanager
17
+ from contextvars import ContextVar
18
+
19
+ import numpy as np
22
20
 
23
- #: Master switch default. Validation is on unless explicitly disabled.
24
- DEFAULT_ENABLED: bool = True
21
+ __all__ = ["ENABLED_ENV_VAR", "enabled", "resolve_enabled"]
25
22
 
26
- #: Master switch is package-wide (gates every annotation-validation domain), so it
27
- #: is deliberately un-namespaced; each domain's behavioural knobs are namespaced.
28
23
  ENABLED_ENV_VAR = "XARRAY_ANNOTATED_ENABLED"
29
24
 
30
- #: String values (lower-cased) accepted for the boolean `enabled` env var.
31
25
  _TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"})
32
26
  _FALSEY: frozenset[str] = frozenset({"0", "false", "no", "off"})
33
27
 
34
- #: Process-wide override for the master switch, shared across all domains.
35
- _process_enabled: bool | None = None
36
-
37
-
38
- class _Unset:
39
- """Sentinel distinguishing 'argument omitted' from 'explicitly set to None'."""
40
-
41
- def __repr__(self) -> str:
42
- return "<unset>"
43
-
44
-
45
- _UNSET = _Unset()
46
-
47
-
48
- def _resolve[T](
49
- env_var: str,
50
- process_value: T | None,
51
- default: T,
52
- parse: Callable[[str], T],
53
- ) -> T:
54
- """Resolve one axis: env var (parsed) → process override → default."""
55
- env = os.environ.get(env_var)
56
- if env:
57
- return parse(env)
58
- if process_value is not None:
59
- return process_value
60
- return default
28
+ _override: ContextVar[bool | None] = ContextVar(
29
+ "xarray_annotated_enabled", default=None
30
+ )
61
31
 
62
32
 
63
33
  def _parse_enabled_env(value: str) -> bool:
@@ -73,26 +43,58 @@ def _parse_enabled_env(value: str) -> bool:
73
43
 
74
44
 
75
45
  def resolve_enabled() -> bool:
76
- """Resolve the package-wide master switch (env → process override → default)."""
77
- return _resolve(
78
- ENABLED_ENV_VAR, _process_enabled, DEFAULT_ENABLED, _parse_enabled_env
79
- )
80
-
46
+ """Return whether xarray-annotated is active for this call.
81
47
 
82
- def get_enabled_override() -> bool | None:
83
- """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`.
84
51
 
85
- Used by each domain's `policy` context manager to save/restore the shared
86
- switch around a temporary override.
52
+ Raises:
53
+ ValueError: If the environment variable holds an unrecognised value.
87
54
  """
88
- return _process_enabled
89
-
90
-
91
- def set_enabled_override(value: bool | None) -> None:
92
- """Set (or, with `None`, clear) the process override for the master switch.
93
-
94
- Shared by every domain's `set_policy`, so disabling validation in one domain
95
- 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'
96
93
  """
97
- global _process_enabled
98
- _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)