xarray-annotated 0.6.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (36) hide show
  1. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/PKG-INFO +43 -37
  2. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/README.md +41 -35
  3. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/pyproject.toml +4 -2
  4. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/pyproject.toml.orig +6 -3
  5. xarray_annotated-0.7.0/src/xarray_annotated/__init__.py +60 -0
  6. xarray_annotated-0.7.0/src/xarray_annotated/_annotations.py +179 -0
  7. xarray_annotated-0.7.0/src/xarray_annotated/_config.py +100 -0
  8. xarray_annotated-0.7.0/src/xarray_annotated/_enforce.py +379 -0
  9. xarray_annotated-0.7.0/src/xarray_annotated/_reader.py +230 -0
  10. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/_writer.py +16 -17
  11. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/schema/__init__.py +5 -23
  12. xarray_annotated-0.7.0/src/xarray_annotated/schema/_annotations.py +127 -0
  13. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/schema/_check.py +57 -55
  14. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/temporal/__init__.py +5 -24
  15. xarray_annotated-0.7.0/src/xarray_annotated/temporal/_annotations.py +68 -0
  16. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/temporal/_check.py +154 -99
  17. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/units/__init__.py +3 -23
  18. xarray_annotated-0.7.0/src/xarray_annotated/units/_annotations.py +57 -0
  19. xarray_annotated-0.7.0/src/xarray_annotated/units/_check.py +490 -0
  20. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/units/_registry.py +9 -1
  21. xarray_annotated-0.6.0/src/xarray_annotated/__init__.py +0 -50
  22. xarray_annotated-0.6.0/src/xarray_annotated/_annotations.py +0 -126
  23. xarray_annotated-0.6.0/src/xarray_annotated/_config.py +0 -98
  24. xarray_annotated-0.6.0/src/xarray_annotated/_reader.py +0 -110
  25. xarray_annotated-0.6.0/src/xarray_annotated/schema/_annotations.py +0 -254
  26. xarray_annotated-0.6.0/src/xarray_annotated/schema/_config.py +0 -147
  27. xarray_annotated-0.6.0/src/xarray_annotated/schema/_decorator.py +0 -125
  28. xarray_annotated-0.6.0/src/xarray_annotated/temporal/_annotations.py +0 -154
  29. xarray_annotated-0.6.0/src/xarray_annotated/temporal/_config.py +0 -182
  30. xarray_annotated-0.6.0/src/xarray_annotated/temporal/_decorator.py +0 -133
  31. xarray_annotated-0.6.0/src/xarray_annotated/units/_annotations.py +0 -169
  32. xarray_annotated-0.6.0/src/xarray_annotated/units/_check.py +0 -592
  33. xarray_annotated-0.6.0/src/xarray_annotated/units/_config.py +0 -237
  34. xarray_annotated-0.6.0/src/xarray_annotated/units/_decorator.py +0 -204
  35. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/LICENSE +0 -0
  36. {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/py.typed +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xarray-annotated
3
- Version: 0.6.0
4
- Summary: Run-time validation of xarray DataArray properties (units, dims/coords/dtype) declared via typing.Annotated
3
+ Version: 0.7.0
4
+ Summary: Run-time validation of xarray DataArray structure, units and frequency, declared via typing.Annotated
5
5
  Keywords: xarray,pint,units,validation,annotations
6
6
  Author: jmarshrossney
7
7
  Author-email: jmarshrossney <17361029+jmarshrossney@users.noreply.github.com>
@@ -33,21 +33,19 @@ Description-Content-Type: text/markdown
33
33
  [![Docs](https://github.com/jmarshrossney/xarray-annotated/actions/workflows/docs.yml/badge.svg)](https://jmarshrossney.github.io/xarray-annotated)
34
34
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/jmarshrossney/xarray-annotated/blob/main/LICENSE)
35
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).
36
+ `xarray-annotated` checks `xarray.DataArray` arguments and return values against properties declared in the function signature with [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated):
37
37
 
38
- The main idea is that annotations serve as a single source of truth for
38
+ - **structure**: dimensions, coordinates and dtype;
39
+ - **physical units**;
40
+ - **time frequency** and phase.
39
41
 
40
- - structural schema (dims, coords, dtype),
41
- - physical units,
42
- - frequency (and phase) of a time axis,
42
+ Making signatures the single source of truth has several advantages: the same declaration
43
43
 
44
- from which
44
+ - can be used by automated checks and validation, and hence enforced at run time;
45
+ - appears in generated documentation without needing to be hand-written;
46
+ - provides rich context for AI coding agents.
45
47
 
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).
48
+ For physical units we lean on the excellent [pint](https://pint.readthedocs.io/en/stable/) and [pint-xarray](https://pint-xarray.readthedocs.io).
51
49
  ([cf-xarray](https://cf-xarray.readthedocs.io) is an optional dependency for CF/UDUNITS unit strings.)
52
50
 
53
51
  For full user documentation please visit **[https://jmarshrossney.github.io/xarray-annotated/](https://jmarshrossney.github.io/xarray-annotated/)**.
@@ -98,26 +96,37 @@ Attributes:
98
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.
99
97
  The returned altitude is numerically wrong and carries a completely bogus hPa units in `attrs`.
100
98
 
101
- Here we use `xarray-annotated` to declare expected properties in the signature and enforce them at run-time with minimal boilerplate (just decorators).
99
+ Here we use `xarray-annotated` to declare expected properties in the signature and enforce them at run-time with minimal boilerplate (one decorator).
102
100
 
101
+ <!-- docs-test: raises ValueError -->
103
102
  ```python
104
103
  from typing import Annotated
105
104
  import xarray as xr
106
- from xarray_annotated.schema import declare_schema, Dims
107
- from xarray_annotated.units import declare_units, Unit
105
+ import xarray_annotated as xra
106
+ from xarray_annotated import Dims, Unit
108
107
 
109
108
 
110
- @declare_units
111
- @declare_schema
109
+ @xra.enforce
112
110
  def altitude(
113
111
  p: Annotated[xr.DataArray, Dims("time"), Unit("Pa")],
114
112
  ) -> Annotated[xr.DataArray, Dims("time"), Unit("m")]:
115
113
  """Barometric altitude."""
116
- return 44330.0 * (1.0 - (p / 101325.0) ** 0.1903)
114
+ alt = 44330.0 * (1.0 - (p / 101325.0) ** 0.1903)
115
+ return alt.assign_attrs(units="m")
117
116
 
118
117
 
119
118
  p = xr.DataArray([1013.0, 1000.0], dims=["time"], attrs={"units": "hPa"})
120
119
  altitude(p)
120
+ # ValueError: [altitude] input 'p': unit 'hPa' differs from declared 'Pa'.
121
+ # Converting would change the values; if that is correct for this quantity,
122
+ # pass xra.units.convert(da, 'Pa').
123
+ ```
124
+
125
+ The wrong unit is now an error rather than a wrong answer.
126
+ Pressure is safe to convert, so we can do that explicitly:
127
+
128
+ ```python
129
+ altitude(xra.units.convert(p, "Pa"))
121
130
  ```
122
131
  ```
123
132
  <xarray.DataArray (time: 2)> Size: 16B
@@ -127,34 +136,31 @@ Attributes:
127
136
  units: m
128
137
  ```
129
138
 
130
- Now the outputs are physical and carry the correct units attribute.
131
- The `hPa` input was converted to `Pa` before the body ran, and the result was stamped with the unit the signature promised.
139
+ Now the outputs are physical and carry the unit the signature promised.
140
+ The body has to state that unit, because xarray would otherwise leave the `Pa` label on the result, and a label that disagrees with the declaration raises: it could just as easily mean a forgotten conversion.
141
+ Nothing is [converted automatically](https://jmarshrossney.github.io/xarray-annotated/guides/units.html#converting-an-input), because whether a conversion is correct depends on the quantity, and a compatible unit is not always the right one.
132
142
  The docstring no longer needs to say "assumes Pa", because the signature does --- and unlike the docstring, it is actually enforced.
133
143
 
134
- If we had handed the function an array whose units were _dimensionally_ wrong, or with an extra dimension, this would have failed gracefully:
144
+ An array whose units were _dimensionally_ wrong would also have raised:
135
145
 
146
+ <!-- docs-test: raises DimensionalityError -->
136
147
  ```python
137
148
  altitude(xr.DataArray([1.0, 2.0], dims=["time"], attrs={"units": "kg"}))
138
149
  # DimensionalityError: Cannot convert from 'kilogram' ([mass])
139
150
  # to 'pascal' ([mass] / [length] / [time] ** 2)
140
-
141
- altitude(xr.DataArray([[1013.0, 1000.0]], dims=["run", "time"], attrs={"units": "hPa"}))
142
- # SchemaError: [altitude] 'p' dims mismatch: expected ('time',) in any order, got ('run', 'time')
143
151
  ```
144
152
 
145
- ## Contributing
153
+ And so would one with an extra dimension:
146
154
 
147
- ```sh
148
- uv sync # install the package + dev dependencies into .venv
149
- just # lint, typecheck, test, build docs
155
+ <!-- docs-test: raises SchemaError -->
156
+ ```python
157
+ altitude(
158
+ xr.DataArray([[101300.0, 100000.0]], dims=["run", "time"], attrs={"units": "Pa"})
159
+ )
160
+ # SchemaError: [altitude] 'p' dims mismatch: expected ('time',) in any order, got ('run', 'time')
150
161
  ```
151
162
 
152
- See the `justfile` for individual targets (`just lint`, `just test`, `just test-cov`, ...).
153
-
154
- 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:
155
-
156
- ```sh
157
- uv run pre-commit install
158
- ```
163
+ ## Contributing
159
164
 
160
- Contributions are welcome --- open an issue or pull request on GitHub.
165
+ Issues and pull requests are welcome.
166
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the package's scope and how to set up a development environment.
@@ -6,21 +6,19 @@
6
6
  [![Docs](https://github.com/jmarshrossney/xarray-annotated/actions/workflows/docs.yml/badge.svg)](https://jmarshrossney.github.io/xarray-annotated)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/jmarshrossney/xarray-annotated/blob/main/LICENSE)
8
8
 
9
- `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).
9
+ `xarray-annotated` checks `xarray.DataArray` arguments and return values against properties declared in the function signature with [`typing.Annotated`](https://docs.python.org/3/library/typing.html#typing.Annotated):
10
10
 
11
- The main idea is that annotations serve as a single source of truth for
11
+ - **structure**: dimensions, coordinates and dtype;
12
+ - **physical units**;
13
+ - **time frequency** and phase.
12
14
 
13
- - structural schema (dims, coords, dtype),
14
- - physical units,
15
- - frequency (and phase) of a time axis,
15
+ Making signatures the single source of truth has several advantages: the same declaration
16
16
 
17
- from which
17
+ - can be used by automated checks and validation, and hence enforced at run time;
18
+ - appears in generated documentation without needing to be hand-written;
19
+ - provides rich context for AI coding agents.
18
20
 
19
- - static checkers and run-time validation can derive expected properties,
20
- - documentation can be generated automatically,
21
- - automated tools (coding agents) receive steering.
22
-
23
- 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).
21
+ For physical units we lean on the excellent [pint](https://pint.readthedocs.io/en/stable/) and [pint-xarray](https://pint-xarray.readthedocs.io).
24
22
  ([cf-xarray](https://cf-xarray.readthedocs.io) is an optional dependency for CF/UDUNITS unit strings.)
25
23
 
26
24
  For full user documentation please visit **[https://jmarshrossney.github.io/xarray-annotated/](https://jmarshrossney.github.io/xarray-annotated/)**.
@@ -71,26 +69,37 @@ Attributes:
71
69
  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.
72
70
  The returned altitude is numerically wrong and carries a completely bogus hPa units in `attrs`.
73
71
 
74
- Here we use `xarray-annotated` to declare expected properties in the signature and enforce them at run-time with minimal boilerplate (just decorators).
72
+ Here we use `xarray-annotated` to declare expected properties in the signature and enforce them at run-time with minimal boilerplate (one decorator).
75
73
 
74
+ <!-- docs-test: raises ValueError -->
76
75
  ```python
77
76
  from typing import Annotated
78
77
  import xarray as xr
79
- from xarray_annotated.schema import declare_schema, Dims
80
- from xarray_annotated.units import declare_units, Unit
78
+ import xarray_annotated as xra
79
+ from xarray_annotated import Dims, Unit
81
80
 
82
81
 
83
- @declare_units
84
- @declare_schema
82
+ @xra.enforce
85
83
  def altitude(
86
84
  p: Annotated[xr.DataArray, Dims("time"), Unit("Pa")],
87
85
  ) -> Annotated[xr.DataArray, Dims("time"), Unit("m")]:
88
86
  """Barometric altitude."""
89
- return 44330.0 * (1.0 - (p / 101325.0) ** 0.1903)
87
+ alt = 44330.0 * (1.0 - (p / 101325.0) ** 0.1903)
88
+ return alt.assign_attrs(units="m")
90
89
 
91
90
 
92
91
  p = xr.DataArray([1013.0, 1000.0], dims=["time"], attrs={"units": "hPa"})
93
92
  altitude(p)
93
+ # ValueError: [altitude] input 'p': unit 'hPa' differs from declared 'Pa'.
94
+ # Converting would change the values; if that is correct for this quantity,
95
+ # pass xra.units.convert(da, 'Pa').
96
+ ```
97
+
98
+ The wrong unit is now an error rather than a wrong answer.
99
+ Pressure is safe to convert, so we can do that explicitly:
100
+
101
+ ```python
102
+ altitude(xra.units.convert(p, "Pa"))
94
103
  ```
95
104
  ```
96
105
  <xarray.DataArray (time: 2)> Size: 16B
@@ -100,34 +109,31 @@ Attributes:
100
109
  units: m
101
110
  ```
102
111
 
103
- Now the outputs are physical and carry the correct units attribute.
104
- The `hPa` input was converted to `Pa` before the body ran, and the result was stamped with the unit the signature promised.
112
+ Now the outputs are physical and carry the unit the signature promised.
113
+ The body has to state that unit, because xarray would otherwise leave the `Pa` label on the result, and a label that disagrees with the declaration raises: it could just as easily mean a forgotten conversion.
114
+ Nothing is [converted automatically](https://jmarshrossney.github.io/xarray-annotated/guides/units.html#converting-an-input), because whether a conversion is correct depends on the quantity, and a compatible unit is not always the right one.
105
115
  The docstring no longer needs to say "assumes Pa", because the signature does --- and unlike the docstring, it is actually enforced.
106
116
 
107
- If we had handed the function an array whose units were _dimensionally_ wrong, or with an extra dimension, this would have failed gracefully:
117
+ An array whose units were _dimensionally_ wrong would also have raised:
108
118
 
119
+ <!-- docs-test: raises DimensionalityError -->
109
120
  ```python
110
121
  altitude(xr.DataArray([1.0, 2.0], dims=["time"], attrs={"units": "kg"}))
111
122
  # DimensionalityError: Cannot convert from 'kilogram' ([mass])
112
123
  # to 'pascal' ([mass] / [length] / [time] ** 2)
113
-
114
- altitude(xr.DataArray([[1013.0, 1000.0]], dims=["run", "time"], attrs={"units": "hPa"}))
115
- # SchemaError: [altitude] 'p' dims mismatch: expected ('time',) in any order, got ('run', 'time')
116
124
  ```
117
125
 
118
- ## Contributing
126
+ And so would one with an extra dimension:
119
127
 
120
- ```sh
121
- uv sync # install the package + dev dependencies into .venv
122
- just # lint, typecheck, test, build docs
128
+ <!-- docs-test: raises SchemaError -->
129
+ ```python
130
+ altitude(
131
+ xr.DataArray([[101300.0, 100000.0]], dims=["run", "time"], attrs={"units": "Pa"})
132
+ )
133
+ # SchemaError: [altitude] 'p' dims mismatch: expected ('time',) in any order, got ('run', 'time')
123
134
  ```
124
135
 
125
- See the `justfile` for individual targets (`just lint`, `just test`, `just test-cov`, ...).
126
-
127
- 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:
128
-
129
- ```sh
130
- uv run pre-commit install
131
- ```
136
+ ## Contributing
132
137
 
133
- Contributions are welcome --- open an issue or pull request on GitHub.
138
+ Issues and pull requests are welcome.
139
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the package's scope and how to set up a development environment.
@@ -4,8 +4,8 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "xarray-annotated"
7
- version = "0.6.0"
8
- description = "Run-time validation of xarray DataArray properties (units, dims/coords/dtype) declared via typing.Annotated"
7
+ version = "0.7.0"
8
+ description = "Run-time validation of xarray DataArray structure, units and frequency, declared via typing.Annotated"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
@@ -60,7 +60,9 @@ dev = [
60
60
  ]
61
61
  integrations = [
62
62
  "beartype>=0.22.9",
63
+ "dask>=2026.8.0",
63
64
  "jaxtyping>=0.3.11",
65
+ "netcdf4>=1.7.5.1.post1",
64
66
  ]
65
67
  docs = [
66
68
  "marimo>=0.23.11",
@@ -4,8 +4,8 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "xarray-annotated"
7
- version = "0.6.0"
8
- description = "Run-time validation of xarray DataArray properties (units, dims/coords/dtype) declared via typing.Annotated"
7
+ version = "0.7.0"
8
+ description = "Run-time validation of xarray DataArray structure, units and frequency, declared via typing.Annotated"
9
9
  authors = [
10
10
  { name = "jmarshrossney", email = "17361029+jmarshrossney@users.noreply.github.com" }
11
11
  ]
@@ -55,10 +55,13 @@ dev = [
55
55
  # test-only: there is nothing for a user to install to "enable" the integration, so this
56
56
  # is deliberately a dependency group and not an optional-dependency extra (contrast
57
57
  # `cf`, which is an extra because `units._registry` really does import cf_xarray).
58
- # `tests/test_jaxtyping.py` skips itself when they are absent.
58
+ # `tests/test_jaxtyping.py`, `test_dask.py` and `test_netcdf.py` skip themselves when
59
+ # they are absent.
59
60
  integrations = [
60
61
  "beartype>=0.22.9",
62
+ "dask>=2026.8.0",
61
63
  "jaxtyping>=0.3.11",
64
+ "netcdf4>=1.7.5.1.post1",
62
65
  ]
63
66
  docs = [
64
67
  "marimo>=0.23.11",
@@ -0,0 +1,60 @@
1
+ """xarray-annotated: validate 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 enforced at runtime. Each kind of
5
+ property is a domain subpackage, imported explicitly:
6
+
7
+ * ``xarray_annotated.units`` — physical units (pint / CF).
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
+ 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
+ """
30
+
31
+ from importlib.metadata import version as _version
32
+
33
+ from . import schema, temporal, units
34
+ from ._config import enabled
35
+ from ._enforce import enforce
36
+ from ._reader import Declared, declaration_from_hint, declarations_from_signature
37
+ from ._writer import annotate
38
+ from .schema import Coords, Dims, Dtype
39
+ from .temporal import Freq
40
+ from .units import Unit
41
+
42
+ __version__ = _version("xarray-annotated")
43
+
44
+ __all__ = [
45
+ "Coords",
46
+ "Declared",
47
+ "Dims",
48
+ "Dtype",
49
+ "Freq",
50
+ "Unit",
51
+ "__version__",
52
+ "annotate",
53
+ "declaration_from_hint",
54
+ "declarations_from_signature",
55
+ "enabled",
56
+ "enforce",
57
+ "schema",
58
+ "temporal",
59
+ "units",
60
+ ]
@@ -0,0 +1,179 @@
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. 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.
8
+ """
9
+
10
+ import dataclasses
11
+ import types
12
+ from collections.abc import Callable, Iterable
13
+ from typing import (
14
+ Annotated,
15
+ Any,
16
+ TypeAliasType,
17
+ Union,
18
+ get_args,
19
+ get_origin,
20
+ get_type_hints,
21
+ is_typeddict,
22
+ )
23
+
24
+ import xarray as xr
25
+
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
+
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
+
34
+ Raises:
35
+ ValueError: If `metadata` holds more than one `marker_type` marker.
36
+ """
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
+
45
+
46
+ def _is_dataarray_type(tp: Any) -> bool:
47
+ """Return whether `tp` is `DataArray` (possibly wrapped or 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
+ 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
+
61
+ Args:
62
+ tp: A type annotation to inspect.
63
+
64
+ Returns:
65
+ `True` if `tp` is, wraps, or contains `DataArray`.
66
+ """
67
+ if tp is xr.DataArray:
68
+ return True
69
+ inner = getattr(tp, "array_type", None)
70
+ if isinstance(inner, type) and issubclass(inner, xr.DataArray):
71
+ return True
72
+ if get_origin(tp) in (Union, types.UnionType):
73
+ return any(_is_dataarray_type(arg) for arg in get_args(tp))
74
+ return False
75
+
76
+
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](
118
+ func: object,
119
+ extract: Callable[[Any], T | None],
120
+ ) -> tuple[dict[str, T], dict[str, T] | T | None]:
121
+ """Extract per-parameter and return declarations from a function's hints.
122
+
123
+ Reads `get_type_hints(func, include_extras=True)` and maps each hint through
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.
127
+
128
+ Args:
129
+ func: A callable whose parameters/return may carry `Annotated` metadata.
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`).
133
+
134
+ Returns:
135
+ An `(inputs, output)` pair:
136
+
137
+ * `inputs` — `dict[str, T]` mapping each declared parameter to its payload.
138
+ * `output` — a `dict[str, T]` (per-field) if the return hint is a
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.
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
+
152
+ hints = get_type_hints(func, include_extras=True)
153
+ ret = hints.pop("return", None)
154
+
155
+ inputs = {
156
+ name: payload
157
+ for name, hint in hints.items()
158
+ if (payload := _extract(hint, f"input {name!r}")) is not None
159
+ }
160
+
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)
165
+ output = {
166
+ name: payload
167
+ for name, hint in ret_hints.items()
168
+ if (payload := _extract(hint, f"output {name!r}")) is not None
169
+ }
170
+ elif record is not None:
171
+ ret_hints = get_type_hints(record, include_extras=True)
172
+ output = {
173
+ f.name: payload
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
177
+ }
178
+
179
+ return inputs, output
@@ -0,0 +1,100 @@
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.
12
+ """
13
+
14
+ import os
15
+ from collections.abc import Generator
16
+ from contextlib import contextmanager
17
+ from contextvars import ContextVar
18
+
19
+ import numpy as np
20
+
21
+ __all__ = ["ENABLED_ENV_VAR", "enabled", "resolve_enabled"]
22
+
23
+ ENABLED_ENV_VAR = "XARRAY_ANNOTATED_ENABLED"
24
+
25
+ _TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on"})
26
+ _FALSEY: frozenset[str] = frozenset({"0", "false", "no", "off"})
27
+
28
+ _override: ContextVar[bool | None] = ContextVar(
29
+ "xarray_annotated_enabled", default=None
30
+ )
31
+
32
+
33
+ def _parse_enabled_env(value: str) -> bool:
34
+ lowered = value.lower()
35
+ if lowered in _TRUTHY:
36
+ return True
37
+ if lowered in _FALSEY:
38
+ return False
39
+ raise ValueError(
40
+ f"Invalid {ENABLED_ENV_VAR} value {value!r}. "
41
+ f"Use one of {sorted(_TRUTHY | _FALSEY)}."
42
+ )
43
+
44
+
45
+ def resolve_enabled() -> bool:
46
+ """Return whether xarray-annotated is active for this call.
47
+
48
+ Returns:
49
+ The innermost `enabled(...)` value if inside one, else the parsed
50
+ `XARRAY_ANNOTATED_ENABLED`, else `True`.
51
+
52
+ Raises:
53
+ ValueError: If the environment variable holds an unrecognised value.
54
+ """
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'
93
+ """
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)