Skip to content

Commit

Permalink
build: drop support for pydantic 1 (#167)
Browse files Browse the repository at this point in the history
* build: drop pydantic 1

* remove timedelta junk

* use typing extensions

* fix cov

* fix source

* cov
  • Loading branch information
tlambert03 authored Jul 1, 2024
1 parent dff2e19 commit 666a692
Show file tree
Hide file tree
Showing 16 changed files with 62 additions and 111 deletions.
30 changes: 14 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ concurrency:

on:
push:
branches:
- main
tags:
- "v*"
branches: [main]
tags: ["v*"]
pull_request: {}
workflow_dispatch:

Expand All @@ -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
Expand Down Expand Up @@ -85,4 +83,4 @@ jobs:
- uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: './dist/*'
files: "./dist/*"
1 change: 0 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,3 @@ repos:
additional_dependencies:
- types-PyYAML
- pydantic >=2
- pydantic-compat >=0.0.4
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
]

Expand Down Expand Up @@ -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",
Expand Down
5 changes: 2 additions & 3 deletions src/useq/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/useq/_base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
)

import numpy as np
from pydantic_compat import BaseModel
from pydantic import BaseModel

if TYPE_CHECKING:
from pydantic import ConfigDict
Expand Down
2 changes: 1 addition & 1 deletion src/useq/_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/useq/_mda_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions src/useq/_mda_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, ...]:
Expand Down
85 changes: 22 additions & 63 deletions src/useq/_time.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -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)


Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/useq/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/useq/_z.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/useq/pycromanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down
6 changes: 3 additions & 3 deletions tests/fixtures/mda.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 0 additions & 9 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 2 additions & 2 deletions tests/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

0 comments on commit 666a692

Please sign in to comment.