Coverage for nexusLIMS/schemas/pint_types.py: 100%
40 statements
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2026-03-24 05:23 +0000
1"""
2Pydantic integration for Pint Quantity objects.
4This module provides custom Pydantic types and validators for handling Pint Quantity
5objects in Pydantic models. It enables seamless validation and serialization of
6physical quantities with units in NexusLIMS metadata schemas.
8The custom types support:
9- Validation of Quantity objects, strings, and numeric values
10- Automatic conversion to preferred units
11- JSON serialization for API/storage
12- Type hints for IDE support
14Examples
15--------
16Use in a Pydantic model:
18>>> from pydantic import BaseModel
19>>> from nexusLIMS.schemas.pint_types import PintQuantity
20>>> from nexusLIMS.schemas.units import ureg
21>>>
22>>> class MyMetadata(BaseModel):
23... voltage: PintQuantity
24... current: PintQuantity | None = None
25>>>
26>>> # Create from Quantity
27>>> meta = MyMetadata(voltage=ureg.Quantity(10, "kV"))
28>>> print(meta.voltage)
2910 kilovolt
30>>>
31>>> # Create from string
32>>> meta = MyMetadata(voltage="10 kV")
33>>> print(meta.voltage)
3410 kilovolt
35>>>
36>>> # Serialize to JSON
37>>> meta.model_dump()
38{'voltage': {'value': 10.0, 'units': 'kilovolt'}, 'current': None}
39"""
41import logging
42from typing import Annotated, Any
44from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
45from pydantic.json_schema import JsonSchemaValue
46from pydantic_core import core_schema
48from nexusLIMS.schemas.units import (
49 deserialize_quantity,
50 serialize_quantity,
51 ureg,
52)
54_logger = logging.getLogger(__name__)
57class _PintQuantityPydanticAnnotation:
58 """
59 Pydantic annotation for Pint Quantity types.
61 This class implements Pydantic's annotation protocol to enable Quantity
62 objects to be used as field types in Pydantic models. It handles:
63 - Validation of input values (Quantity, string, numeric)
64 - Serialization to JSON-compatible dicts
65 - JSON schema generation for documentation
67 This is an internal implementation class. Users should use the
68 :const:`PintQuantity` type alias instead.
69 """
71 @classmethod
72 def __get_pydantic_core_schema__(
73 cls,
74 _source_type: Any,
75 _handler: GetCoreSchemaHandler,
76 ) -> core_schema.CoreSchema:
77 """
78 Generate the Pydantic core schema for Quantity validation.
80 This method is called by Pydantic during model initialization to
81 determine how to validate and serialize Quantity fields.
83 Parameters
84 ----------
85 _source_type : Any
86 The source type annotation (unused)
87 _handler : GetCoreSchemaHandler
88 Pydantic's schema handler (unused)
90 Returns
91 -------
92 core_schema.CoreSchema
93 A Pydantic core schema for Quantity validation
94 """
96 def validate_quantity(value: Any) -> Any:
97 """
98 Validate and normalize a value to a Pint Quantity.
100 Accepts:
101 - Pint Quantity objects (returned as-is)
102 - Strings like "10 kV" (parsed to Quantity)
103 - Dicts with 'value' and 'units' keys (deserialized)
104 - None (passed through)
106 Parameters
107 ----------
108 value : Any
109 The value to validate
111 Returns
112 -------
113 Any
114 A Pint Quantity or None
116 Raises
117 ------
118 ValueError
119 If the value cannot be converted to a Quantity
120 """
121 # Pass through None
122 if value is None:
123 # Note: Pydantic optimizes the validation call for None in optional
124 # fields. This code path is logically correct, but Pydantic's
125 # optimization means the function isn't actually called and it's just
126 # here for defensiveness.
127 return None # pragma: no cover
129 # Already a Quantity
130 if isinstance(value, ureg.Quantity):
131 return value
133 # Dict from JSON deserialization
134 if isinstance(value, dict):
135 try:
136 return deserialize_quantity(value)
137 except Exception as e:
138 msg = f"Could not deserialize quantity from dict {value}: {e}"
139 raise ValueError(msg) from e
141 # String parsing
142 if isinstance(value, str):
143 try:
144 return ureg.Quantity(value)
145 except Exception as e:
146 msg = f"Could not parse '{value}' as a Pint Quantity: {e}"
147 raise ValueError(msg) from e
149 # Numeric value (dimensionless)
150 if isinstance(value, (int, float)):
151 return ureg.Quantity(value)
153 # Unknown type
154 msg = (
155 f"Cannot convert {type(value).__name__} to Pint Quantity. "
156 f"Expected Quantity, string, dict, or numeric value."
157 )
158 raise ValueError(msg)
160 def serialize_quantity_json(value: Any) -> Any:
161 """
162 Serialize a Quantity for JSON output.
164 Converts Quantity objects to dicts with 'value' and 'units' keys.
165 Non-Quantity values are serialized as-is.
167 Parameters
168 ----------
169 value : Any
170 The value to serialize
172 Returns
173 -------
174 Any
175 Serialized representation
176 """
177 if value is None:
178 # Note: Pydantic optimizes the serializer call for None in optional
179 # fields. This code path is logically correct, but Pydantic's
180 # optimization means the function isn't actually called and it's just
181 # here for defensiveness.
182 return None # pragma: no cover
183 return serialize_quantity(value)
185 # Create a Pydantic schema that uses our validation/serialization functions
186 python_schema = core_schema.no_info_plain_validator_function(
187 validate_quantity,
188 )
190 return core_schema.json_or_python_schema(
191 json_schema=core_schema.no_info_plain_validator_function(
192 validate_quantity,
193 ),
194 python_schema=python_schema,
195 serialization=core_schema.plain_serializer_function_ser_schema(
196 serialize_quantity_json,
197 when_used="json",
198 ),
199 )
201 @classmethod
202 def __get_pydantic_json_schema__(
203 cls,
204 _core_schema: core_schema.CoreSchema,
205 handler: GetJsonSchemaHandler,
206 ) -> JsonSchemaValue:
207 """
208 Generate JSON schema for documentation.
210 Provides a JSON schema representation of the Quantity type for
211 API documentation and OpenAPI specs.
213 Parameters
214 ----------
215 _core_schema : core_schema.CoreSchema
216 The core schema (unused)
217 handler : GetJsonSchemaHandler
218 Pydantic's JSON schema handler
220 Returns
221 -------
222 JsonSchemaValue
223 JSON schema dict
224 """
225 # Return a schema that describes our serialized format
226 return {
227 "oneOf": [
228 {
229 "type": "object",
230 "properties": {
231 "value": {
232 "type": "number",
233 "description": "Numeric value of the quantity",
234 },
235 "units": {
236 "type": "string",
237 "description": "Unit string (e.g., 'kilovolt', etc.)",
238 },
239 },
240 "required": ["value", "units"],
241 "description": "Physical quantity with value and units",
242 },
243 {
244 "type": "string",
245 "description": "Quantity as string (e.g., '10 kV', '5.2 mm')",
246 },
247 {
248 "type": "number",
249 "description": "Dimensionless numeric value",
250 },
251 {
252 "type": "null",
253 "description": "No value",
254 },
255 ],
256 }
259# Public type alias for use in Pydantic models
260PintQuantity = Annotated[
261 Any, # The actual type is ureg.Quantity, but use Any for flexibility
262 _PintQuantityPydanticAnnotation,
263]
264"""
265Type alias for Pint Quantity fields in Pydantic models.
267Use this type hint for fields that should accept and validate physical
268quantities with units. The field will accept:
270- Pint Quantity objects
271- String representations like "10 kV" or "5.2 mm"
272- Dicts with 'value' and 'units' keys (from JSON deserialization)
273- Numeric values (interpreted as dimensionless)
274- None (if field is optional)
276The field will serialize to JSON as a dict with 'value' and 'units' keys.
278Examples
279--------
280Define a model with Quantity fields:
282>>> from pydantic import BaseModel
283>>> from nexusLIMS.schemas.pint_types import PintQuantity
284>>> from nexusLIMS.schemas.units import ureg
285>>>
286>>> class ImageMetadata(BaseModel):
287... voltage: PintQuantity
288... working_distance: PintQuantity | None = None
289...
290>>> # Create from Quantity objects
291>>> meta = ImageMetadata(
292... voltage=ureg.Quantity(10, "kilovolt"),
293... working_distance=ureg.Quantity(5.2, "millimeter"),
294... )
295>>>
296>>> # Create from strings
297>>> meta = ImageMetadata(voltage="10 kV", working_distance="5.2 mm")
298>>>
299>>> # Serialize to JSON
300>>> import json
301>>> print(json.dumps(meta.model_dump(), indent=2))
302{
303 "voltage": {
304 "value": 10.0,
305 "units": "kilovolt"
306 },
307 "working_distance": {
308 "value": 5.2,
309 "units": "millimeter"
310 }
311}
312>>>
313>>> # Deserialize from JSON
314>>> json_data = '''
315... {
316... "voltage": {"value": 15.0, "units": "kilovolt"},
317... "working_distance": {"value": 10.0, "units": "millimeter"}
318... }
319... '''
320>>> meta = ImageMetadata.model_validate_json(json_data)
321>>> print(meta.voltage)
32215.0 kilovolt
324See Also
325--------
326nexusLIMS.schemas.units : Pint unit registry and utilities
327nexusLIMS.schemas.metadata : Metadata schemas using PintQuantity fields
328"""
330__all__ = ["PintQuantity"]