Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 3 additions & 12 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,7 @@ repos:
# Run the formatter.
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.19.1
- repo: https://github.com/astral-sh/ty-pre-commit
rev: v0.0.71
hooks:
- id: mypy
args: ["--config=pyproject.toml"]
additional_dependencies:
[
dask,
"numpy>=2.0.0,<3.0.0",
pandas,
xarray>=2024.03.0,
types-python-dateutil,
]
- id: ty
2 changes: 1 addition & 1 deletion conda-env/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ dependencies:
# NOTE: If the tools below are updated, also update their 'rev' in `.pre-commit.config.yaml`
- pre-commit=4.1.0
- ruff=0.14.10
- mypy=1.19.1
- ty=0.0.71
# Testing
# ==================
- pytest
Expand Down
20 changes: 11 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ docs = [
"ipython",
"gsw-xarray",
]
dev = ["types-python-dateutil", "pre-commit", "ruff", "mypy"]
dev = ["types-python-dateutil", "pre-commit", "ruff", "ty"]

[tool.setuptools.packages.find]
include = ["xcdat", "xcdat.*"]
Expand Down Expand Up @@ -125,11 +125,13 @@ python_files = ["tests.py", "test_*.py"]
# These markers are defined in `xarray.tests` and must be included to avoid warnings when importing from this module.
markers = ["flaky", "network"]

[tool.mypy]
# Docs: https://mypy.readthedocs.io/en/stable/config_file.html
python_version = "3.13"
check_untyped_defs = true
ignore_missing_imports = true
warn_unused_ignores = true
warn_redundant_casts = true
warn_unused_configs = true
[tool.ty.environment]
python-version = "3.13"

[tool.ty.src]
exclude = ["docs"]

