Skip to content

Commit 6617106

Browse files
authored
test: improve tests structure with test cases in test_position_sequence (#233)
* update test patterns * add pyright * more typing * make pyright manual * update lock * add comprehensive test cases for MDASequence functionality * update AF * rename file * more reorg
1 parent 7f7baa4 commit 6617106

24 files changed

Lines changed: 2703 additions & 2270 deletions

.pre-commit-config.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,14 @@ repos:
2727
- types-PyYAML
2828
- pydantic >=2
2929
- numpy >=2
30+
31+
- repo: local
32+
hooks:
33+
- id: pyright
34+
stages: [manual]
35+
name: pyright
36+
language: system
37+
types_or: [python, pyi]
38+
require_serial: true
39+
files: "src"
40+
entry: uv run pyright

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ dev = [
6262
"rich>=14.0.0",
6363
"ruff>=0.11.9",
6464
"types-pyyaml>=6.0.12.20250402",
65+
"pyright>=1.1.401",
6566
]
6667
docs = [
6768
"mkdocs >=1.4",
@@ -89,6 +90,8 @@ packages = ["src/useq"]
8990
line-length = 88
9091
target-version = "py39"
9192
src = ["src", "tests"]
93+
fix = true
94+
unsafe-fixes = true
9295

9396
[tool.ruff.lint]
9497
pydocstyle = { convention = "numpy" }

src/useq/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Implementation agnostic schema for multi-dimensional microscopy experiments."""
22

33
import warnings
4-
from typing import Any
4+
from typing import TYPE_CHECKING, Any
55

66
from useq._actions import AcquireImage, Action, CustomAction, HardwareAutofocus
77
from useq._channel import Channel
@@ -39,6 +39,10 @@
3939
ZTopBottom,
4040
)
4141

42+
if TYPE_CHECKING:
43+
from useq._grid import GridRelative
44+
45+
4246
__all__ = [
4347
"AbsolutePosition",
4448
"AcquireImage",

src/useq/_actions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class AcquireImage(Action):
3838
This action can be used to acquire an image.
3939
"""
4040

41-
type: Literal["acquire_image"] = "acquire_image"
41+
type: Literal["acquire_image"] = "acquire_image" # pyright: ignore[reportIncompatibleVariableOverride]
4242

4343

4444
class HardwareAutofocus(Action):
@@ -62,7 +62,7 @@ class HardwareAutofocus(Action):
6262
The number of retries if autofocus fails. By default, 3.
6363
"""
6464

65-
type: Literal["hardware_autofocus"] = "hardware_autofocus"
65+
type: Literal["hardware_autofocus"] = "hardware_autofocus" # pyright: ignore[reportIncompatibleVariableOverride]
6666
autofocus_device_name: Optional[str] = None
6767
autofocus_motor_offset: Optional[float] = None
6868
max_retries: int = 3
@@ -89,7 +89,7 @@ class CustomAction(Action):
8989
Custom data associated with the action.
9090
"""
9191

92-
type: Literal["custom"] = "custom"
92+
type: Literal["custom"] = "custom" # pyright: ignore[reportIncompatibleVariableOverride]
9393
name: str = ""
9494
data: dict = Field(default_factory=dict)
9595

src/useq/_channel.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
from typing import Optional
1+
from typing import Any, Optional
22

3-
from pydantic import Field
3+
from pydantic import Field, model_validator
44

55
from useq._base_model import FrozenModel
66

@@ -38,3 +38,10 @@ class Channel(FrozenModel):
3838
z_offset: float = 0.0
3939
acquire_every: int = Field(default=1, gt=0) # acquire every n frames
4040
camera: Optional[str] = None
41+
42+
@model_validator(mode="before")
43+
@classmethod
44+
def _cast(cls, value: Any) -> Any:
45+
if isinstance(value, str):
46+
value = {"config": value}
47+
return value

src/useq/_grid.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,15 +76,15 @@ class _GridPlan(_MultiPointPlan[PositionT]):
7676
Engines MAY override this even if provided.
7777
"""
7878

79-
overlap: tuple[float, float] = Field((0.0, 0.0), frozen=True)
80-
mode: OrderMode = Field(OrderMode.row_wise_snake, frozen=True)
79+
overlap: tuple[float, float] = Field(default=(0.0, 0.0), frozen=True)
80+
mode: OrderMode = Field(default=OrderMode.row_wise_snake, frozen=True)
8181

8282
@field_validator("overlap", mode="before")
8383
def _validate_overlap(cls, v: Any) -> tuple[float, float]:
8484
with contextlib.suppress(TypeError, ValueError):
8585
v = float(v)
8686
if isinstance(v, float):
87-
return (v,) * 2
87+
return (v, v)
8888
if isinstance(v, Sequence) and len(v) == 2:
8989
return float(v[0]), float(v[1])
9090
raise ValueError( # pragma: no cover
@@ -288,7 +288,7 @@ class GridRowsColumns(_GridPlan[RelativePosition]):
288288
# everything but fov_width and fov_height is immutable
289289
rows: int = Field(..., frozen=True, ge=1)
290290
columns: int = Field(..., frozen=True, ge=1)
291-
relative_to: RelativeTo = Field(RelativeTo.center, frozen=True)
291+
relative_to: RelativeTo = Field(default=RelativeTo.center, frozen=True)
292292

293293
def _nrows(self, dy: float) -> int:
294294
return self.rows
@@ -346,7 +346,7 @@ class GridWidthHeight(_GridPlan[RelativePosition]):
346346

347347
width: float = Field(..., frozen=True, gt=0)
348348
height: float = Field(..., frozen=True, gt=0)
349-
relative_to: RelativeTo = Field(RelativeTo.center, frozen=True)
349+
relative_to: RelativeTo = Field(default=RelativeTo.center, frozen=True)
350350

351351
def _nrows(self, dy: float) -> int:
352352
return math.ceil(self.height / dy)

src/useq/_iter_sequence.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def _iter_sequence(
197197
# determine any relative position shifts or global overrides
198198
_pos, _offsets = _position_offsets(position, event_kwargs)
199199
# build overrides for this position
200-
pos_overrides = MDAEventDict(sequence=sequence, **_pos)
200+
pos_overrides = MDAEventDict(sequence=sequence, **_pos) # pyright: ignore[reportCallIssue]
201201
pos_overrides["reset_event_timer"] = False
202202
if position.name:
203203
pos_overrides["pos_name"] = position.name

src/useq/_plot.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def plot_plate(
181181
for well in plate_plan.selected_well_positions:
182182
x, y = float(well.x), float(well.y) # type: ignore[arg-type]
183183
# draw name next to spot
184-
ax.text(x + offset_x, y - offset_y, well.name, fontsize=7)
184+
ax.text(x + offset_x, y - offset_y, well.name or "", fontsize=7)
185185

186186
ax.axis("equal")
187187
if show:

src/useq/_position.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from collections.abc import Iterator
2-
from typing import TYPE_CHECKING, Generic, Optional, SupportsIndex, TypeVar
2+
from typing import TYPE_CHECKING, Any, Generic, Optional, SupportsIndex, TypeVar
33

4-
from pydantic import Field
4+
import numpy as np
5+
from pydantic import Field, model_validator
56

67
from useq._base_model import FrozenModel, MutableModel
78

@@ -73,6 +74,20 @@ def __round__(self, ndigits: "SupportsIndex | None" = None) -> "Self":
7374
# not sure why these Self types are not working
7475
return type(self).model_construct(**kwargs) # type: ignore [return-value]
7576

77+
@model_validator(mode="before")
78+
@classmethod
79+
def _cast(cls, value: Any) -> Any:
80+
if isinstance(value, (np.ndarray, tuple)):
81+
x = y = z = None
82+
if len(value) > 0:
83+
x = value[0]
84+
if len(value) > 1:
85+
y = value[1]
86+
if len(value) > 2:
87+
z = value[2]
88+
value = {"x": x, "y": y, "z": z}
89+
return value
90+
7691

7792
class AbsolutePosition(PositionBase, FrozenModel):
7893
"""An absolute position in 3D space."""
@@ -120,9 +135,9 @@ class RelativePosition(PositionBase, _MultiPointPlan["RelativePosition"]):
120135
be used to define a single field of view for a "multi-point" plan.
121136
"""
122137

123-
x: float = 0
124-
y: float = 0
125-
z: float = 0
138+
x: float = 0 # pyright: ignore[reportIncompatibleVariableOverride]
139+
y: float = 0 # pyright: ignore[reportIncompatibleVariableOverride]
140+
z: float = 0 # pyright: ignore[reportIncompatibleVariableOverride]
126141

127142
def __iter__(self) -> Iterator["RelativePosition"]: # type: ignore [override]
128143
yield self

src/useq/_time.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from collections.abc import Iterator, Sequence
22
from datetime import timedelta
3-
from typing import Annotated, Union
3+
from typing import Annotated, Any, Union
44

5-
from pydantic import BeforeValidator, Field, PlainSerializer
5+
from pydantic import BeforeValidator, Field, PlainSerializer, model_validator
66

77
from useq._base_model import FrozenModel
88

@@ -122,16 +122,25 @@ def deltas(self) -> Iterator[timedelta]:
122122
accum = timedelta(0)
123123
yield accum
124124
for phase in self.phases:
125+
td = None
125126
for i, td in enumerate(phase.deltas()):
126127
# skip the first timepoint of later phases
127128
if i == 0 and td == timedelta(0):
128129
continue
129130
yield td + accum
130-
accum += td
131+
if td is not None:
132+
accum += td
131133

132134
def num_timepoints(self) -> int:
133135
# TODO: is this correct?
134136
return sum(phase.loops for phase in self.phases) - 1
135137

138+
@model_validator(mode="before")
139+
@classmethod
140+
def _cast(cls, value: Any) -> Any:
141+
if isinstance(value, Sequence) and not isinstance(value, str):
142+
value = {"phases": value}
143+
return value
144+
136145

137146
AnyTimePlan = Union[MultiPhaseTimePlan, SinglePhaseTimePlan]

0 commit comments

Comments
 (0)