-
Notifications
You must be signed in to change notification settings - Fork 4
Define Pydantic schema on Quantity
#178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3b36116
830b081
4c6e642
a0607d7
6032628
b85b755
e7b209d
0d39777
28689bf
29acaa9
c5ca345
db18d2a
9b0b793
f7078d4
fa71bd5
e97db32
a30adb0
803e05d
827603c
4ccd83c
ed8b118
8a83095
c69d5d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import numpy | ||
| import pytest | ||
|
|
||
| from openff.units import Quantity, unit | ||
|
|
||
| pydantic = pytest.importorskip("pydantic") | ||
|
|
||
|
|
||
| def test_model_definition(): | ||
| """Just define a Pydantic model, which will crash if the schema is bad.""" | ||
|
|
||
| class MyModel(pydantic.BaseModel): | ||
| x: Quantity | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "value", | ||
| [ | ||
| 1.0 * unit.angstrom, | ||
| Quantity("1.0 angstrom"), | ||
| "1.0 angstrom", | ||
| {"magnitude": 1.0, "units": "angstrom"}, | ||
| ], | ||
| ) | ||
| def test_basic_field_validation(value): | ||
| class MyModel(pydantic.BaseModel): | ||
| x: Quantity | ||
|
|
||
| stored_value = MyModel(x=value).x | ||
|
|
||
| assert str(stored_value.units) == "angstrom" | ||
| assert stored_value.m_as("angstrom") == 1.0 | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("serialize_with", ["python", "json"]) | ||
| def test_model_roundtrip(serialize_with): | ||
| """Test that a model can be round-tripped in Python.""" | ||
|
|
||
| class MyModel(pydantic.BaseModel): | ||
| x: Quantity | ||
| y: Quantity | ||
| z: Quantity | ||
|
|
||
| model = MyModel( | ||
| x=1.0 * unit.angstrom, | ||
| y=[2, 3] * unit.amu, | ||
| z={"magnitude": 299.99, "units": "kelvin"}, | ||
| ) | ||
|
|
||
| match serialize_with: | ||
| case "json": | ||
| model_as_json = model.model_dump_json() | ||
| new_model = MyModel.model_validate_json(model_as_json) | ||
| case "python": | ||
| model_as_dict = model.model_dump() | ||
| new_model = MyModel.model_validate(model_as_dict) | ||
|
|
||
| assert new_model.x == Quantity(1.0, unit.angstrom), model.model_dump_json() | ||
| assert (new_model.y == Quantity([2, 3], unit.amu)).all() | ||
| assert type(new_model.y.m) is not list | ||
| assert type(new_model.y.m) is numpy.ndarray | ||
| assert new_model.z == Quantity(299.99, unit.kelvin) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("serialize_with", ["python", "json"]) | ||
| def test_different_iterables(serialize_with): | ||
| class MyModel(pydantic.BaseModel): | ||
| a: Quantity | ||
| b: Quantity | ||
| c: Quantity | ||
|
|
||
| model = MyModel( | ||
| a=(300.0, 300.1) * unit.kelvin, | ||
| b=[12.011, 1.008, 1.008, 0.0] * unit.amu, | ||
| c=4.0 * numpy.eye(3) * unit.angstrom, | ||
| ) | ||
|
|
||
| match serialize_with: | ||
| case "json": | ||
| model_as_json = model.model_dump_json() | ||
| new_model = MyModel.model_validate_json(model_as_json) | ||
| case "python": | ||
| model_as_dict = model.model_dump() | ||
| new_model = MyModel.model_validate(model_as_dict) | ||
|
|
||
| assert new_model.a.shape == (2,) | ||
| assert new_model.b.shape == (4,) | ||
| assert new_model.c.shape == (3, 3) | ||
|
|
||
| assert str(new_model.a.units) == "kelvin" | ||
| assert str(new_model.b.units) == "unified_atomic_mass_unit" | ||
| assert str(new_model.c.units) == "angstrom" | ||
|
|
||
| assert new_model.a[1].m == 300.1 | ||
| assert new_model.b[0].m == 12.011 | ||
| assert new_model.b[-1].m == 0.0 | ||
| assert new_model.c[2, 2].m == 4.0 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,21 +2,34 @@ | |
| Core classes for OpenFF Units | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import uuid | ||
| import warnings | ||
| from typing import TYPE_CHECKING | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| import numpy # possible to make this optional? | ||
| import pint | ||
| from openff.utilities import requires_package | ||
| from pint import Measurement as _Measurement | ||
| from pint import Quantity as _Quantity | ||
| from pint import Unit as _Unit | ||
| from pint.facets.plain.quantity import PlainQuantity as PintQuantity | ||
|
|
||
| from openff.units.utilities import get_defaults_path | ||
|
|
||
| if TYPE_CHECKING: | ||
| import openmm.unit | ||
|
|
||
| try: | ||
| from pydantic import GetCoreSchemaHandler | ||
| from pydantic_core import core_schema | ||
|
|
||
| has_pydantic = True | ||
| except ImportError: | ||
| has_pydantic = False | ||
|
|
||
| __all__ = ( | ||
| "DEFAULT_UNIT_REGISTRY", | ||
| "Measurement", | ||
|
|
@@ -32,7 +45,120 @@ class Unit(pint.UnitRegistry.Unit): | |
| pass | ||
|
|
||
|
|
||
| class Quantity(pint.UnitRegistry.Quantity): | ||
| if has_pydantic: | ||
|
|
||
| class _QuantityMixin: | ||
| @classmethod | ||
| def serialize( | ||
| cls, | ||
| v: PintQuantity, | ||
| info: core_schema.SerializationInfo | None = None, | ||
| ) -> dict | str | PintQuantity: | ||
| to_json = info is not None and info.mode_is_json() | ||
|
|
||
| if to_json: | ||
| magnitude = v.magnitude | ||
|
|
||
| # storing numpy arrays natively works fine in memory in Python, | ||
| # but must be list-ified when serializing to JSON. | ||
| if isinstance(magnitude, numpy.ndarray): | ||
| magnitude = v.magnitude.tolist() | ||
|
|
||
| # TODO: I think this is necessary for handling unit-wrapped arrays, but it is | ||
| # not so performant. Scalar quantities can be directly serialized to much | ||
| # shorter strings | ||
| return json.dumps( | ||
| { | ||
| "magnitude": magnitude, | ||
| "units": str(v.units), | ||
| } | ||
| ) | ||
|
|
||
| return { | ||
| "magnitude": v.magnitude, | ||
| "units": str(v.units), | ||
| } | ||
|
|
||
| @classmethod | ||
| def validate( | ||
| cls, | ||
| v: dict | str | PintQuantity, | ||
| ): | ||
| if isinstance(v, Quantity): | ||
| return v | ||
| elif isinstance(v, str): | ||
| # TODO: A significant wart is that we have to try to guess whether the string is a | ||
| # JSON-serialized quantity or a simple string representation of a quantity. | ||
| # For example: input of "0.9 nanometer" can be passed directly to the | ||
| # Quantity constructor, but "{"magnitude": 0.9, "units": "nanometer"}" cannot | ||
| # as it needs to be unwrapped. A better solution would require a better way | ||
| # of serializing unit-wrapped arrays to JSON | ||
| if "{" in v: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (not blocking) 👍 I was certain I was about to see an |
||
| deserialized = json.loads(v) | ||
| return Quantity( | ||
| deserialized["magnitude"], | ||
| deserialized["units"], | ||
| ) | ||
| else: | ||
| return Quantity(v) | ||
| elif isinstance(v, dict): | ||
| return Quantity(v["magnitude"], v["units"]) | ||
| else: | ||
| # this cannot be accessed with the current core_schema definition - the types of | ||
| # the `v` argument to this method **happen** to be identical to the supported types | ||
| # in the core_schema. If **either** is changed, this clause may be hit | ||
| raise ValueError(f"Invalid type {type(v)} for Quantity") | ||
|
|
||
| @classmethod | ||
| def __get_pydantic_core_schema__( | ||
| cls, | ||
| source_type: Any, | ||
| handler: GetCoreSchemaHandler, | ||
| ) -> core_schema.CoreSchema: | ||
|
|
||
| validate_schema = core_schema.chain_schema( | ||
| [ | ||
| core_schema.union_schema( | ||
| [ | ||
| core_schema.is_instance_schema(PintQuantity), | ||
| core_schema.str_schema(), | ||
| core_schema.dict_schema(), | ||
| # any other types that could be accepted by a quantity field? | ||
| ] | ||
| ), | ||
| core_schema.no_info_plain_validator_function(cls.validate), | ||
| ] | ||
| ) | ||
|
|
||
| validate_json_schema = core_schema.chain_schema( | ||
| [ | ||
| core_schema.union_schema( | ||
| [ | ||
| core_schema.str_schema(coerce_numbers_to_str=True), | ||
| core_schema.dict_schema(), | ||
| ] | ||
| ), | ||
| core_schema.no_info_plain_validator_function(cls.validate), | ||
| ] | ||
| ) | ||
|
|
||
| serialize_schema = core_schema.plain_serializer_function_ser_schema( | ||
| cls.serialize, | ||
| info_arg=True, | ||
| ) | ||
|
|
||
| return core_schema.json_or_python_schema( | ||
| json_schema=validate_json_schema, | ||
| python_schema=validate_schema, | ||
| serialization=serialize_schema, | ||
| ) | ||
| else: | ||
|
|
||
| class _QuantityMixin: | ||
| pass | ||
|
|
||
|
|
||
| class Quantity(_QuantityMixin, PintQuantity): | ||
| """A value with associated units.""" | ||
|
|
||
| def __dask_tokenize__(self): | ||
|
|
@@ -45,7 +171,7 @@ def _dask_finalize(results, func, args, units): | |
|
|
||
|
|
||
| @requires_package("openmm") | ||
| def _to_openmm(self) -> "openmm.unit.Quantity": | ||
| def _to_openmm(self) -> openmm.unit.Quantity: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (not blocking) You'd asked me during synchronous review to flag this.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was concerned this would break things if OpenMM isn't installed, but that's not the case because I turned on deferred annotations ( [openff-units] pixi run -e py312pint24 python define-pydantic-schema ✱
Python 3.12.13 | packaged by conda-forge | (main, Mar 5 2026, 17:06:14) [Clang 19.1.7 ] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import openmm
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'openmm'
>>> from openff.units import *
>>> Quantity("1 picosecond")
<Quantity(1, 'picosecond')> |
||
| """Convert the quantity to an ``openmm.unit.Quantity``. | ||
|
|
||
| Returns | ||
|
|
@@ -84,10 +210,18 @@ class UnitRegistry(pint.UnitRegistry): | |
| Quantity: type[_Quantity] = DEFAULT_UNIT_REGISTRY.Quantity | ||
| Measurement: type[_Measurement] = DEFAULT_UNIT_REGISTRY.Measurement | ||
|
|
||
| pint.set_application_registry(DEFAULT_UNIT_REGISTRY) | ||
|
|
||
| Quantity.to_openmm = _to_openmm # type: ignore[attr-defined] | ||
|
|
||
| if has_pydantic: | ||
| # Re-attach the Pydantic magic to our new Quantity class, which itself was | ||
| # dynamically created by Pint's magic (and lost these methods in the process). | ||
| Quantity.__get_pydantic_core_schema__ = _QuantityMixin.__get_pydantic_core_schema__ # type: ignore[attr-defined] | ||
| Quantity.validate = _QuantityMixin.validate # type: ignore[attr-defined] | ||
| Quantity.serialize = _QuantityMixin.serialize # type: ignore[attr-defined] | ||
|
|
||
|
|
||
| pint.set_application_registry(DEFAULT_UNIT_REGISTRY) | ||
|
|
||
| with warnings.catch_warnings(): | ||
| warnings.simplefilter("ignore") | ||
| Quantity([]) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(not blocking) If performance is a concern, maybe add a test defining/serializing/deserializing a big array quantity and test performance with a timeout (even if the current performance is acceptable, this could prevent other changes in the future from exacerbating performance issues)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Valid concern but deferring this until it's diagnosed as an issue