[tool.ty.rules]
unresolved-import = "ignore"
unused-ignore-comment = "error"
redundant-cast = "error"
5 changes: 3 additions & 2 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""This module stores reusable test fixtures."""

from collections.abc import Hashable
from typing import Literal

import cftime
Expand Down Expand Up @@ -457,10 +458,10 @@ def generate_multiple_variable_dataset(
for idx in range(copies):
ds_copy = ds_base.copy(deep=True)

var_names = list(["ts"])
var_names: list[Hashable] = ["ts"]

if separate_dims:
var_names += list(ds_base.sizes.keys()) # type: ignore[arg-type]
var_names += list(ds_base.sizes.keys())

ds_copy = ds_copy.rename({x: f"{x}{idx + 1}" for x in var_names})

Expand Down
2 changes: 1 addition & 1 deletion tests/test_axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def test_raises_error_if_dim_does_not_exist(self):

for dim in dims:
with pytest.raises(KeyError):
get_dim_coords(ds, dim) # type: ignore
get_dim_coords(ds, dim)

def test_raises_error_if_axis_or_standard_name_is_not_set_or_dim_name_is_not_valid(
self,
Expand Down
15 changes: 15 additions & 0 deletions tests/test_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -1729,3 +1729,18 @@ def test_bounds_always_persist(self):
assert ds.get("lat_bnds") is not None
assert ds.get("lon_bnds") is not None
assert ds.get("time_bnds") is not None

def test_drops_coords_and_dimensions_used_only_by_unselected_var(self):
ds = xr.Dataset(
data_vars={
"selected": ("time", [1, 2]),
"unselected": ("level", [3, 4]),
},
coords={"time": [0, 1], "level": [1000, 850]},
)

result = _keep_single_var(ds, key="selected")

assert list(result.data_vars) == ["selected"]
assert "level" not in result.coords
assert "level" not in result.dims
69 changes: 68 additions & 1 deletion tests/test_regrid.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime
import re
import sys
from typing import cast
from unittest import mock

import numpy as np
Expand Down Expand Up @@ -387,6 +388,50 @@ def test_missing_output_z_coord(self):
):
regridder.vertical("so", ds)

def test_multiple_output_z_coords(self):
output_grid = self.output_grid.assign_coords(
ilev=xr.DataArray(
self.output_grid.lev.data,
dims="ilev",
attrs=self.output_grid.lev.attrs.copy(),
)
)
regridder = xgcm.XGCMRegridder(
self.ds, output_grid, method="linear", target_data=None
)

with pytest.raises(
RuntimeError,
match="Could not determine a single 'Z' coordinate in output dataset",
):
regridder.vertical("so", self.ds)

@mock.patch("xgcm.Grid")
def test_non_string_vertical_coordinate_names(self, grid):
output_coord_z = xr.DataArray(
self.output_grid.lev.data,
dims="output_lev",
name=0,
attrs=self.output_grid.lev.attrs.copy(),
)
get_dim_coords = xgcm.get_dim_coords

with mock.patch("xcdat.regridder.xgcm.get_dim_coords") as get_dim_coords_mock:
get_dim_coords_mock.side_effect = lambda obj, axis: (
output_coord_z if obj is self.output_grid else get_dim_coords(obj, axis)
)
grid.return_value.transform.return_value = self.ds.so.rename(
{"lev": "output_lev"}
)
regridder = xgcm.XGCMRegridder(
self.ds, self.output_grid, method="linear", target_data=None
)

with pytest.raises(
RuntimeError, match="Vertical coordinate names must be strings"
):
regridder.vertical("so", self.ds)

def test_missing_input_z_bounds(self):
ds = fixtures.generate_lev_dataset()

Expand Down Expand Up @@ -759,6 +804,24 @@ def test_regrid_fine_coarse_2d(self):

assert np.all(output_data.ts == 1)

@pytest.mark.parametrize(("axis", "coord"), (("X", "lon"), ("Y", "lat")))
def test_multiple_output_dimension_coords(self, axis, coord):
output_grid = self.fine_2d_ds.assign_coords(
{
f"{coord}_alternate": xr.DataArray(
self.fine_2d_ds[coord].data,
dims=f"{coord}_alternate",
attrs=self.fine_2d_ds[coord].attrs.copy(),
)
}
)
regridder = regrid2.Regrid2Regridder(self.coarse_2d_ds, output_grid)

with pytest.raises(
ValueError, match=f"Multiple dimension coordinates found for {axis!r} axis"
):
regridder.horizontal("ts", self.coarse_2d_ds)

def test_regrid_3d(self):
regridder = regrid2.Regrid2Regridder(self.coarse_3d_ds, self.fine_2d_ds)

Expand Down Expand Up @@ -1242,7 +1305,11 @@ def test_create_grid_wrong_axis_value(self):
"Argument 'x' should be an xr.DataArray representing coordinates or a tuple (xr.DataArray, xr.DataArray) representing coordinates and bounds."
),
):
grid.create_grid(x=(self.lon, self.lon_bnds, self.lat)) # type: ignore[arg-type]
invalid_x = cast(
tuple[xr.DataArray, xr.DataArray | None],
(self.lon, self.lon_bnds, self.lat),
)
grid.create_grid(x=invalid_x)

def test_uniform_grid(self):
new_grid = grid.create_uniform_grid(-90, 90, 4.0, -180, 180, 5.0)
Expand Down
26 changes: 26 additions & 0 deletions tests/test_tutorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ def test_open_dataset_no_cache(self, mock_open_dataset, mock_retrieve):
)
assert not os.path.exists(mock_retrieve.return_value)

@patch("pooch.retrieve")
@patch("pooch.HTTPDownloader")
@patch("xcdat.dataset.open_dataset")
def test_retrieve_downloader_adapter(
self, mock_open_dataset, mock_http_downloader, mock_retrieve
):
mock_open_dataset.return_value = xr.Dataset()
mock_downloader = mock_http_downloader.return_value
mock_downloader.return_value = str(self.cache_dir / "test.nc")

def retrieve(*, downloader, **_):
return downloader(
"https://example.com/test.nc", None, None, check_only=None
)

mock_retrieve.side_effect = retrieve

open_dataset("tas_amon_access", cache_dir=self.cache_dir)

mock_downloader.assert_called_once_with(
url="https://example.com/test.nc",
output_file=None,
pooch=None,
check_only=False,
)

def test_raises_error_with_invalid_name(self):
with pytest.raises(ValueError):
open_dataset("invalid_name", cache_dir=self.cache_dir)
Expand Down
3 changes: 2 additions & 1 deletion xcdat/axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,8 @@ def _align_lon_to_360(
ds_lon[var.name] = new_var

# Create a new dataset of non-longitude vars and updated longitude vars.
ds_no_lon = ds.get([v for v in ds.data_vars if dim not in ds[v].dims]) # type: ignore
ds_no_lon = ds.get([v for v in ds.data_vars if dim not in ds[v].dims])
assert isinstance(ds_no_lon, xr.Dataset)
ds_final = xr.merge((ds_no_lon, ds_lon))

return ds_final
Expand Down
21 changes: 13 additions & 8 deletions xcdat/bounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import collections
import datetime
import warnings
from collections.abc import Callable
from typing import Literal

import cf_xarray as cfxr # noqa: F401
Expand All @@ -24,6 +25,10 @@

logger = _setup_custom_logger(__name__)

TimeSteps = np.ndarray | pd.DatetimeIndex
TimeBound = cftime.datetime | pd.Timestamp
TimeBoundFactory = Callable[..., TimeBound]


@xr.register_dataset_accessor("bounds")
class BoundsAccessor:
Expand Down Expand Up @@ -620,7 +625,7 @@ def _create_time_bounds( # noqa: C901
# pandas time/date components which simplifies creating bounds.
# https://pandas.pydata.org/docs/user_guide/timeseries.html#time-date-components
timesteps = pd.to_datetime(timesteps)
obj_type = pd.Timestamp
obj_type: TimeBoundFactory = pd.Timestamp
elif _get_datetime_like_type(time) == cftime.datetime:
calendar = time.encoding["calendar"]
obj_type = get_date_type(calendar)
Expand Down Expand Up @@ -666,8 +671,8 @@ def _create_time_bounds( # noqa: C901

def _create_yearly_time_bounds(
self,
timesteps: np.ndarray,
obj_type: cftime.datetime | pd.Timestamp,
timesteps: TimeSteps,
obj_type: TimeBoundFactory,
) -> list[cftime.datetime | pd.Timestamp]:
"""Creates time bounds for each timestep with the start and end of the year.

