From 2629b2ec6703e6f31c3df58753ad3f54c53f769f Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Mon, 4 Dec 2023 22:04:47 -0700 Subject: [PATCH 1/4] repo-review 2 --- cf_xarray/accessor.py | 86 ++++++++++++++++---------------- cf_xarray/criteria.py | 2 +- cf_xarray/formatting.py | 22 +++----- cf_xarray/options.py | 4 +- cf_xarray/tests/test_accessor.py | 8 +-- cf_xarray/tests/test_helpers.py | 2 +- cf_xarray/units.py | 8 +-- cf_xarray/utils.py | 48 +++++++++++++++++- pyproject.toml | 16 ++++-- 9 files changed, 123 insertions(+), 73 deletions(-) diff --git a/cf_xarray/accessor.py b/cf_xarray/accessor.py index 648f8748..78673d77 100644 --- a/cf_xarray/accessor.py +++ b/cf_xarray/accessor.py @@ -4,13 +4,13 @@ import inspect import itertools import re -import warnings from collections import ChainMap, namedtuple from collections.abc import Hashable, Iterable, Mapping, MutableMapping, Sequence from datetime import datetime from typing import ( Any, Callable, + Literal, TypeVar, Union, cast, @@ -48,6 +48,7 @@ _get_version, _is_datetime_like, always_iterable, + emit_user_level_warning, invert_mappings, parse_cell_methods_attr, parse_cf_standard_name_table, @@ -222,7 +223,7 @@ def _get_custom_criteria( try: from regex import match as regex_match except ImportError: - from re import match as regex_match # type: ignore + from re import match as regex_match # type: ignore[no-redef] if isinstance(obj, DataArray): obj = obj._to_temp_dataset() @@ -358,8 +359,6 @@ def _get_measure(obj: DataArray | Dataset, key: str) -> list[str]: if key in measures: results.update([measures[key]]) - if isinstance(results, str): - return [results] return list(results) @@ -453,7 +452,7 @@ def _get_all(obj: DataArray | Dataset, key: Hashable) -> list[Hashable]: """ all_mappers: tuple[Mapper] = ( _get_custom_criteria, - functools.partial(_get_custom_criteria, criteria=cf_role_criteria), # type: ignore + functools.partial(_get_custom_criteria, criteria=cf_role_criteria), # type: ignore[assignment] functools.partial(_get_custom_criteria, criteria=grid_mapping_var_criteria), _get_axis_coord, _get_measure, @@ -491,7 +490,7 @@ def _get_coords(obj: DataArray | Dataset, key: Hashable) -> list[Hashable]: def _variables(func: F) -> F: @functools.wraps(func) def wrapper(obj: DataArray | Dataset, key: Hashable) -> list[DataArray]: - return [obj[k] for k in func(obj, key)] # type: ignore[misc] + return [obj[k] for k in func(obj, key)] return cast(F, wrapper) @@ -636,10 +635,10 @@ def _getattr( ): raise AttributeError( f"{obj.__class__.__name__+'.cf'!r} object has no attribute {attr!r}" - ) + ) from None raise AttributeError( f"{attr!r} is not a valid attribute on the underlying xarray object." - ) + ) from None if isinstance(attribute, Mapping): if not attribute: @@ -663,7 +662,7 @@ def _getattr( newmap.update(dict.fromkeys(inverted[key], value)) newmap.update({key: attribute[key] for key in unused_keys}) - skip: dict[str, list[Hashable] | None] = { + skip: dict[str, list[Literal["coords", "measures"]] | None] = { "data_vars": ["coords"], "coords": None, } @@ -672,7 +671,7 @@ def _getattr( newmap[key] = _getitem(accessor, key, skip=skip[attr]) return newmap - elif isinstance(attribute, Callable): # type: ignore + elif isinstance(attribute, Callable): # type: ignore[arg-type] func: Callable = attribute else: @@ -704,7 +703,7 @@ def wrapper(*args, **kwargs): def _getitem( accessor: CFAccessor, key: Hashable, - skip: list[Hashable] | None = None, + skip: list[Literal["coords", "measures"]] | None = None, ) -> DataArray: ... @@ -713,15 +712,15 @@ def _getitem( def _getitem( accessor: CFAccessor, key: Iterable[Hashable], - skip: list[Hashable] | None = None, + skip: list[Literal["coords", "measures"]] | None = None, ) -> Dataset: ... def _getitem( - accessor, - key, - skip=None, + accessor: CFAccessor, + key: Hashable | Iterable[Hashable], + skip: list[Literal["coords", "measures"]] | None = None, ): """ Index into obj using key. Attaches CF associated variables. @@ -772,7 +771,7 @@ def check_results(names, key): measures = accessor._get_all_cell_measures() except ValueError: measures = [] - warnings.warn("Ignoring bad cell_measures attribute.", UserWarning) + emit_user_level_warning("Ignoring bad cell_measures attribute.", UserWarning) if isinstance(obj, Dataset): grid_mapping_names = list(accessor.grid_mapping_names) @@ -835,6 +834,7 @@ def check_results(names, key): ) coords.extend(itertools.chain(*extravars.values())) + ds: Dataset if isinstance(obj, DataArray): ds = obj._to_temp_dataset() else: @@ -842,7 +842,7 @@ def check_results(names, key): if scalar_key: if len(allnames) == 1: - da: DataArray = ds.reset_coords()[allnames[0]] # type: ignore + da: DataArray = ds.reset_coords()[allnames[0]] if allnames[0] in coords: coords.remove(allnames[0]) for k1 in coords: @@ -857,18 +857,19 @@ def check_results(names, key): ds = ds.reset_coords()[varnames + coords] if isinstance(obj, DataArray): - if scalar_key and len(ds.variables) == 1: - # single dimension coordinates - assert coords - assert not varnames + if scalar_key: + if len(ds.variables) == 1: + # single dimension coordinates + assert coords + assert not varnames - return ds[coords[0]] + return ds[coords[0]] - elif scalar_key and len(ds.variables) > 1: - raise NotImplementedError( - "Not sure what to return when given scalar key for DataArray and it has multiple values. " - "Please open an issue." - ) + else: + raise NotImplementedError( + "Not sure what to return when given scalar key for DataArray and it has multiple values. " + "Please open an issue." + ) return ds.set_coords(coords) @@ -876,7 +877,7 @@ def check_results(names, key): raise KeyError( f"{kind}.cf does not understand the key {k!r}. " f"Use 'repr({kind}.cf)' (or '{kind}.cf' in a Jupyter environment) to see a list of key names that can be interpreted." - ) + ) from None def _possible_x_y_plot(obj, key, skip=None): @@ -1115,7 +1116,7 @@ def _assert_valid_other_comparison(self, other): ) return flag_dict - def __eq__(self, other) -> DataArray: # type: ignore + def __eq__(self, other) -> DataArray: """ Compare flag values against `other`. @@ -1125,7 +1126,7 @@ def __eq__(self, other) -> DataArray: # type: ignore """ return self._extract_flags([other])[other].rename(self._obj.name) - def __ne__(self, other) -> DataArray: # type: ignore + def __ne__(self, other) -> DataArray: """ Compare flag values against `other`. @@ -1247,7 +1248,7 @@ def curvefit( coords_iter = coords coords = [ apply_mapper( - [_single(_get_coords)], self._obj, v, error=False, default=[v] # type: ignore + [_single(_get_coords)], self._obj, v, error=False, default=[v] # type: ignore[arg-type] )[0] for v in coords_iter ] @@ -1258,7 +1259,7 @@ def curvefit( reduce_dims_iter = list(reduce_dims) reduce_dims = [ apply_mapper( - [_single(_get_dims)], self._obj, v, error=False, default=[v] # type: ignore + [_single(_get_dims)], self._obj, v, error=False, default=[v] # type: ignore[arg-type] )[0] for v in reduce_dims_iter ] @@ -1353,7 +1354,7 @@ def _rewrite_values( # allow multiple return values here. # these are valid for .sel, .isel, .coarsen - all_mappers = ChainMap( # type: ignore + all_mappers = ChainMap( # type: ignore[misc] key_mappers, dict.fromkeys(var_kws, (_get_all,)), ) @@ -1442,7 +1443,7 @@ def describe(self): Print a string repr to screen. """ - warnings.warn( + emit_user_level_warning( "'obj.cf.describe()' will be removed in a future version. " "Use instead 'repr(obj.cf)' or 'obj.cf' in a Jupyter environment.", DeprecationWarning, @@ -1610,10 +1611,9 @@ def cell_measures(self) -> dict[str, list[Hashable]]: bad_vars = list( as_dataset.filter_by_attrs(cell_measures=attr).data_vars.keys() ) - warnings.warn( + emit_user_level_warning( f"Ignoring bad cell_measures attribute: {attr} on {bad_vars}.", UserWarning, - stacklevel=2, ) measures = { key: self._drop_missing_variables(_get_all(self._obj, key)) for key in keys @@ -1730,9 +1730,9 @@ def get_associated_variable_names( except ValueError as e: if error: msg = e.args[0] + " Ignore this error by passing 'error=False'" - raise ValueError(msg) + raise ValueError(msg) from None else: - warnings.warn( + emit_user_level_warning( f"Ignoring bad cell_measures attribute: {attrs_or_encoding['cell_measures']}", UserWarning, ) @@ -1764,7 +1764,7 @@ def get_associated_variable_names( missing = set(allvars) - set(self._maybe_to_dataset()._variables) if missing: if OPTIONS["warn_on_missing_variables"]: - warnings.warn( + emit_user_level_warning( f"Variables {missing!r} not found in object but are referred to in the CF attributes.", UserWarning, ) @@ -1877,7 +1877,7 @@ def get_renamer_and_conflicts(keydict): # Rename and warn if conflicts: - warnings.warn( + emit_user_level_warning( "Conflicting variables skipped:\n" + "\n".join( [ @@ -2585,10 +2585,12 @@ def decode_vertical_coords(self, *, outnames=None, prefix=None): try: zname = outnames[dim] except KeyError: - raise KeyError("Your `outnames` need to include a key of `dim`.") + raise KeyError( + "Your `outnames` need to include a key of `dim`." + ) from None else: - warnings.warn( + emit_user_level_warning( "`prefix` is being deprecated; use `outnames` instead.", DeprecationWarning, ) diff --git a/cf_xarray/criteria.py b/cf_xarray/criteria.py index f6bfc56e..f287e5de 100644 --- a/cf_xarray/criteria.py +++ b/cf_xarray/criteria.py @@ -128,7 +128,7 @@ coordinate_criteria["time"] = coordinate_criteria["T"] # "long_name" and "standard_name" criteria are the same. For convenience. -for coord, attrs in coordinate_criteria.items(): +for coord in coordinate_criteria: coordinate_criteria[coord]["long_name"] = coordinate_criteria[coord][ "standard_name" ] diff --git a/cf_xarray/formatting.py b/cf_xarray/formatting.py index c700924c..c818f161 100644 --- a/cf_xarray/formatting.py +++ b/cf_xarray/formatting.py @@ -10,7 +10,7 @@ try: from rich.table import Table except ImportError: - Table = None # type: ignore + Table = None # type: ignore[assignment, misc] def _format_missing_row(row: str, rich: bool) -> str: @@ -41,7 +41,7 @@ def _format_cf_name(name: str, rich: bool) -> str: def make_text_section( accessor, subtitle: str, - attr: str, + attr: str | dict, dims=None, valid_keys=None, valid_values=None, @@ -140,10 +140,10 @@ def _maybe_panel(textgen, title: str, rich: bool): width=100, ) if isinstance(textgen, Table): - return Panel(textgen, padding=(0, 20), **kwargs) # type: ignore + return Panel(textgen, padding=(0, 20), **kwargs) # type: ignore[arg-type] else: text = "".join(textgen) - return Panel(f"[color(241)]{text.rstrip()}[/color(241)]", **kwargs) # type: ignore + return Panel(f"[color(241)]{text.rstrip()}[/color(241)]", **kwargs) # type: ignore[arg-type] else: text = "".join(textgen) return title + ":\n" + text @@ -220,22 +220,14 @@ def _format_flags(accessor, rich): table.add_column("Value", justify="right") table.add_column("Bits", justify="center") - for val, bit, (key, (mask, value)) in zip( - value_text, bit_text, flag_dict.items() - ): - table.add_row( - _format_cf_name(key, rich), - val, - bit, - ) + for val, bit, key in zip(value_text, bit_text, flag_dict): + table.add_row(_format_cf_name(key, rich), val, bit) return table else: rows = [] - for val, bit, (key, (mask, value)) in zip( - value_text, bit_text, flag_dict.items() - ): + for val, bit, key in zip(value_text, bit_text, flag_dict): rows.append(f"{TAB}{_format_cf_name(key, rich)}: {TAB} {val} {bit}") return _print_rows("Flag Meanings", rows, rich) diff --git a/cf_xarray/options.py b/cf_xarray/options.py index 75749a10..0aee9747 100644 --- a/cf_xarray/options.py +++ b/cf_xarray/options.py @@ -47,7 +47,7 @@ class set_options: def __init__(self, **kwargs): self.old = {} - for k, v in kwargs.items(): + for k in kwargs: if k not in OPTIONS: raise ValueError( f"argument name {k!r} is not in the set of valid options {set(OPTIONS)!r}" @@ -57,7 +57,7 @@ def __init__(self, **kwargs): def _apply_update(self, options_dict): options_dict = copy.deepcopy(options_dict) - for k, v in options_dict.items(): + for k in options_dict: if k == "custom_criteria": options_dict["custom_criteria"] = always_iterable( options_dict["custom_criteria"], allowed=(tuple, list) diff --git a/cf_xarray/tests/test_accessor.py b/cf_xarray/tests/test_accessor.py index 85ac2787..ea3dfbc6 100644 --- a/cf_xarray/tests/test_accessor.py +++ b/cf_xarray/tests/test_accessor.py @@ -846,7 +846,7 @@ def test_add_bounds_nd_variable() -> None: # 2D expected = ( - vertices_to_bounds( # type: ignore + vertices_to_bounds( xr.DataArray( np.arange(0, 13, 3).reshape(5, 1) + np.arange(-2, 2).reshape(1, 4), dims=("x", "y"), @@ -1090,7 +1090,7 @@ def test_docstring() -> None: assert "present in .indexes" in airds.cf.resample.__doc__ # Make sure docs are up to date - get_all_doc: str = cf_xarray.accessor._get_all.__doc__ # type: ignore + get_all_doc: str = cf_xarray.accessor._get_all.__doc__ # type: ignore[assignment] all_keys = ( cf_xarray.accessor._AXIS_NAMES + cf_xarray.accessor._COORD_NAMES @@ -1489,7 +1489,7 @@ def test_new_standard_name_mappers() -> None: ) assert_identical(forecast.cf.chunk({"realization": 1}), forecast.chunk({"M": 1})) assert_identical(forecast.cf.isel({"realization": 1}), forecast.isel({"M": 1})) - assert_identical(forecast.cf.isel(**{"realization": 1}), forecast.isel(**{"M": 1})) # type: ignore + assert_identical(forecast.cf.isel(**{"realization": 1}), forecast.isel(**{"M": 1})) assert_identical( forecast.cf.groupby("forecast_reference_time.month").mean(), forecast.groupby("S.month").mean(), @@ -1770,7 +1770,7 @@ def test_add_canonical_attributes(override, skip, verbose, capsys): # Catch print captured = capsys.readouterr() if not verbose: - captured.out == "" + assert captured.out == "" # Attributes have been added for var in sum(ds.cf.standard_names.values(), []): diff --git a/cf_xarray/tests/test_helpers.py b/cf_xarray/tests/test_helpers.py index 96d46c2e..1a26b1bc 100644 --- a/cf_xarray/tests/test_helpers.py +++ b/cf_xarray/tests/test_helpers.py @@ -8,7 +8,7 @@ try: from dask.array import Array as DaskArray except ImportError: - DaskArray = None # type: ignore + DaskArray = None # type: ignore[assignment, misc] def test_bounds_to_vertices() -> None: diff --git a/cf_xarray/units.py b/cf_xarray/units.py index ca57d569..acd48fbe 100644 --- a/cf_xarray/units.py +++ b/cf_xarray/units.py @@ -1,7 +1,6 @@ """Module to provide unit support via pint approximating UDUNITS/CF.""" import functools import re -import warnings import pint from pint import ( # noqa: F401 @@ -10,6 +9,8 @@ UnitStrippedWarning, ) +from .utils import emit_user_level_warning + # from `xclim`'s unit support module with permission of the maintainers try: @@ -111,9 +112,10 @@ def repl(m): try: units.setup_matplotlib() except ImportError: - warnings.warn( + emit_user_level_warning( "Import(s) unavailable to set up matplotlib support...skipping this portion " - "of the setup." + "of the setup.", + UserWarning, ) # end of vendored code from MetPy diff --git a/cf_xarray/utils.py b/cf_xarray/utils.py index df5bb531..5644774c 100644 --- a/cf_xarray/utils.py +++ b/cf_xarray/utils.py @@ -1,3 +1,6 @@ +import inspect +import os +import warnings from collections import defaultdict from collections.abc import Iterable from typing import Any @@ -94,7 +97,7 @@ def parse_cf_standard_name_table(source=None): # Build dictionaries info = {} - table: dict = {} + table = {} aliases = {} for child in root: if child.tag == "entry": @@ -121,3 +124,46 @@ def _get_version(): except ImportError: pass return __version__ + + +def find_stack_level(test_mode=False) -> int: + """Find the first place in the stack that is not inside xarray. + + This is unless the code emanates from a test, in which case we would prefer + to see the xarray source. + + This function is taken from pandas. + + Parameters + ---------- + test_mode : bool + Flag used for testing purposes to switch off the detection of test + directories in the stack trace. + + Returns + ------- + stacklevel : int + First level in the stack that is not part of xarray. + """ + import cf_xarray as cfxr + + pkg_dir = os.path.dirname(cfxr.__file__) + test_dir = os.path.join(pkg_dir, "tests") + + # https://stackoverflow.com/questions/17407119/python-inspect-stack-is-slow + frame = inspect.currentframe() + n = 0 + while frame: + fname = inspect.getfile(frame) + if fname.startswith(pkg_dir) and (not fname.startswith(test_dir) or test_mode): + frame = frame.f_back + n += 1 + else: + break + return n + + +def emit_user_level_warning(message, category=None): + """Emit a warning at the user level by inspecting the stack trace.""" + stacklevel = find_stack_level() + warnings.warn(message, category=category, stacklevel=stacklevel) diff --git a/pyproject.toml b/pyproject.toml index f9c9150a..0bfcb13c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,13 +65,14 @@ exclude = [ "doc", ] # E402: module level import not at top of file -# E501: line too long - let black worry about that -# E731: do not assign a lambda expression, use a def +# E501: line too long - let ruff worry about that ignore = [ "E402", "E501", - "E731", + "B018", + "B015", ] +[tool.ruff.lint] select = [ # Pyflakes "F", @@ -83,8 +84,11 @@ select = [ # Pyupgrade "UP", ] +extend-select = [ + "B", # flake8-bugbear +] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["cf_xarray"] known-third-party = [ "dask", @@ -99,6 +103,7 @@ known-third-party = [ "xarray" ] + [tool.pytest] python_files = "test_*.py" testpaths = ["cf_xarray/tests"] @@ -123,6 +128,9 @@ exclude = "doc|flycheck" files = "cf_xarray/" show_error_codes = true warn_unused_ignores = true +warn_unreachable = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] + [[tool.mypy.overrides]] module=[ From bb2b68f2bc9593b90ac7b2e7060d8f1141eb392b Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Mon, 4 Dec 2023 22:32:43 -0700 Subject: [PATCH 2/4] fix --- cf_xarray/formatting.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cf_xarray/formatting.py b/cf_xarray/formatting.py index c818f161..65b21495 100644 --- a/cf_xarray/formatting.py +++ b/cf_xarray/formatting.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import warnings from collections.abc import Hashable, Iterable from functools import partial From 0e382f71d0eec610241e57628cea43ac201f4d9d Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Mon, 4 Dec 2023 22:39:59 -0700 Subject: [PATCH 3/4] fixes --- cf_xarray/accessor.py | 4 ++-- cf_xarray/criteria.py | 2 +- cf_xarray/tests/test_accessor.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cf_xarray/accessor.py b/cf_xarray/accessor.py index 78673d77..7d249871 100644 --- a/cf_xarray/accessor.py +++ b/cf_xarray/accessor.py @@ -108,7 +108,7 @@ def apply_mapper( """ if not isinstance(key, Hashable): - if default is None: + if default is None: # type: ignore[unreachable] raise ValueError( "`default` must be provided when `key` is not not a valid DataArray name (of hashable type)." ) @@ -858,7 +858,7 @@ def check_results(names, key): ds = ds.reset_coords()[varnames + coords] if isinstance(obj, DataArray): if scalar_key: - if len(ds.variables) == 1: + if len(ds.variables) == 1: # type: ignore[unreachable] # single dimension coordinates assert coords assert not varnames diff --git a/cf_xarray/criteria.py b/cf_xarray/criteria.py index f287e5de..76299520 100644 --- a/cf_xarray/criteria.py +++ b/cf_xarray/criteria.py @@ -7,7 +7,7 @@ try: import regex as re except ImportError: - import re # type: ignore + import re # type: ignore[no-redef] from collections.abc import Mapping, MutableMapping from typing import Any diff --git a/cf_xarray/tests/test_accessor.py b/cf_xarray/tests/test_accessor.py index ea3dfbc6..05cdeb2f 100644 --- a/cf_xarray/tests/test_accessor.py +++ b/cf_xarray/tests/test_accessor.py @@ -1489,7 +1489,7 @@ def test_new_standard_name_mappers() -> None: ) assert_identical(forecast.cf.chunk({"realization": 1}), forecast.chunk({"M": 1})) assert_identical(forecast.cf.isel({"realization": 1}), forecast.isel({"M": 1})) - assert_identical(forecast.cf.isel(**{"realization": 1}), forecast.isel(**{"M": 1})) + assert_identical(forecast.cf.isel(realization=1), forecast.isel(M=1)) assert_identical( forecast.cf.groupby("forecast_reference_time.month").mean(), forecast.groupby("S.month").mean(), From ac1e5b8fb13793fb0e2f581ecc9d8221c4d0f5a8 Mon Sep 17 00:00:00 2001 From: Deepak Cherian Date: Mon, 4 Dec 2023 22:43:30 -0700 Subject: [PATCH 4/4] more fix --- cf_xarray/accessor.py | 6 +++--- cf_xarray/tests/test_accessor.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cf_xarray/accessor.py b/cf_xarray/accessor.py index 7d249871..d5af3e76 100644 --- a/cf_xarray/accessor.py +++ b/cf_xarray/accessor.py @@ -490,7 +490,7 @@ def _get_coords(obj: DataArray | Dataset, key: Hashable) -> list[Hashable]: def _variables(func: F) -> F: @functools.wraps(func) def wrapper(obj: DataArray | Dataset, key: Hashable) -> list[DataArray]: - return [obj[k] for k in func(obj, key)] + return [obj[k] for k in func(obj, key)] # type: ignore[misc] return cast(F, wrapper) @@ -1116,7 +1116,7 @@ def _assert_valid_other_comparison(self, other): ) return flag_dict - def __eq__(self, other) -> DataArray: + def __eq__(self, other) -> DataArray: # type: ignore[override] """ Compare flag values against `other`. @@ -1126,7 +1126,7 @@ def __eq__(self, other) -> DataArray: """ return self._extract_flags([other])[other].rename(self._obj.name) - def __ne__(self, other) -> DataArray: + def __ne__(self, other) -> DataArray: # type: ignore[override] """ Compare flag values against `other`. diff --git a/cf_xarray/tests/test_accessor.py b/cf_xarray/tests/test_accessor.py index 05cdeb2f..5063cb62 100644 --- a/cf_xarray/tests/test_accessor.py +++ b/cf_xarray/tests/test_accessor.py @@ -846,7 +846,7 @@ def test_add_bounds_nd_variable() -> None: # 2D expected = ( - vertices_to_bounds( + vertices_to_bounds( # type: ignore[misc] xr.DataArray( np.arange(0, 13, 3).reshape(5, 1) + np.arange(-2, 2).reshape(1, 4), dims=("x", "y"),