Skip to content

Commit 0caf096

Browse files
authored
More precisely type pipe methods (#10038)
* Upgrade mypy to 1.15 Mypy 1.15 includes fix for <python/mypy#9031>, allowing several "type: ignore" comments to be removed. * Add type annotations to DataTree.pipe tests * More precisely type `pipe` methods. In addition, enhance mypy job configuration to support running it locally via `act`. Fixes #9997 * Pin mypy to 1.15 in CI * Revert mypy CI job changes * Add pytest-mypy-plugin and typestub packages * Add pytest-mypy-plugins to all conda env files * Remove dup pandas-stubs dep * Revert pre-commit config changes * Place mypy tests behind pytest mypy marker * Set default pytest numprocesses to 4 * Ignore pytest-mypy-plugins for min version check
1 parent 4bbab48 commit 0caf096

18 files changed

+788
-41
lines changed

.github/workflows/ci.yaml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ jobs:
6767
- env: "flaky"
6868
python-version: "3.13"
6969
os: ubuntu-latest
70+
# The mypy tests must be executed using only 1 process in order to guarantee
71+
# predictable mypy output messages for comparison to expectations.
72+
- env: "mypy"
73+
python-version: "3.10"
74+
numprocesses: 1
75+
os: ubuntu-latest
76+
- env: "mypy"
77+
python-version: "3.13"
78+
numprocesses: 1
79+
os: ubuntu-latest
7080
steps:
7181
- uses: actions/checkout@v4
7282
with:
@@ -88,6 +98,10 @@ jobs:
8898
then
8999
echo "CONDA_ENV_FILE=ci/requirements/environment.yml" >> $GITHUB_ENV
90100
echo "PYTEST_ADDOPTS=-m 'flaky or network' --run-flaky --run-network-tests -W default" >> $GITHUB_ENV
101+
elif [[ "${{ matrix.env }}" == "mypy" ]] ;
102+
then
103+
echo "CONDA_ENV_FILE=ci/requirements/environment.yml" >> $GITHUB_ENV
104+
echo "PYTEST_ADDOPTS=-n 1 -m 'mypy' --run-mypy -W default" >> $GITHUB_ENV
91105
else
92106
echo "CONDA_ENV_FILE=ci/requirements/${{ matrix.env }}.yml" >> $GITHUB_ENV
93107
fi
@@ -144,7 +158,7 @@ jobs:
144158
save-always: true
145159

146160
- name: Run tests
147-
run: python -m pytest -n 4
161+
run: python -m pytest -n ${{ matrix.numprocesses || 4 }}
148162
--timeout 180
149163
--cov=xarray
150164
--cov-report=xml

ci/minimum_versions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
"pytest",
2727
"pytest-cov",
2828
"pytest-env",
29-
"pytest-xdist",
29+
"pytest-mypy-plugins",
3030
"pytest-timeout",
31+
"pytest-xdist",
3132
"hypothesis",
3233
]
3334

ci/requirements/all-but-dask.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ dependencies:
3030
- pytest
3131
- pytest-cov
3232
- pytest-env
33-
- pytest-xdist
33+
- pytest-mypy-plugins
3434
- pytest-timeout
35+
- pytest-xdist
3536
- rasterio
3637
- scipy
3738
- seaborn

ci/requirements/all-but-numba.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@ dependencies:
4343
- pytest
4444
- pytest-cov
4545
- pytest-env
46-
- pytest-xdist
46+
- pytest-mypy-plugins
4747
- pytest-timeout
48+
- pytest-xdist
4849
- rasterio
4950
- scipy
5051
- seaborn

ci/requirements/bare-minimum.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ dependencies:
99
- pytest
1010
- pytest-cov
1111
- pytest-env
12-
- pytest-xdist
12+
- pytest-mypy-plugins
1313
- pytest-timeout
14+
- pytest-xdist
1415
- numpy=1.24
1516
- packaging=23.1
1617
- pandas=2.1

ci/requirements/environment-3.14.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies:
2929
- opt_einsum
3030
- packaging
3131
- pandas
32+
- pandas-stubs
3233
# - pint>=0.22
3334
- pip
3435
- pooch
@@ -38,14 +39,25 @@ dependencies:
3839
- pytest
3940
- pytest-cov
4041
- pytest-env
41-
- pytest-xdist
42+
- pytest-mypy-plugins
4243
- pytest-timeout
44+
- pytest-xdist
4345
- rasterio
4446
- scipy
4547
- seaborn
4648
# - sparse
4749
- toolz
50+
- types-colorama
51+
- types-docutils
52+
- types-psutil
53+
- types-Pygments
54+
- types-python-dateutil
55+
- types-pytz
56+
- types-PyYAML
57+
- types-setuptools
4858
- typing_extensions
4959
- zarr
5060
- pip:
5161
- jax # no way to get cpu-only jaxlib from conda if gpu is present
62+
- types-defusedxml
63+
- types-pexpect

