From 666a69248af202fd3614fe3bbac2d95ec49e50b9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 1 Jul 2024 18:50:50 -0400 Subject: [PATCH] build: drop support for pydantic 1 (#167) * build: drop pydantic 1 * remove timedelta junk * use typing extensions * fix cov * fix source * cov --- .github/workflows/ci.yml | 30 ++++++------- .pre-commit-config.yaml | 1 - README.md | 5 ++- pyproject.toml | 7 ++- src/useq/__init__.py | 5 +-- src/useq/_base_model.py | 2 +- src/useq/_grid.py | 2 +- src/useq/_mda_event.py | 2 +- src/useq/_mda_sequence.py | 7 ++- src/useq/_time.py | 85 ++++++++++--------------------------- src/useq/_utils.py | 2 +- src/useq/_z.py | 2 +- src/useq/pycromanager.py | 4 +- tests/fixtures/mda.yaml | 6 +-- tests/test_misc.py | 9 ---- tests/test_serialization.py | 4 +- 16 files changed, 62 insertions(+), 111 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3dac6fb..2446932c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,8 @@ concurrency: on: push: - branches: - - main - tags: - - "v*" + branches: [main] + tags: ["v*"] pull_request: {} workflow_dispatch: @@ -22,25 +20,25 @@ jobs: - run: pip3 install check-manifest && check-manifest test: - uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@main + uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 with: os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} - pip-post-installs: ${{ matrix.pydantic }} - secrets: - codecov-token: ${{ secrets.CODECOV_TOKEN }} + coverage-upload: artifact strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] include: - - python-version: "3.11" - platform: ubuntu-latest - pydantic: "'pydantic<2'" - - python-version: "3.8" - platform: ubuntu-latest - pydantic: "'pydantic<2'" + - os: ubuntu-latest + python-version: "3.8" + + upload_coverage: + if: always() + needs: [test] + uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 + secrets: inherit test-dependents: uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 @@ -85,4 +83,4 @@ jobs: - uses: softprops/action-gh-release@v2 with: generate_release_notes: true - files: './dist/*' + files: "./dist/*" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59d2139b..27d34830 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,4 +26,3 @@ repos: additional_dependencies: - types-PyYAML - pydantic >=2 - - pydantic-compat >=0.0.4 diff --git a/README.md b/README.md index 28094703..fd57a80b 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ for more details. > **Note:** `useq-schema` uses [`pydantic`](https://pydantic-docs.helpmanual.io/) to > define models, so you can retrieve the [json schema](https://json-schema.org/) -> for the `MDAEvent` object with `MDAEvent.schema_json()` +> for the `MDAEvent` object with `MDAEvent.model_json_schema()` ## `MDASequence` @@ -80,7 +80,7 @@ Time Plan, a Z Plan, a list of channels and positions, etc.). A See [`useq.MDASequence` documentation](https://pymmcore-plus.github.io/useq-schema/schema/sequence/) for more details. -### example `MDASequence` usage: +### example `MDASequence` usage ```python from useq import MDASequence @@ -149,6 +149,7 @@ z_plan: range: 4.0 step: 0.5 ``` + ## Executing useq-schema experiments with pymmcore-plus [pymmcore-plus](https://github.com/pymmcore-plus/pymmcore-plus) implements an diff --git a/pyproject.toml b/pyproject.toml index 612f953f..7efb8c2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ "Typing :: Typed", ] dynamic = ["version"] -dependencies = ["pydantic >=1.7,!=2.4.1", "numpy", "pydantic-compat >=0.0.4"] +dependencies = ["pydantic >=2,!=2.4.1", "numpy", "typing-extensions"] # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies @@ -91,7 +91,7 @@ select = [ "SLF", # private access ] ignore = [ - "D100", # Missing docstring in public module + "D100", # Missing docstring in public module "D401", # First line should be in imperative mood (remove to opt in) ] @@ -122,6 +122,9 @@ pretty = true plugins = ["pydantic.mypy"] # https://coverage.readthedocs.io/en/6.4/config.html +[tool.coverage.run] +source = ["useq"] + [tool.coverage.report] exclude_lines = [ "pragma: no cover", diff --git a/src/useq/__init__.py b/src/useq/__init__.py index 82858348..b8cfde0a 100644 --- a/src/useq/__init__.py +++ b/src/useq/__init__.py @@ -63,9 +63,8 @@ ] -# type ignores because pydantic-compat consumes the kwargs -MDAEvent.model_rebuild(MDASequence=MDASequence) # type: ignore [call-arg] -Position.model_rebuild(MDASequence=MDASequence) # type: ignore [call-arg] +MDAEvent.model_rebuild() +Position.model_rebuild() def __getattr__(name: str) -> Any: diff --git a/src/useq/_base_model.py b/src/useq/_base_model.py index f1af62e6..2fdbe6b5 100644 --- a/src/useq/_base_model.py +++ b/src/useq/_base_model.py @@ -14,7 +14,7 @@ ) import numpy as np -from pydantic_compat import BaseModel +from pydantic import BaseModel if TYPE_CHECKING: from pydantic import ConfigDict diff --git a/src/useq/_grid.py b/src/useq/_grid.py index 269cde26..4c1078cd 100644 --- a/src/useq/_grid.py +++ b/src/useq/_grid.py @@ -20,7 +20,7 @@ ) import numpy as np -from pydantic_compat import Field, field_validator +from pydantic import Field, field_validator from useq._base_model import FrozenModel diff --git a/src/useq/_mda_event.py b/src/useq/_mda_event.py index 87316d15..01a19994 100644 --- a/src/useq/_mda_event.py +++ b/src/useq/_mda_event.py @@ -14,7 +14,7 @@ Tuple, ) -from pydantic_compat import Field, field_validator +from pydantic import Field, field_validator from useq._actions import AcquireImage, AnyAction from useq._base_model import UseqModel diff --git a/src/useq/_mda_sequence.py b/src/useq/_mda_sequence.py index 20f61ccb..2fcad2b2 100644 --- a/src/useq/_mda_sequence.py +++ b/src/useq/_mda_sequence.py @@ -15,8 +15,7 @@ from warnings import warn import numpy as np -from pydantic import Field, PrivateAttr -from pydantic_compat import field_validator, model_validator +from pydantic import Field, PrivateAttr, field_validator, model_validator from useq._base_model import UseqModel from useq._channel import Channel @@ -378,10 +377,10 @@ def _check_order( if Axis.Z in order and z_plan and not z_plan.is_relative: err = "Absolute Z positions cannot be used with autofocus plan." if isinstance(autofocus_plan, AxesBasedAF): - raise ValueError(err) + raise ValueError(err) # pragma: no cover for p in stage_positions: if p.sequence is not None and p.sequence.autofocus_plan: - raise ValueError(err) + raise ValueError(err) # pragma: no cover @property def shape(self) -> Tuple[int, ...]: diff --git a/src/useq/_time.py b/src/useq/_time.py index e4a49512..997b7aac 100644 --- a/src/useq/_time.py +++ b/src/useq/_time.py @@ -1,59 +1,18 @@ -import datetime -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Dict, - Generator, - Iterator, - Sequence, - Union, -) - -from pydantic import Field +from datetime import timedelta +from typing import Iterator, Sequence, Union -from useq._base_model import FrozenModel -from useq._utils import parse_duration - -if TYPE_CHECKING: - from pydantic_core import CoreSchema, core_schema - - -# FIXME: please!! -# This is a gross amalgamation of fixes that tries to work with both pydantic1 and 2 -class timedelta(datetime.timedelta): - @classmethod - def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: - yield cls.validate - - @classmethod - def validate(cls, v: Any) -> datetime.timedelta: - return datetime.timedelta(**v) if isinstance(v, dict) else parse_duration(v) +from pydantic import BeforeValidator, Field, PlainSerializer +from typing_extensions import Annotated - @classmethod - def __get_pydantic_core_schema__( - cls, source_type: Any, handler: Any - ) -> "CoreSchema": - from pydantic_core.core_schema import ( - no_info_plain_validator_function, - plain_serializer_function_ser_schema, - ) - - serializer = plain_serializer_function_ser_schema( - cls._serialize, when_used="json" - ) - - return no_info_plain_validator_function(cls.validate, serialization=serializer) - - @classmethod - def _serialize(cls, v: datetime.timedelta) -> float: - return v.total_seconds() +from useq._base_model import FrozenModel - @classmethod - def __get_pydantic_json_schema__( - cls, core_schema: "core_schema.CoreSchema", handler: Any - ) -> Dict[str, Any]: - return {"type": "number", "format": "float"} +# slightly modified so that we can accept dict objects as input +# and serialize to total_seconds +TimeDelta = Annotated[ + timedelta, + BeforeValidator(lambda v: timedelta(**v) if isinstance(v, dict) else v), + PlainSerializer(lambda td: td.total_seconds()), +] class TimePlan(FrozenModel): @@ -67,7 +26,7 @@ def __iter__(self) -> Iterator[float]: # type: ignore def num_timepoints(self) -> int: return self.loops # type: ignore # TODO - def deltas(self) -> Iterator[datetime.timedelta]: + def deltas(self) -> Iterator[timedelta]: current = timedelta(0) for _ in range(self.loops): # type: ignore # TODO yield current @@ -89,11 +48,11 @@ class TIntervalLoops(TimePlan): of conflict. By default, `False`. """ - interval: timedelta + interval: TimeDelta loops: int = Field(..., gt=0) @property - def duration(self) -> datetime.timedelta: + def duration(self) -> timedelta: return self.interval * (self.loops - 1) @@ -112,11 +71,11 @@ class TDurationLoops(TimePlan): of conflict. By default, `False`. """ - duration: timedelta + duration: TimeDelta loops: int = Field(..., gt=0) @property - def interval(self) -> datetime.timedelta: + def interval(self) -> timedelta: # -1 makes it so that the last loop will *occur* at duration, not *finish* return self.duration / (self.loops - 1) @@ -136,8 +95,8 @@ class TIntervalDuration(TimePlan): of conflict. By default, `True`. """ - interval: timedelta - duration: timedelta + interval: TimeDelta + duration: TimeDelta prioritize_duration: bool = True @property @@ -159,13 +118,13 @@ class MultiPhaseTimePlan(TimePlan): phases: Sequence[SinglePhaseTimePlan] - def deltas(self) -> Iterator[datetime.timedelta]: - accum = datetime.timedelta(0) + def deltas(self) -> Iterator[timedelta]: + accum = timedelta(0) yield accum for phase in self.phases: for i, td in enumerate(phase.deltas()): # skip the first timepoint of later phases - if i == 0 and td == datetime.timedelta(0): + if i == 0 and td == timedelta(0): continue yield td + accum accum += td diff --git a/src/useq/_utils.py b/src/useq/_utils.py index 8b98701f..06b22038 100644 --- a/src/useq/_utils.py +++ b/src/useq/_utils.py @@ -59,7 +59,7 @@ class TimeEstimate(NamedTuple): def __add__(self, other: object) -> TimeEstimate: """Add two TimeEstimates.""" if not isinstance(other, TimeEstimate): - return NotImplemented + return NotImplemented # pragma: no cover return TimeEstimate( self.total_duration + other.total_duration, self.per_t_duration + other.per_t_duration, diff --git a/src/useq/_z.py b/src/useq/_z.py index bd839c3f..c749cdbe 100644 --- a/src/useq/_z.py +++ b/src/useq/_z.py @@ -4,7 +4,7 @@ from typing import Callable, Iterator, List, Sequence, Union import numpy as np -from pydantic_compat import field_validator +from pydantic import field_validator from useq._base_model import FrozenModel diff --git a/src/useq/pycromanager.py b/src/useq/pycromanager.py index 43cf5504..acfa0524 100644 --- a/src/useq/pycromanager.py +++ b/src/useq/pycromanager.py @@ -63,7 +63,9 @@ def to_pycromanager( return _event_to_pycromanager(obj) elif isinstance(obj, MDASequence): return [_event_to_pycromanager(event) for event in obj] - raise TypeError(f"invalid argument: {obj!r}. Must be MDAEvent or MDASequence.") + raise TypeError( # pragma: no cover + f"invalid argument: {obj!r}. Must be MDAEvent or MDASequence." + ) _USEQ_AXIS_TO_PYCRO: dict[str, PycroAxis] = { diff --git a/tests/fixtures/mda.yaml b/tests/fixtures/mda.yaml index 3201607c..5fea5c92 100644 --- a/tests/fixtures/mda.yaml +++ b/tests/fixtures/mda.yaml @@ -46,10 +46,10 @@ stage_positions: z: 50.0 time_plan: phases: - - interval: 0:00:03 + - interval: 3.0 loops: 3 - - duration: 0:40:00 - interval: 0:00:10 + - duration: 2400.0 + interval: 10.0 z_plan: range: 1.0 step: 0.5 diff --git a/tests/test_misc.py b/tests/test_misc.py index cc5aa5b2..e202e59a 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -141,12 +141,3 @@ def test_time_estimation_with_position_seqs(seq: useq.MDASequence) -> None: if not isinstance(expect, tuple): expect = (expect, False) assert _duration_exceeded(seq) == expect - - -def test_pydantic_compat(mda1: useq.MDASequence) -> None: - # testing names of deprecated methods and cross-compatible API - assert mda1.json() - assert mda1.model_dump_json() - - assert mda1.model_dump() - assert mda1.dict() diff --git a/tests/test_serialization.py b/tests/test_serialization.py index aead5c64..a5b41aaf 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -13,13 +13,13 @@ def test_serialization(mda1: MDASequence, ext: str) -> None: mda = MDASequence.from_file(str(FILE)) assert mda == mda1 if ext == "json": - assert json.loads(mda.json(exclude={"uid"})) == json.loads(text) + assert json.loads(mda.model_dump_json(exclude={"uid"})) == json.loads(text) else: assert mda.yaml() == text it = iter(mda) for _ in range(20): if ext == "json": - assert next(it).json() + assert next(it).model_dump_json() else: assert next(it).yaml()