Skip to content
Merged
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
38 changes: 24 additions & 14 deletions .github/workflows/ci-additional.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,8 @@ jobs:
min-version-policy:
name: Minimum Version Policy
runs-on: "ubuntu-latest"
needs: detect-ci-trigger
# min-version-policy doesn't work with Pixi yet https://github.com/pydata/xarray/pull/10888#discussion_r2504335457
if: false
# if: needs.detect-ci-trigger.outputs.triggered == 'false'
needs: [detect-ci-trigger, cache-pixi-lock]
if: needs.detect-ci-trigger.outputs.triggered == 'false'
defaults:
run:
shell: bash -l {0}
Expand All @@ -260,18 +258,30 @@ jobs:
with:
fetch-depth: 0 # Fetch all history for all branches and tags.

- uses: actions/setup-python@v6
- name: Restore cached pixi lockfile
uses: actions/cache/restore@v5
id: restore-pixi-lock
with:
python-version: "3.x"
enableCrossOsArchive: true
path: |
pixi.lock
key: ${{ needs.cache-pixi-lock.outputs.cache-id }}

- name: All-deps minimum versions policy
uses: xarray-contrib/minimum-dependency-versions@3db8e1c17328ee1e27dfe4db90d908644856eb61 # v1.0.0
- uses: prefix-dev/setup-pixi@v0.9.3
with:
policy: ci/policy.yaml
environment-paths: ci/requirements/min-all-deps.yml
pixi-version: ${{ env.PIXI_VERSION }}
cache: true
environments: "policy"
cache-write: ${{ github.event_name == 'push' && github.ref_name == 'main' }}

- name: Bare minimum versions policy
uses: xarray-contrib/minimum-dependency-versions@3db8e1c17328ee1e27dfe4db90d908644856eb61 # v1.0.0
with:
policy: ci/policy.yaml
environment-paths: ci/requirements/bare-minimum.yml
run: |
pixi run policy-bare-minimum

- name: Bare minimum and scipy versions policy
run: |
pixi run policy-bare-min-and-scipy

- name: All-deps minimum versions policy
run: |
pixi run policy-min-versions
2 changes: 1 addition & 1 deletion .github/workflows/nightly-wheels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
fi

- name: Upload wheel
uses: scientific-python/upload-nightly-action@b36e8c0c10dbcfd2e05bf95f17ef8c14fd708dbf # 0.6.2
uses: scientific-python/upload-nightly-action@5748273c71e2d8d3a61f3a11a16421c8954f9ecf # 0.6.3
with:
anaconda_nightly_upload_token: ${{ secrets.ANACONDA_NIGHTLY }}
artifacts_path: dist
5 changes: 4 additions & 1 deletion ci/policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ policy:
- pytest-xdist
- pytest-hypothesis
- hypothesis
- pytz
- pytest-reportlog
# these packages don't fail the CI, but will be printed in the report
ignored_violations: []
ignored_violations:
- array-api-strict
50 changes: 42 additions & 8 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ seaborn = "*"
[feature.extras.dependencies]
# array
sparse = "*"
pint = "*"
array-api-strict = "*"

# algorithms
scipy = "*"
Expand All @@ -87,9 +89,10 @@ toolz = "*"
# tutorial
pooch = "*"

# other
# calendar
cftime = "*"
pint = "*"

# other
iris = "*"

[feature.extras.pypi-dependencies]
Expand All @@ -106,11 +109,6 @@ pandas = "2.2.*"
scipy = "1.13.*"

[feature.min-versions.dependencies]
# minimal versions for all dependencies
# Note that when you update min-supported versions, you should:
# - Update the min version lower-bound in the corresponding feature(s) where applicable
# - Update this section to pin to the min version

array-api-strict = "2.4.*" # dependency for testing the array api compat
boto3 = "1.34.*"
bottleneck = "1.4.*"
Expand Down Expand Up @@ -204,7 +202,6 @@ cartopy = "*"
seaborn = "*"

[feature.test.dependencies]
array-api-strict = "*"
pytest = "*"
pytest-asyncio = "*"
pytest-cov = "*"
Expand Down Expand Up @@ -292,6 +289,42 @@ cytoolz = "*"
[feature.release.tasks]
release-contributors = "python ci/release_contributors.py"

[feature.policy.pypi-dependencies]
xarray-minimum-dependency-policy = "*"

[feature.policy.dependencies]
python = "3.13.*"

