diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a32aad0..575e1c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.9", "3.11", "3.12", "3.13"] include: - - os: ubuntu-latest - python-version: "3.8" - - os: macos-latest + - os: windows-latest python-version: "3.10" test-minreqs: diff --git a/pyproject.toml b/pyproject.toml index 7abad9c..ac0dee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ name = "useq-schema" description = "Schema for multi-dimensional microscopy experiments" readme = "README.md" keywords = ["microscopy", "schema"] -requires-python = ">=3.8" +requires-python = ">=3.9" license = { text = "BSD 3-Clause License" } authors = [ { email = "talley.lambert@gmail.com", name = "Talley Lambert" }, @@ -23,7 +23,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -73,7 +72,7 @@ packages = ["src/useq"] # https://beta.ruff.rs/docs/rules/ [tool.ruff] line-length = 88 -target-version = "py38" +target-version = "py39" src = ["src", "tests"] [tool.ruff.lint] diff --git a/src/useq/_base_model.py b/src/useq/_base_model.py index 412dda1..4525d90 100644 --- a/src/useq/_base_model.py +++ b/src/useq/_base_model.py @@ -5,9 +5,7 @@ TYPE_CHECKING, Any, ClassVar, - Iterable, Optional, - Type, TypeVar, Union, ) @@ -16,6 +14,8 @@ from pydantic import BaseModel, ConfigDict if TYPE_CHECKING: + from collections.abc import Iterable + from typing_extensions import Self ReprArgs = Iterable[tuple[str | None, Any]] @@ -84,7 +84,7 @@ class MutableModel(_ReplaceableModel): class UseqModel(FrozenModel): @classmethod - def from_file(cls: Type[_Y], path: Union[str, Path]) -> _Y: + def from_file(cls: type[_Y], path: Union[str, Path]) -> _Y: """Return an instance of this class from a file. Supports JSON and YAML.""" path = Path(path) if path.suffix in {".yaml", ".yml"}: diff --git a/src/useq/_grid.py b/src/useq/_grid.py index 930f302..311eeca 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -3,23 +3,21 @@ import contextlib import math import warnings +from collections.abc import Iterable, Iterator, Sequence from enum import Enum from typing import ( TYPE_CHECKING, + Annotated, Any, Callable, - Iterable, - Iterator, Optional, - Sequence, - Tuple, Union, ) import numpy as np from annotated_types import Ge, Gt from pydantic import Field, field_validator, model_validator -from typing_extensions import Annotated, Self, TypeAlias +from typing_extensions import Self, TypeAlias from useq._point_visiting import OrderMode, TraversalOrder from useq._position import ( @@ -76,11 +74,11 @@ class _GridPlan(_MultiPointPlan[PositionT]): Engines MAY override this even if provided. """ - overlap: Tuple[float, float] = Field((0.0, 0.0), frozen=True) + overlap: tuple[float, float] = Field((0.0, 0.0), frozen=True) mode: OrderMode = Field(OrderMode.row_wise_snake, frozen=True) @field_validator("overlap", mode="before") - def _validate_overlap(cls, v: Any) -> Tuple[float, float]: + def _validate_overlap(cls, v: Any) -> tuple[float, float]: with contextlib.suppress(TypeError, ValueError): v = float(v) if isinstance(v, float): @@ -156,7 +154,7 @@ def iter_grid_positions( def __iter__(self) -> Iterator[PositionT]: # type: ignore [override] yield from self.iter_grid_positions() - def _step_size(self, fov_width: float, fov_height: float) -> Tuple[float, float]: + def _step_size(self, fov_width: float, fov_height: float) -> tuple[float, float]: dx = fov_width - (fov_width * self.overlap[0]) / 100 dy = fov_height - (fov_height * self.overlap[1]) / 100 return dx, dy @@ -409,7 +407,7 @@ def __iter__(self) -> Iterator[RelativePosition]: # type: ignore [override] seed = np.random.RandomState(self.random_seed) func = _POINTS_GENERATORS[self.shape] - points: list[Tuple[float, float]] = [] + points: list[tuple[float, float]] = [] needed_points = self.num_points start_at = self.start_at if isinstance(start_at, RelativePosition): @@ -454,7 +452,7 @@ def num_positions(self) -> int: def _is_a_valid_point( - points: list[Tuple[float, float]], + points: list[tuple[float, float]], x: float, y: float, min_dist_x: float, diff --git a/src/useq/_hardware_autofocus.py b/src/useq/_hardware_autofocus.py index 208bdc9..7f1254f 100644 --- a/src/useq/_hardware_autofocus.py +++ b/src/useq/_hardware_autofocus.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, Tuple +from typing import Any, Optional from pydantic import PrivateAttr @@ -42,7 +42,7 @@ def event(self, event: MDAEvent) -> Optional[MDAEvent]: if not self.should_autofocus(event): return None - updates: Dict[str, Any] = {"action": self.as_action()} + updates: dict[str, Any] = {"action": self.as_action()} if event.z_pos is not None and event.sequence is not None: zplan = event.sequence.z_plan if zplan and zplan.is_relative and "z" in event.index: @@ -71,7 +71,7 @@ class AxesBasedAF(AutoFocusPlan): is change, (in other words: every time the position is changed.). """ - axes: Tuple[str, ...] + axes: tuple[str, ...] _previous: dict = PrivateAttr(default_factory=dict) def should_autofocus(self, event: MDAEvent) -> bool: diff --git a/src/useq/_iter_sequence.py b/src/useq/_iter_sequence.py index bd7e0a8..c4f652a 100644 --- a/src/useq/_iter_sequence.py +++ b/src/useq/_iter_sequence.py @@ -1,9 +1,9 @@ from __future__ import annotations -from functools import lru_cache +from functools import cache from itertools import product from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Iterator, cast +from typing import TYPE_CHECKING, Any, cast from typing_extensions import TypedDict @@ -14,6 +14,8 @@ from useq._z import AnyZPlan # noqa: TCH001 # noqa: TCH001 if TYPE_CHECKING: + from collections.abc import Iterator + from useq._mda_sequence import MDASequence from useq._position import Position, PositionBase, RelativePosition @@ -39,17 +41,17 @@ class PositionDict(TypedDict, total=False): z_pos: float -@lru_cache(maxsize=None) +@cache def _iter_axis(seq: MDASequence, ax: str) -> tuple[Channel | float | PositionBase, ...]: return tuple(seq.iter_axis(ax)) -@lru_cache(maxsize=None) +@cache def _sizes(seq: MDASequence) -> dict[str, int]: return {k: len(list(_iter_axis(seq, k))) for k in seq.axis_order} -@lru_cache(maxsize=None) +@cache def _used_axes(seq: MDASequence) -> str: return "".join(k for k in seq.axis_order if _sizes(seq)[k]) diff --git a/src/useq/_mda_event.py b/src/useq/_mda_event.py index 5c69c30..56e8205 100644 --- a/src/useq/_mda_event.py +++ b/src/useq/_mda_event.py @@ -1,17 +1,13 @@ # don't add __future__.annotations here # pydantic2 isn't rebuilding the model correctly +from collections.abc import Mapping, Sequence from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, - Dict, - List, - Mapping, NamedTuple, Optional, - Sequence, - Tuple, ) import numpy as np @@ -29,7 +25,7 @@ if TYPE_CHECKING: from useq._mda_sequence import MDASequence - ReprArgs = Sequence[Tuple[Optional[str], Any]] + ReprArgs = Sequence[tuple[Optional[str], Any]] class Channel(UseqModel): @@ -182,7 +178,6 @@ class MDAEvent(UseqModel): `False`. """ - # MappingProxyType is not subscriptable on Python 3.8 index: Mapping[str, int] = Field(default_factory=lambda: MappingProxyType({})) channel: Optional[Channel] = None exposure: Optional[float] = Field(default=None, gt=0.0) @@ -193,8 +188,8 @@ class MDAEvent(UseqModel): z_pos: Optional[float] = None slm_image: Optional[SLMImage] = None sequence: Optional["MDASequence"] = Field(default=None, repr=False) - properties: Optional[List[PropertyTuple]] = None - metadata: Dict[str, Any] = Field(default_factory=dict) + properties: Optional[list[PropertyTuple]] = None + metadata: dict[str, Any] = Field(default_factory=dict) action: AnyAction = Field(default_factory=AcquireImage) keep_shutter_open: bool = False reset_event_timer: bool = False diff --git a/src/useq/_mda_sequence.py b/src/useq/_mda_sequence.py index 0faaf57..0ae05dc 100644 --- a/src/useq/_mda_sequence.py +++ b/src/useq/_mda_sequence.py @@ -1,16 +1,11 @@ from __future__ import annotations +from collections.abc import Iterable, Iterator, Mapping, Sequence from contextlib import suppress from typing import ( TYPE_CHECKING, Any, - Dict, - Iterable, - Iterator, - Mapping, Optional, - Sequence, - Tuple, Union, ) from uuid import UUID, uuid4 @@ -182,24 +177,24 @@ class MDASequence(UseqModel): ``` """ - metadata: Dict[str, Any] = Field(default_factory=dict) - axis_order: Tuple[str, ...] = AXES + metadata: dict[str, Any] = Field(default_factory=dict) + axis_order: tuple[str, ...] = AXES # note that these are BOTH just `Sequence[Position]` but we retain the distinction # here so that WellPlatePlans are preserved in the model instance. - stage_positions: Union[WellPlatePlan, Tuple[Position, ...]] = Field( + stage_positions: Union[WellPlatePlan, tuple[Position, ...]] = Field( default_factory=tuple, union_mode="left_to_right" ) grid_plan: Optional[MultiPointPlan] = Field( default=None, union_mode="left_to_right" ) - channels: Tuple[Channel, ...] = Field(default_factory=tuple) + channels: tuple[Channel, ...] = Field(default_factory=tuple) time_plan: Optional[AnyTimePlan] = None z_plan: Optional[AnyZPlan] = None autofocus_plan: Optional[AnyAutofocusPlan] = None - keep_shutter_open_across: Tuple[str, ...] = Field(default_factory=tuple) + keep_shutter_open_across: tuple[str, ...] = Field(default_factory=tuple) _uid: UUID = PrivateAttr(default_factory=uuid4) - _sizes: Optional[Dict[str, int]] = PrivateAttr(default=None) + _sizes: Optional[dict[str, int]] = PrivateAttr(default=None) @property def uid(self) -> UUID: @@ -225,7 +220,7 @@ def _validate_keep_shutter_open_across(cls, v: tuple[str, ...]) -> tuple[str, .. return v @field_validator("channels", mode="before") - def _validate_channels(cls, value: Any) -> Tuple[Channel, ...]: + def _validate_channels(cls, value: Any) -> tuple[Channel, ...]: if isinstance(value, str) or not isinstance( value, Sequence ): # pragma: no cover @@ -245,7 +240,7 @@ def _validate_channels(cls, value: Any) -> Tuple[Channel, ...]: @field_validator("stage_positions", mode="before") def _validate_stage_positions( cls, value: Any - ) -> Union[WellPlatePlan, Tuple[Position, ...]]: + ) -> Union[WellPlatePlan, tuple[Position, ...]]: if isinstance(value, np.ndarray): if value.ndim == 1: value = [value] @@ -402,7 +397,7 @@ def _check_order( raise ValueError(err) # pragma: no cover @property - def shape(self) -> Tuple[int, ...]: + def shape(self) -> tuple[int, ...]: """Return the shape of this sequence. !!! note diff --git a/src/useq/_plate.py b/src/useq/_plate.py index c86ffaf..33f56f3 100644 --- a/src/useq/_plate.py +++ b/src/useq/_plate.py @@ -1,14 +1,11 @@ from __future__ import annotations +from collections.abc import Iterable, Iterator, Sequence from functools import cached_property from typing import ( TYPE_CHECKING, + Annotated, Any, - Iterable, - Iterator, - List, - Sequence, - Tuple, Union, cast, overload, @@ -23,7 +20,6 @@ field_validator, model_validator, ) -from typing_extensions import Annotated from useq._base_model import FrozenModel, UseqModel from useq._grid import RandomPoints, RelativeMultiPointPlan, Shape @@ -33,8 +29,8 @@ if TYPE_CHECKING: from pydantic_core import core_schema - Index = Union[int, List[int], slice] - IndexExpression = Union[Tuple[Index, ...], Index] + Index = Union[int, list[int], slice] + IndexExpression = Union[tuple[Index, ...], Index] class WellPlate(FrozenModel): @@ -62,8 +58,8 @@ class WellPlate(FrozenModel): rows: Annotated[int, Gt(0)] columns: Annotated[int, Gt(0)] - well_spacing: Tuple[float, float] # (x, y) - well_size: Tuple[float, float] # (width, height) + well_spacing: tuple[float, float] # (x, y) + well_size: tuple[float, float] # (width, height) circular_wells: bool = True name: str = "" @@ -162,14 +158,14 @@ class WellPlatePlan(UseqModel, Sequence[Position]): """ plate: WellPlate - a1_center_xy: Tuple[float, float] + a1_center_xy: tuple[float, float] rotation: Union[float, None] = None - selected_wells: Union[Tuple[Tuple[int, ...], Tuple[int, ...]], None] = None + selected_wells: Union[tuple[tuple[int, ...], tuple[int, ...]], None] = None well_points_plan: RelativeMultiPointPlan = Field( default_factory=RelativePosition, union_mode="left_to_right" ) - def __repr_args__(self) -> Iterable[Tuple[str | None, Any]]: + def __repr_args__(self) -> Iterable[tuple[str | None, Any]]: for item in super().__repr_args__(): if item[0] == "selected_wells": # improve repr for selected_wells @@ -233,7 +229,7 @@ def _validate_rotation(cls, value: Any) -> Any: @classmethod def _validate_selected_wells( cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo - ) -> Tuple[Tuple[int, ...], Tuple[int, ...]]: + ) -> tuple[tuple[int, ...], tuple[int, ...]]: plate = info.data.get("plate") if not isinstance(plate, WellPlate): raise ValueError("Plate must be defined before selecting wells") @@ -365,7 +361,7 @@ def image_positions(self) -> Sequence[Position]: """ wpp = self.well_points_plan offsets = [wpp] if isinstance(wpp, RelativePosition) else wpp - pos: List[Position] = [] + pos: list[Position] = [] for well in self.selected_well_positions: pos.extend(well + offset for offset in offsets) return pos diff --git a/src/useq/_plate_registry.py b/src/useq/_plate_registry.py index 03bf049..e16fcbc 100644 --- a/src/useq/_plate_registry.py +++ b/src/useq/_plate_registry.py @@ -3,7 +3,8 @@ from typing import TYPE_CHECKING, overload if TYPE_CHECKING: - from typing import Iterable, Mapping, Required, TypeAlias, TypedDict + from collections.abc import Iterable, Mapping + from typing import Required, TypeAlias, TypedDict from useq._plate import WellPlate diff --git a/src/useq/_plot.py b/src/useq/_plot.py index 52e798d..cb3b75a 100644 --- a/src/useq/_plot.py +++ b/src/useq/_plot.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Iterable +from typing import TYPE_CHECKING, Callable try: import matplotlib.pyplot as plt @@ -11,6 +11,8 @@ ) from e if TYPE_CHECKING: + from collections.abc import Iterable + from matplotlib.axes import Axes from useq._plate import WellPlatePlan diff --git a/src/useq/_point_visiting.py b/src/useq/_point_visiting.py index b656b17..f919152 100644 --- a/src/useq/_point_visiting.py +++ b/src/useq/_point_visiting.py @@ -1,8 +1,9 @@ from __future__ import annotations +from collections.abc import Iterable, Iterator from enum import Enum from functools import partial -from typing import Callable, Iterable, Iterator, Tuple +from typing import Callable import numpy as np @@ -32,14 +33,14 @@ class OrderMode(Enum): column_wise_snake = "column_wise_snake" spiral = "spiral" - def generate_indices(self, rows: int, columns: int) -> Iterator[Tuple[int, int]]: + def generate_indices(self, rows: int, columns: int) -> Iterator[tuple[int, int]]: """Generate indices for the given grid size.""" return _INDEX_GENERATORS[self](rows, columns) def _spiral_indices( rows: int, columns: int, center_origin: bool = False -) -> Iterator[Tuple[int, int]]: +) -> Iterator[tuple[int, int]]: """Return a spiral iterator over a 2D grid. Parameters @@ -78,7 +79,7 @@ def _spiral_indices( # function that iterates indices (row, col) in a grid where (0, 0) is the top left def _rect_indices( rows: int, columns: int, snake: bool = False, row_wise: bool = True -) -> Iterator[Tuple[int, int]]: +) -> Iterator[tuple[int, int]]: """Return a row or column-wise iterator over a 2D grid.""" c, r = np.meshgrid(np.arange(columns), np.arange(rows)) if snake: @@ -89,7 +90,7 @@ def _rect_indices( return zip(r.ravel(), c.ravel()) if row_wise else zip(r.T.ravel(), c.T.ravel()) -IndexGenerator = Callable[[int, int], Iterator[Tuple[int, int]]] +IndexGenerator = Callable[[int, int], Iterator[tuple[int, int]]] _INDEX_GENERATORS: dict[OrderMode, IndexGenerator] = { OrderMode.row_wise: partial(_rect_indices, snake=False, row_wise=True), OrderMode.column_wise: partial(_rect_indices, snake=False, row_wise=False), diff --git a/src/useq/_position.py b/src/useq/_position.py index ec1c552..3137e99 100644 --- a/src/useq/_position.py +++ b/src/useq/_position.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Generic, Iterator, Optional, SupportsIndex, TypeVar +from collections.abc import Iterator +from typing import TYPE_CHECKING, Generic, Optional, SupportsIndex, TypeVar from pydantic import Field diff --git a/src/useq/_time.py b/src/useq/_time.py index 997b7aa..a8a1011 100644 --- a/src/useq/_time.py +++ b/src/useq/_time.py @@ -1,8 +1,8 @@ +from collections.abc import Iterator, Sequence from datetime import timedelta -from typing import Iterator, Sequence, Union +from typing import Annotated, Union from pydantic import BeforeValidator, Field, PlainSerializer -from typing_extensions import Annotated from useq._base_model import FrozenModel diff --git a/src/useq/_z.py b/src/useq/_z.py index c749cdb..622d145 100644 --- a/src/useq/_z.py +++ b/src/useq/_z.py @@ -1,13 +1,16 @@ from __future__ import annotations import math -from typing import Callable, Iterator, List, Sequence, Union +from typing import TYPE_CHECKING, Callable, Union import numpy as np from pydantic import field_validator from useq._base_model import FrozenModel +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + def _list_cast(field: str) -> Callable: v = field_validator(field, mode="before", check_fields=False) @@ -31,7 +34,7 @@ def positions(self) -> Sequence[float]: if step == 0: return [start] stop += step / 2 # make sure we include the last point - return list(np.arange(start, stop, step)) + return [float(x) for x in np.arange(start, stop, step)] def num_positions(self) -> int: start, stop, step = self._start_stop_step() @@ -146,7 +149,7 @@ class ZRelativePositions(ZPlan): reverse. """ - relative: List[float] + relative: list[float] _normrel = _list_cast("relative") @@ -169,7 +172,7 @@ class ZAbsolutePositions(ZPlan): reverse. """ - absolute: List[float] + absolute: list[float] _normabs = _list_cast("absolute") diff --git a/tests/test_autofocus.py b/tests/test_autofocus.py index cf30aaf..f020ca2 100644 --- a/tests/test_autofocus.py +++ b/tests/test_autofocus.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import Iterable +from typing import TYPE_CHECKING import pytest import useq from useq import AxesBasedAF, HardwareAutofocus, MDASequence +if TYPE_CHECKING: + from collections.abc import Iterable + ZRANGE2 = useq.ZRangeAround(range=2, step=1) ZPOS_30 = useq.Position(z=30.0) ZPOS_200 = useq.Position(z=200.0) diff --git a/tests/test_points_plans.py b/tests/test_points_plans.py index 8548851..7074364 100644 --- a/tests/test_points_plans.py +++ b/tests/test_points_plans.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterable, Optional, get_args +from typing import TYPE_CHECKING, Optional, get_args import pytest @@ -16,6 +16,8 @@ from useq._point_visiting import OrderMode, _rect_indices, _spiral_indices if TYPE_CHECKING: + from collections.abc import Iterable + from useq._position import PositionBase EXPECT = { diff --git a/tests/test_position_sequence.py b/tests/test_position_sequence.py index 35ac9e9..c9d0590 100644 --- a/tests/test_position_sequence.py +++ b/tests/test_position_sequence.py @@ -1,11 +1,14 @@ from __future__ import annotations from itertools import product -from typing import Any, Sequence +from typing import TYPE_CHECKING, Any import useq from useq import MDASequence +if TYPE_CHECKING: + from collections.abc import Sequence + FITC = "FITC" CY5 = "Cy5" CY3 = "Cy3" diff --git a/tests/test_sequence.py b/tests/test_sequence.py index 3c7a846..aee7fe6 100644 --- a/tests/test_sequence.py +++ b/tests/test_sequence.py @@ -1,6 +1,7 @@ import itertools import json -from typing import Any, List, Sequence, Tuple +from collections.abc import Sequence +from typing import Any import numpy as np import pytest @@ -25,7 +26,7 @@ from useq._mda_event import SLMImage from useq._position import RelativePosition -_T = List[Tuple[Any, Sequence[float]]] +_T = list[tuple[Any, Sequence[float]]] z_as_class: _T = [ (ZAboveBelow(above=8, below=4, step=2), [-4, -2, 0, 2, 4, 6, 8]),