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

1""" 

2Pydantic integration for Pint Quantity objects. 

3 

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. 

7 

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 

13 

14Examples 

15-------- 

16Use in a Pydantic model: 

17 

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""" 

40 

41import logging 

42from typing import Annotated, Any 

43 

44from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler 

45from pydantic.json_schema import JsonSchemaValue 

46from pydantic_core import core_schema 

47 

48from nexusLIMS.schemas.units import ( 

49 deserialize_quantity, 

50 serialize_quantity, 

51 ureg, 

52) 

53 

54_logger = logging.getLogger(__name__) 

55 

56 

57class _PintQuantityPydanticAnnotation: 

58 """ 

59 Pydantic annotation for Pint Quantity types. 

60 

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 

66 

67 This is an internal implementation class. Users should use the 

68 :const:`PintQuantity` type alias instead. 

69 """ 

70 

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. 

79 

80 This method is called by Pydantic during model initialization to 

81 determine how to validate and serialize Quantity fields. 

82 

83 Parameters 

84 ---------- 

85 _source_type : Any 

86 The source type annotation (unused) 

87 _handler : GetCoreSchemaHandler 

88 Pydantic's schema handler (unused) 

89 

90 Returns 

91 ------- 

92 core_schema.CoreSchema 

93 A Pydantic core schema for Quantity validation 

94 """ 

95 

96 def validate_quantity(value: Any) -> Any: 

97 """ 

98 Validate and normalize a value to a Pint Quantity. 

99 

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) 

105 

106 Parameters 

107 ---------- 

108 value : Any 

109 The value to validate 

110 

111 Returns 

112 ------- 

113 Any 

114 A Pint Quantity or None 

115 

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 

128 

129 # Already a Quantity 

130 if isinstance(value, ureg.Quantity): 

131 return value 

132 

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 

140 

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 

148 

149 # Numeric value (dimensionless) 

150 if isinstance(value, (int, float)): 

151 return ureg.Quantity(value) 

152 

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) 

159 

160 def serialize_quantity_json(value: Any) -> Any: 

161 """ 

162 Serialize a Quantity for JSON output. 

163 

164 Converts Quantity objects to dicts with 'value' and 'units' keys. 

165 Non-Quantity values are serialized as-is. 

166 

167 Parameters 

168 ---------- 

169 value : Any 

170 The value to serialize 

171 

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) 

184 

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 ) 

189 

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 ) 

200 

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. 

209 

210 Provides a JSON schema representation of the Quantity type for 

211 API documentation and OpenAPI specs. 

212 

213 Parameters 

214 ---------- 

215 _core_schema : core_schema.CoreSchema 

216 The core schema (unused) 

217 handler : GetJsonSchemaHandler 

218 Pydantic's JSON schema handler 

219 

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 } 

257 

258 

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. 

266 

267Use this type hint for fields that should accept and validate physical 

268quantities with units. The field will accept: 

269 

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) 

275 

276The field will serialize to JSON as a dict with 'value' and 'units' keys. 

277 

278Examples 

279-------- 

280Define a model with Quantity fields: 

281 

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 

323 

324See Also 

325-------- 

326nexusLIMS.schemas.units : Pint unit registry and utilities 

327nexusLIMS.schemas.metadata : Metadata schemas using PintQuantity fields 

328""" 

329 

330__all__ = ["PintQuantity"]