Expand Down Expand Up @@ -703,8 +708,8 @@ def _create_yearly_time_bounds(

def _create_monthly_time_bounds(
self,
timesteps: np.ndarray,
obj_type: cftime.datetime | pd.Timestamp,
timesteps: TimeSteps,
obj_type: TimeBoundFactory,
end_of_month: bool = False,
) -> list[cftime.datetime | pd.Timestamp]:
"""Creates time bounds for each timestep with the start and end of the month.
Expand Down Expand Up @@ -758,7 +763,7 @@ def _create_monthly_time_bounds(
def _add_months_to_timestep(
self,
timestep: cftime.datetime | pd.Timestamp,
obj_type: cftime.datetime | pd.Timestamp,
obj_type: TimeBoundFactory,
delta: int,
) -> cftime.datetime | pd.Timestamp:
"""Adds delta month(s) to a timestep.
Expand Down Expand Up @@ -807,8 +812,8 @@ def _add_months_to_timestep(

def _create_daily_time_bounds(
self,
timesteps: np.ndarray,
obj_type: cftime.datetime | pd.Timestamp,
timesteps: TimeSteps,
obj_type: TimeBoundFactory,
freq: Literal[1, 2, 3, 4, 6, 8, 12, 24] = 1,
) -> list[cftime.datetime | pd.Timestamp]:
"""Creates time bounds for each timestep with the start and end of the day.
Expand Down
4 changes: 2 additions & 2 deletions xcdat/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from datetime import datetime
from functools import partial
from io import BufferedIOBase
from typing import Any, Literal
from typing import Any, Literal, cast

import numpy as np
import xarray as xr
Expand Down Expand Up @@ -769,7 +769,7 @@ def _keep_single_var(dataset: xr.Dataset, key: str) -> xr.Dataset:
if key in bounds_vars:
raise ValueError("Please specify a non-bounds data variable.")

return dataset[[key] + bounds_vars]
return cast(xr.Dataset, dataset[[key] + bounds_vars])


def _get_data_var(dataset: xr.Dataset, key: str) -> xr.DataArray:
Expand Down
14 changes: 7 additions & 7 deletions xcdat/mask.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ def generate_land_sea_mask(
if method == "regionmask":
land_mask = regionmask.defined_regions.natural_earth_v5_0_0.land_110

lon, lat = get_dim_coords(da, "X"), get_dim_coords(da, "Y")
lon = _as_dataarray(get_dim_coords(da, "X"))
lat = _as_dataarray(get_dim_coords(da, "Y"))

land_sea_mask = land_mask.mask(lon, lat=lat)

Expand Down Expand Up @@ -261,11 +262,6 @@ def pcmdi_land_sea_mask(
>>> from xcdat._data import _get_pcmdi_mask_path
>>> path = _get_pcmdi_mask_path()
"""
if source is not None and source_data_var is None:
raise ValueError(
"The 'source_data_var' value cannot be None when using the 'source' option."
)

if source is None:
source_data_var = "sftlf"

Expand All @@ -274,6 +270,10 @@ def pcmdi_land_sea_mask(
# Turn off time decoding to prevent logger warning since this dataset
# does not have a time axis.
source = open_dataset(resource_path, decode_times=False)
elif source_data_var is None:
raise ValueError(
"The 'source_data_var' value cannot be None when using the 'source' option."
)

source_regrid = source.regridder.horizontal(
source_data_var, _obj_to_grid_ds(da), tool="regrid2"
Expand Down Expand Up @@ -302,7 +302,7 @@ def pcmdi_land_sea_mask(
improved_mask = _improve_mask(
mask.copy(deep=True),
source_regrid,
source_data_var, # type: ignore[arg-type]
source_data_var,
surrounds,
is_circular,
threshold1,
Expand Down
Loading
Loading