ci/requirements/environment-windows-3.14.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies:
2525
- numpy
2626
- packaging
2727
- pandas
28+
- pandas-stubs
2829
# - pint>=0.22
2930
- pip
3031
- pre-commit
@@ -33,12 +34,24 @@ dependencies:
3334
- pytest
3435
- pytest-cov
3536
- pytest-env
36-
- pytest-xdist
37+
- pytest-mypy-plugins
3738
- pytest-timeout
39+
- pytest-xdist
3840
- rasterio
3941
- scipy
4042
- seaborn
4143
# - sparse
4244
- toolz
45+
- types-colorama
46+
- types-docutils
47+
- types-psutil
48+
- types-Pygments
49+
- types-python-dateutil
50+
- types-pytz
51+
- types-PyYAML
52+
- types-setuptools
4353
- typing_extensions
4454
- zarr
55+
- pip:
56+
- types-defusedxml
57+
- types-pexpect

ci/requirements/environment-windows.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies:
2525
- numpy
2626
- packaging
2727
- pandas
28+
- pandas-stubs
2829
# - pint>=0.22
2930
- pip
3031
- pre-commit
@@ -33,12 +34,24 @@ dependencies:
3334
- pytest
3435
- pytest-cov
3536
- pytest-env
36-
- pytest-xdist
37+
- pytest-mypy-plugins
3738
- pytest-timeout
39+
- pytest-xdist
3840
- rasterio
3941
- scipy
4042
- seaborn
4143
- sparse
4244
- toolz
45+
- types-colorama
46+
- types-docutils
47+
- types-psutil
48+
- types-Pygments
49+
- types-python-dateutil
50+
- types-pytz
51+
- types-PyYAML
52+
- types-setuptools
4353
- typing_extensions
4454
- zarr
55+
- pip:
56+
- types-defusedxml
57+
- types-pexpect

ci/requirements/environment.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies:
2929
- opt_einsum
3030
- packaging
3131
- pandas
32+
- pandas-stubs
3233
# - pint>=0.22
3334
- pip
3435
- pooch
@@ -39,14 +40,25 @@ dependencies:
3940
- pytest
4041
- pytest-cov
4142
- pytest-env
42-
- pytest-xdist
43+
- pytest-mypy-plugins
4344
- pytest-timeout
45+
- pytest-xdist
4446
- rasterio
4547
- scipy
4648
- seaborn
4749
- sparse
4850
- toolz
51+
- types-colorama
52+
- types-docutils
53+
- types-psutil
54+
- types-Pygments
55+
- types-python-dateutil
56+
- types-pytz
57+
- types-PyYAML
58+
- types-setuptools
4959
- typing_extensions
5060
- zarr
5161
- pip:
5262
- jax # no way to get cpu-only jaxlib from conda if gpu is present
63+
- types-defusedxml
64+
- types-pexpect

ci/requirements/min-all-deps.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ dependencies:
4646
- pytest
4747
- pytest-cov
4848
- pytest-env
49-
- pytest-xdist
49+
- pytest-mypy-plugins
5050
- pytest-timeout
51+
- pytest-xdist
5152
- rasterio=1.3
5253
- scipy=1.11
5354
- seaborn=0.13

conftest.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import pytest
44

55

6-
def pytest_addoption(parser):
6+
def pytest_addoption(parser: pytest.Parser):
77
"""Add command-line flags for pytest."""
88
parser.addoption("--run-flaky", action="store_true", help="runs flaky tests")
99
parser.addoption(
1010
"--run-network-tests",
1111
action="store_true",
1212
help="runs tests requiring a network connection",
1313
)
14+
parser.addoption("--run-mypy", action="store_true", help="runs mypy tests")
1415

1516

