From 0a1db809e714a88f871ae19c9dc96e3a17536ef1 Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Sun, 26 Jan 2025 11:45:26 -0500 Subject: [PATCH 1/2] chore(ci): Test on Python 3.10+ --- .github/workflows/test.yml | 5 ++++- tox.ini | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c49c3ad9..5799a733 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,6 +85,9 @@ jobs: # Unit tests only on Linux/Python 3.12 runs-on: 'ubuntu-latest' needs: ['cache-test-data'] + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 with: @@ -93,7 +96,7 @@ jobs: - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: ${{ matrix.python-version }} - name: Install the latest version of uv uses: astral-sh/setup-uv@v5 - name: Install ANTs diff --git a/tox.ini b/tox.ini index 2a05091a..03deb6b9 100644 --- a/tox.ini +++ b/tox.ini @@ -2,13 +2,17 @@ requires = tox>=4 envlist = - py312, notebooks + py3{10,11,12,13} + notebooks skip_missing_interpreters = true # Configuration that allows us to split tests across GitHub runners effectively [gh-actions] python = + 3.10: py310 + 3.11: py311 3.12: py312, notebooks + 3.13: py313 [testenv] description = Pytest with coverage From 2008a6c380bb51b0cbc66071be9ee3ff6190b28b Mon Sep 17 00:00:00 2001 From: "Christopher J. Markiewicz" Date: Sun, 26 Jan 2025 11:52:46 -0500 Subject: [PATCH 2/2] fix(types): Use typing_extensions for 3.10 --- pyproject.toml | 1 + src/nifreeze/data/base.py | 10 +++++----- src/nifreeze/estimator.py | 3 ++- test/test_data_base.py | 12 ++++++------ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index df3993a2..79c2ea11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "scikit-image>=0.15.0", "scikit_learn>=1.3.0", "scipy>=1.8.0", + "typing_extensions >=4.12", ] dynamic = ["version"] diff --git a/src/nifreeze/data/base.py b/src/nifreeze/data/base.py index 4fee5a01..4cf47ff2 100644 --- a/src/nifreeze/data/base.py +++ b/src/nifreeze/data/base.py @@ -27,7 +27,7 @@ from collections import namedtuple from pathlib import Path from tempfile import mkdtemp -from typing import Any, Generic, TypeVarTuple +from typing import Any, Generic import attr import h5py @@ -35,6 +35,7 @@ import numpy as np from nibabel.spatialimages import SpatialHeader, SpatialImage from nitransforms.linear import Affine +from typing_extensions import TypeVarTuple, Unpack from nifreeze.utils.ndimage import load_api @@ -58,7 +59,7 @@ def _cmp(lh: Any, rh: Any) -> bool: @attr.s(slots=True) -class BaseDataset(Generic[*Ts]): +class BaseDataset(Generic[Unpack[Ts]]): """ Base dataset representation structure. @@ -99,13 +100,12 @@ def __len__(self) -> int: return self.dataobj.shape[-1] - def _getextra(self, idx: int | slice | tuple | np.ndarray) -> tuple[*Ts]: - # PY312: Default values for TypeVarTuples are not yet supported + def _getextra(self, idx: int | slice | tuple | np.ndarray) -> tuple[Unpack[Ts]]: return () # type: ignore[return-value] def __getitem__( self, idx: int | slice | tuple | np.ndarray - ) -> tuple[np.ndarray, np.ndarray | None, *Ts]: + ) -> tuple[np.ndarray, np.ndarray | None, Unpack[Ts]]: """ Returns volume(s) and corresponding affine(s) through fancy indexing. diff --git a/src/nifreeze/estimator.py b/src/nifreeze/estimator.py index be1ed333..4c8a171e 100644 --- a/src/nifreeze/estimator.py +++ b/src/nifreeze/estimator.py @@ -26,9 +26,10 @@ from pathlib import Path from tempfile import TemporaryDirectory -from typing import Self, TypeVar +from typing import TypeVar from tqdm import tqdm +from typing_extensions import Self from nifreeze.data.base import BaseDataset from nifreeze.model.base import BaseModel, ModelFactory diff --git a/test/test_data_base.py b/test/test_data_base.py index 4afa6446..d8c65e29 100644 --- a/test/test_data_base.py +++ b/test/test_data_base.py @@ -53,25 +53,25 @@ def test_len(random_dataset: BaseDataset): assert len(random_dataset) == 5 # last dimension is 5 volumes -def test_getitem_volume_index(random_dataset: BaseDataset[()]): +def test_getitem_volume_index(random_dataset: BaseDataset): """ Test that __getitem__ returns the correct (volume, affine) tuple. By default, motion_affines is None, so we expect to get None for the affine. """ - # Single volume - volume0, aff0 = random_dataset[0] + # Single volume # Note that the type ignore can be removed once we can use *Ts + volume0, aff0 = random_dataset[0] # type: ignore[misc] # PY310 assert volume0.shape == (32, 32, 32) # No transforms have been applied yet, so there's no motion_affines array assert aff0 is None # Slice of volumes - volume_slice, aff_slice = random_dataset[2:4] + volume_slice, aff_slice = random_dataset[2:4] # type: ignore[misc] # PY310 assert volume_slice.shape == (32, 32, 32, 2) assert aff_slice is None -def test_set_transform(random_dataset: BaseDataset[()]): +def test_set_transform(random_dataset: BaseDataset): """ Test that calling set_transform changes the data and motion_affines. For simplicity, we'll apply an identity transform and check that motion_affines is updated. @@ -83,7 +83,7 @@ def test_set_transform(random_dataset: BaseDataset[()]): random_dataset.set_transform(idx, affine, order=1) # Data shouldn't have changed (since transform is identity). - volume0, aff0 = random_dataset[idx] + volume0, aff0 = random_dataset[idx] # type: ignore[misc] # PY310 assert np.allclose(data_before, volume0) # motion_affines should be created and match the transform matrix.