[feature.policy.tasks.check-policy]
cmd = "minimum-versions validate --policy ci/policy.yaml --manifest-path pixi.toml {{ env }}"
args = ["env"]

[feature.policy.tasks]
policy-bare-minimum = [
{ task = "check-policy", args = [
"pixi:test-py311-bare-minimum",
] },
]
policy-bare-min-and-scipy = [
{ task = "check-policy", args = [
"pixi:test-py311-bare-min-and-scipy",
] },
]
policy-min-versions = [
{ task = "check-policy", args = [
"pixi:test-py311-min-versions",
] },
]
policy = [
{ task = "check-policy", args = [
"""\
pixi:test-py311-bare-minimum \
pixi:test-py311-bare-min-and-scipy \
pixi:test-py311-min-versions \
""",
] },
]

[environments]
# Testing
# test-just-xarray = { features = ["test"] } # https://github.com/pydata/xarray/pull/10888/files#r2511336147
Expand Down Expand Up @@ -392,3 +425,4 @@ doc = { features = [
] }
pre-commit = { features = ["pre-commit"], no-default-feature = true }
release = { features = ["release"], no-default-feature = true }
policy = { features = ["policy"], no-default-feature = true }
135 changes: 135 additions & 0 deletions properties/test_coordinate_transform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Property tests comparing CoordinateTransformIndex to PandasIndex."""

import functools
import operator
from collections.abc import Hashable
from typing import Any

import numpy as np
import pytest

pytest.importorskip("hypothesis")

import hypothesis.strategies as st
from hypothesis import given

import xarray as xr
import xarray.testing.strategies as xrst
from xarray.core.coordinate_transform import CoordinateTransform
from xarray.core.indexes import CoordinateTransformIndex
from xarray.testing import assert_equal

DATA_VAR_NAME = "_test_data_"


class IdentityTransform(CoordinateTransform):
"""Identity transform that returns dimension positions as coordinate labels."""

def forward(self, dim_positions: dict[str, Any]) -> dict[Hashable, Any]:
return dim_positions

def reverse(self, coord_labels: dict[Hashable, Any]) -> dict[str, Any]:
return coord_labels

def equals(
self, other: CoordinateTransform, exclude: frozenset[Hashable] | None = None
) -> bool:
if not isinstance(other, IdentityTransform):
return False
return self.dim_size == other.dim_size


def create_transform_da(sizes: dict[str, int]) -> xr.DataArray:
"""Create a DataArray with IdentityTransform CoordinateTransformIndex."""
dims = list(sizes.keys())
shape = tuple(sizes.values())
data = np.arange(np.prod(shape)).reshape(shape)

# Create dataset with transform index for each dimension
ds = xr.Dataset({DATA_VAR_NAME: (dims, data)})
indexes = [
xr.Coordinates.from_xindex(
CoordinateTransformIndex(
IdentityTransform((dim,), {dim: size}, dtype=np.dtype(np.int64))
)
)
for dim, size in sizes.items()
]
coords = functools.reduce(operator.or_, indexes)
return ds.assign_coords(coords).get(DATA_VAR_NAME)


def create_pandas_da(sizes: dict[str, int]) -> xr.DataArray:
"""Create a DataArray with standard PandasIndex (range index)."""
shape = tuple(sizes.values())
data = np.arange(np.prod(shape)).reshape(shape)
coords = {dim: np.arange(size) for dim, size in sizes.items()}
return xr.DataArray(
data, dims=list(sizes.keys()), coords=coords, name=DATA_VAR_NAME
)


@given(
st.data(),
xrst.dimension_sizes(min_dims=1, max_dims=3, min_side=1, max_side=5),
)
def test_basic_indexing(data, sizes):
"""Test basic indexing produces identical results for transform and pandas index."""
pandas_da = create_pandas_da(sizes)
transform_da = create_transform_da(sizes)
idxr = data.draw(xrst.basic_indexers(sizes=sizes))
pandas_result = pandas_da.isel(idxr)
transform_result = transform_da.isel(idxr)
# TODO: any indexed dim in pandas_result should be an indexed dim in transform_result
# This requires us to return a new CoordinateTransformIndex from .isel.
# for dim in pandas_result.xindexes:
# assert isinstance(transform_result.xindexes[dim], CoordinateTransformIndex)
assert_equal(pandas_result, transform_result)

# not supported today
# pandas_result = pandas_da.sel(idxr)
# transform_result = transform_da.sel(idxr)
# assert_identical(pandas_result, transform_result)


@given(
st.data(),
xrst.dimension_sizes(min_dims=1, max_dims=3, min_side=1, max_side=5),
)
def test_outer_indexing(data, sizes):
"""Test outer indexing produces identical results for transform and pandas index."""
pandas_da = create_pandas_da(sizes)
transform_da = create_transform_da(sizes)
idxr = data.draw(xrst.outer_array_indexers(sizes=sizes, min_dims=1))
pandas_result = pandas_da.isel(idxr)
transform_result = transform_da.isel(idxr)
assert_equal(pandas_result, transform_result)

label_idxr = {
dim: np.arange(pandas_da.sizes[dim])[ind.data] for dim, ind in idxr.items()
}
pandas_result = pandas_da.sel(label_idxr)
transform_result = transform_da.sel(label_idxr, method="nearest")
assert_equal(pandas_result, transform_result)


@given(
st.data(),
xrst.dimension_sizes(min_dims=2, max_dims=3, min_side=1, max_side=5),
)
def test_vectorized_indexing(data, sizes):
"""Test vectorized indexing produces identical results for transform and pandas index."""
pandas_da = create_pandas_da(sizes)
transform_da = create_transform_da(sizes)
idxr = data.draw(xrst.vectorized_indexers(sizes=sizes))
pandas_result = pandas_da.isel(idxr)
transform_result = transform_da.isel(idxr)
assert_equal(pandas_result, transform_result)

label_idxr = {
dim: ind.copy(data=np.arange(pandas_da.sizes[dim])[ind.data])
for dim, ind in idxr.items()
}
pandas_result = pandas_da.sel(label_idxr, method="nearest")
transform_result = transform_da.sel(label_idxr, method="nearest")
assert_equal(pandas_result, transform_result)
66 changes: 66 additions & 0 deletions properties/test_indexing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import pytest

pytest.importorskip("hypothesis")

import hypothesis.strategies as st
from hypothesis import given

import xarray as xr
import xarray.testing.strategies as xrst


def _slice_size(s: slice, dim_size: int) -> int:
"""Compute the size of a slice applied to a dimension."""
return len(range(*s.indices(dim_size)))


@given(
st.data(),
xrst.variables(dims=xrst.dimension_sizes(min_dims=1, max_dims=4, min_side=1)),
)
def test_basic_indexing(data, var):
"""Test that basic indexers produce expected output shape."""
idxr = data.draw(xrst.basic_indexers(sizes=var.sizes))
result = var.isel(idxr)
expected_shape = tuple(
_slice_size(idxr[d], var.sizes[d]) if d in idxr else var.sizes[d]
for d in result.dims
)
assert result.shape == expected_shape


@given(
st.data(),
xrst.variables(dims=xrst.dimension_sizes(min_dims=1, max_dims=4, min_side=1)),
)
def test_outer_indexing(data, var):
"""Test that outer array indexers produce expected output shape."""
idxr = data.draw(xrst.outer_array_indexers(sizes=var.sizes, min_dims=1))
result = var.isel(idxr)
expected_shape = tuple(
len(idxr[d]) if d in idxr else var.sizes[d] for d in result.dims
)
assert result.shape == expected_shape


@given(
st.data(),
xrst.variables(dims=xrst.dimension_sizes(min_dims=2, max_dims=4, min_side=1)),
)
def test_vectorized_indexing(data, var):
"""Test that vectorized indexers produce expected output shape."""
da = xr.DataArray(var)
idxr = data.draw(xrst.vectorized_indexers(sizes=var.sizes))
result = da.isel(idxr)

# TODO: this logic works because the dims in idxr don't overlap with da.dims
# Compute expected shape from result dims
# Non-indexed dims keep their original size, indexed dims get broadcast size
broadcast_result = xr.broadcast(*idxr.values())
broadcast_sizes = dict(
zip(broadcast_result[0].dims, broadcast_result[0].shape, strict=True)
)
expected_shape = tuple(
var.sizes[d] if d in var.sizes else broadcast_sizes[d] for d in result.dims
)
assert result.shape == expected_shape
7 changes: 7 additions & 0 deletions xarray/core/indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,13 @@ def sel(
missing_labels_str = ",".join([f"{name}" for name in missing_labels])
raise ValueError(f"missing labels for coordinate(s): {missing_labels_str}.")

labels = {
name: Variable(dims=(name,), data=data)
if isinstance(data, np.ndarray)
else data
for (name, data) in labels.items()
}

label0_obj = next(iter(labels.values()))
dim_size0 = getattr(label0_obj, "sizes", {})

Expand Down
Loading
Loading