1617
def pytest_runtest_setup(item):
@@ -21,6 +22,21 @@ def pytest_runtest_setup(item):
2122
pytest.skip(
2223
"set --run-network-tests to run test requiring an internet connection"
2324
)
25+
if "mypy" in item.keywords and not item.config.getoption("--run-mypy"):
26+
pytest.skip("set --run-mypy option to run mypy tests")
27+
28+
29+
# See https://docs.pytest.org/en/stable/example/markers.html#automatically-adding-markers-based-on-test-names
30+
def pytest_collection_modifyitems(items):
31+
for item in items:
32+
if "mypy" in item.nodeid:
33+
# IMPORTANT: mypy type annotation tests leverage the pytest-mypy-plugins
34+
# plugin, and are thus written in test_*.yml files. As such, there are
35+
# no explicit test functions on which we can apply a pytest.mark.mypy
36+
# decorator. Therefore, we mark them via this name-based, automatic
37+
# marking approach, meaning that each test case must contain "mypy" in the
38+
# name.
39+
item.add_marker(pytest.mark.mypy)
2440

2541

2642
@pytest.fixture(autouse=True)

pyproject.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ dev = [
4444
"pytest",
4545
"pytest-cov",
4646
"pytest-env",
47-
"pytest-xdist",
47+
"pytest-mypy-plugins",
4848
"pytest-timeout",
49+
"pytest-xdist",
4950
"ruff>=0.8.0",
5051
"sphinx",
5152
"sphinx_autosummary_accessors",
@@ -304,7 +305,12 @@ known-first-party = ["xarray"]
304305
ban-relative-imports = "all"
305306

306307
[tool.pytest.ini_options]
307-
addopts = ["--strict-config", "--strict-markers"]
308+
addopts = [
309+
"--strict-config",
310+
"--strict-markers",
311+
"--mypy-only-local-stub",
312+
"--mypy-pyproject-toml-file=pyproject.toml",
313+
]
308314

309315
# We want to forbid warnings from within xarray in our tests — instead we should
310316
# fix our own code, or mark the test itself as expecting a warning. So this:
@@ -361,6 +367,7 @@ filterwarnings = [
361367
log_cli_level = "INFO"
362368
markers = [
363369
"flaky: flaky tests",
370+
"mypy: type annotation tests",
364371
"network: tests requiring a network connection",
365372
"slow: slow tests",
366373
"slow_hypothesis: slow hypothesis tests",

xarray/core/common.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from contextlib import suppress
77
from html import escape
88
from textwrap import dedent
9-
from typing import TYPE_CHECKING, Any, TypeVar, Union, overload
9+
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, Union, overload
1010

1111
import numpy as np
1212
import pandas as pd
@@ -60,6 +60,7 @@
6060
T_Resample = TypeVar("T_Resample", bound="Resample")
6161
C = TypeVar("C")
6262
T = TypeVar("T")
63+
P = ParamSpec("P")
6364

6465

6566
class ImplementsArrayReduce:
@@ -718,11 +719,27 @@ def assign_attrs(self, *args: Any, **kwargs: Any) -> Self:
718719
out.attrs.update(*args, **kwargs)
719720
return out
720721

722+
@overload
723+
def pipe(
724+
self,
725+
func: Callable[Concatenate[Self, P], T],
726+
*args: P.args,
727+
**kwargs: P.kwargs,
728+
) -> T: ...
729+
730+
@overload
721731
def pipe(
722732
self,
723-
func: Callable[..., T] | tuple[Callable[..., T], str],
733+
func: tuple[Callable[..., T], str],
724734
*args: Any,
725735
**kwargs: Any,
736+
) -> T: ...
737+
738+
def pipe(
739+
self,
740+
func: Callable[Concatenate[Self, P], T] | tuple[Callable[P, T], str],
741+
*args: P.args,
742+
**kwargs: P.kwargs,
726743
) -> T:
727744
"""
728745
Apply ``func(self, *args, **kwargs)``
@@ -840,15 +857,19 @@ def pipe(
840857
pandas.DataFrame.pipe
841858
"""
842859
if isinstance(func, tuple):
843-
func, target = func
860+
# Use different var when unpacking function from tuple because the type
861+
# signature of the unpacked function differs from the expected type
862+
# signature in the case where only a function is given, rather than a tuple.
863+
# This makes type checkers happy at both call sites below.
864+
f, target = func
844865
if target in kwargs:
845866
raise ValueError(
846867
f"{target} is both the pipe target and a keyword argument"
847868
)
848869
kwargs[target] = self
849-
return func(*args, **kwargs)
850-
else:
851-
return func(self, *args, **kwargs)
870+
return f(*args, **kwargs)
871+
872+
return func(self, *args, **kwargs)
852873

853874
def rolling_exp(
854875
self: T_DataWithCoords,

0 commit comments

Comments
 (0)