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.
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/PKG-INFO +43 -37
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/README.md +41 -35
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/pyproject.toml +4 -2
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/pyproject.toml.orig +6 -3
- xarray_annotated-0.7.0/src/xarray_annotated/__init__.py +60 -0
- xarray_annotated-0.7.0/src/xarray_annotated/_annotations.py +179 -0
- xarray_annotated-0.7.0/src/xarray_annotated/_config.py +100 -0
- xarray_annotated-0.7.0/src/xarray_annotated/_enforce.py +379 -0
- xarray_annotated-0.7.0/src/xarray_annotated/_reader.py +230 -0
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/_writer.py +16 -17
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/schema/__init__.py +5 -23
- xarray_annotated-0.7.0/src/xarray_annotated/schema/_annotations.py +127 -0
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/schema/_check.py +57 -55
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/temporal/__init__.py +5 -24
- xarray_annotated-0.7.0/src/xarray_annotated/temporal/_annotations.py +68 -0
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/temporal/_check.py +154 -99
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/units/__init__.py +3 -23
- xarray_annotated-0.7.0/src/xarray_annotated/units/_annotations.py +57 -0
- xarray_annotated-0.7.0/src/xarray_annotated/units/_check.py +490 -0
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/src/xarray_annotated/units/_registry.py +9 -1
- xarray_annotated-0.6.0/src/xarray_annotated/__init__.py +0 -50
- xarray_annotated-0.6.0/src/xarray_annotated/_annotations.py +0 -126
- xarray_annotated-0.6.0/src/xarray_annotated/_config.py +0 -98
- xarray_annotated-0.6.0/src/xarray_annotated/_reader.py +0 -110
- xarray_annotated-0.6.0/src/xarray_annotated/schema/_annotations.py +0 -254
- xarray_annotated-0.6.0/src/xarray_annotated/schema/_config.py +0 -147
- xarray_annotated-0.6.0/src/xarray_annotated/schema/_decorator.py +0 -125
- xarray_annotated-0.6.0/src/xarray_annotated/temporal/_annotations.py +0 -154
- xarray_annotated-0.6.0/src/xarray_annotated/temporal/_config.py +0 -182
- xarray_annotated-0.6.0/src/xarray_annotated/temporal/_decorator.py +0 -133
- xarray_annotated-0.6.0/src/xarray_annotated/units/_annotations.py +0 -169
- xarray_annotated-0.6.0/src/xarray_annotated/units/_check.py +0 -592
- xarray_annotated-0.6.0/src/xarray_annotated/units/_config.py +0 -237
- xarray_annotated-0.6.0/src/xarray_annotated/units/_decorator.py +0 -204
- {xarray_annotated-0.6.0 → xarray_annotated-0.7.0}/LICENSE +0 -0
- {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.
|
|
4
|
-
Summary: Run-time validation of xarray DataArray
|
|
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
|
[](https://jmarshrossney.github.io/xarray-annotated)
|
|
34
34
|
[](https://github.com/jmarshrossney/xarray-annotated/blob/main/LICENSE)
|
|
35
35
|
|
|
36
|
-
`xarray-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
|
-
|
|
38
|
+
- **structure**: dimensions, coordinates and dtype;
|
|
39
|
+
- **physical units**;
|
|
40
|
+
- **time frequency** and phase.
|
|
39
41
|
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
107
|
-
from xarray_annotated
|
|
105
|
+
import xarray_annotated as xra
|
|
106
|
+
from xarray_annotated import Dims, Unit
|
|
108
107
|
|
|
109
108
|
|
|
110
|
-
@
|
|
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
|
-
|
|
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
|
|
131
|
-
The
|
|
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
|
-
|
|
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
|
-
|
|
153
|
+
And so would one with an extra dimension:
|
|
146
154
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
[](https://jmarshrossney.github.io/xarray-annotated)
|
|
7
7
|
[](https://github.com/jmarshrossney/xarray-annotated/blob/main/LICENSE)
|
|
8
8
|
|
|
9
|
-
`xarray-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
|
-
|
|
11
|
+
- **structure**: dimensions, coordinates and dtype;
|
|
12
|
+
- **physical units**;
|
|
13
|
+
- **time frequency** and phase.
|
|
12
14
|
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
80
|
-
from xarray_annotated
|
|
78
|
+
import xarray_annotated as xra
|
|
79
|
+
from xarray_annotated import Dims, Unit
|
|
81
80
|
|
|
82
81
|
|
|
83
|
-
@
|
|
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
|
-
|
|
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
|
|
104
|
-
The
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
And so would one with an extra dimension:
|
|
119
127
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
8
|
-
description = "Run-time validation of xarray DataArray
|
|
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.
|
|
8
|
-
description = "Run-time validation of xarray DataArray
|
|
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`
|
|
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)
|