Coverage for python/lsst/images/serialization/_common.py: 54%

126 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +0000

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "SCHEMA_URL_BASE", 

16 "ArchiveAccessRequiredError", 

17 "ArchiveReadError", 

18 "ArchiveTree", 

19 "ButlerInfo", 

20 "DevelopmentSchemaWarning", 

21 "InvalidComponentError", 

22 "InvalidParameterError", 

23 "JsonRef", 

24 "MetadataValue", 

25 "OpaqueArchiveMetadata", 

26 "is_development_version", 

27 "no_header_updates", 

28 "warn_for_development_schemas", 

29) 

30 

31import operator 

32import warnings 

33from abc import ABC, abstractmethod 

34from collections.abc import Iterator 

35from typing import TYPE_CHECKING, Any, ClassVar, Protocol, Self 

36 

37import astropy.table 

38import astropy.units 

39import pydantic 

40from packaging.version import Version 

41 

42from .._geom import Box 

43from ..utils import is_none 

44 

45try: 

46 from lsst.daf.butler import DatasetProvenance, SerializedDatasetRef 

47except ImportError: 

48 type DatasetProvenance = Any # type: ignore[no-redef] 

49 type SerializedDatasetRef = Any # type: ignore[no-redef] 

50 

51if TYPE_CHECKING: 

52 import astropy.io.fits 

53 

54 from ._input_archive import InputArchive 

55 

56 

57type MetadataValue = ( 

58 pydantic.StrictInt | pydantic.StrictFloat | pydantic.StrictStr | pydantic.StrictBool | None 

59) 

60 

61SCHEMA_URL_BASE = "https://images.lsst.io/schemas" 

62"""Base for the schema URLs of this package's own schemas, used as 

63``{SCHEMA_URL_BASE}/{name}-{version}``. 

64 

65External packages providing their own schemas override 

66`~lsst.images.serialization.ArchiveTree.SCHEMA_URL_BASE` instead, so their 

67schema URLs are minted under a site they control. 

68""" 

69 

70 

71class ButlerInfo(pydantic.BaseModel): 

72 """Information about a butler dataset.""" 

73 

74 dataset: SerializedDatasetRef 

75 provenance: DatasetProvenance = pydantic.Field(default_factory=DatasetProvenance) 

76 

77 

78class JsonRef(pydantic.BaseModel, serialize_by_alias=True): 

79 """Pydantic model for JSON Reference / Pointer (IETF RFC 6901). 

80 

81 Notes 

82 ----- 

83 This model does not do any of the escaping or special-character 

84 interpretation required by the spec; it assumes that's already been done, 

85 so its job is *just* putting a ``$ref`` field inside another model. 

86 """ 

87 

88 ref: str = pydantic.Field(alias="$ref") 

89 

90 

91class ArchiveTree( 

92 pydantic.BaseModel, ABC, ser_json_inf_nan="constants", ser_json_bytes="base64", val_json_bytes="base64" 

93): 

94 """An intermediate base class of `pydantic.BaseModel` that should be used 

95 for all objects that may be used as the top-level tree models written to 

96 archives. 

97 

98 See :ref:`lsst.images-schema-versioning` for how the ``SCHEMA_NAME`` / 

99 ``SCHEMA_VERSION`` / ``MIN_READ_VERSION`` constants and the 

100 ``schema_version`` / ``min_read_version`` / ``schema_url`` fields are used. 

101 """ 

102 

103 SCHEMA_NAME: ClassVar[str] 

104 SCHEMA_VERSION: ClassVar[str] 

105 MIN_READ_VERSION: ClassVar[int] 

106 SCHEMA_URL_BASE: ClassVar[str] = SCHEMA_URL_BASE 

107 """Base for this schema's URL, as ``{SCHEMA_URL_BASE}/{name}-{version}``. 

108 

109 External packages providing their own schemas should override this (once, 

110 on a shared intermediate base class) so their schema URLs are minted 

111 under a documentation site they control rather than images.lsst.io.""" 

112 

113 PUBLIC_TYPE: ClassVar[type] 

114 """In-memory Python type produced by this tree's ``deserialize`` (e.g. 

115 `dict` for a mapping return). Declared explicitly by each concrete 

116 subclass and surfaced by 

117 `~lsst.images.serialization.public_type_for_schema`.""" 

118 

119 # These mirror the SCHEMA_VERSION / MIN_READ_VERSION class constants and 

120 # are never passed on construction, so repr omits them; they say nothing 

121 # about the object that its type does not already say. 

122 schema_version: str = pydantic.Field( 

123 default="1.0.0", 

124 description="Data-model schema version of this tree (major.minor.patch).", 

125 repr=False, 

126 ) 

127 min_read_version: int = pydantic.Field( 

128 default=1, 

129 description="Smallest reader major that can interpret this tree.", 

130 repr=False, 

131 ) 

132 metadata: dict[str, MetadataValue] = pydantic.Field( 

133 default_factory=dict, description="Additional unstructured metadata.", exclude_if=operator.not_ 

134 ) 

135 butler_info: ButlerInfo | None = pydantic.Field( 

136 default=None, 

137 description="Information about the butler dataset backed by this file.", 

138 exclude_if=is_none, 

139 ) 

140 # Populated by the archive machinery rather than by a caller, so repr 

141 # omits it as well. 

142 indirect: list[Any] = pydantic.Field( 

143 default_factory=list, 

144 description="Serialized nested objects that may be saved or read more than once.", 

145 exclude_if=operator.not_, 

146 repr=False, 

147 ) 

148 

149 @pydantic.computed_field(description="Canonical schema URL for this tree.") # type: ignore[prop-decorator] 

150 @property 

151 def schema_url(self) -> str: 

152 """Return the schema URL of this tree's class. 

153 

154 Computed from ``SCHEMA_NAME`` and ``SCHEMA_VERSION`` ClassVars. 

155 """ 

156 cls = type(self) 

157 return f"{cls.SCHEMA_URL_BASE}/{cls.SCHEMA_NAME}-{cls.SCHEMA_VERSION}" 

158 

159 @pydantic.model_validator(mode="after") 

160 def _check_and_normalize_schema_version(self) -> Self: 

161 """Validate and normalise the schema version fields. 

162 

163 Compares the on-tree ``schema_version`` / ``min_read_version`` against 

164 the in-code values from the subclass's ClassVars; raises if 

165 incompatible, otherwise normalises the fields to the in-code values. 

166 """ 

167 cls = type(self) 

168 # ArchiveTree itself is abstract (deserialize is @abstractmethod). 

169 # Subclasses that haven't yet declared SCHEMA_NAME are skipped — this 

170 # matters during incremental rollout and remains a safe no-op 

171 # afterwards (a class-invariants test ensures every concrete subclass 

172 # has the constants). 

173 if not hasattr(cls, "SCHEMA_NAME"): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 return self 

175 _check_compat( 

176 cls.SCHEMA_NAME, 

177 self.schema_version, 

178 self.min_read_version, 

179 cls.SCHEMA_VERSION, 

180 ) 

181 if self.schema_version != cls.SCHEMA_VERSION: 

182 self.schema_version = cls.SCHEMA_VERSION 

183 if self.min_read_version != cls.MIN_READ_VERSION: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 self.min_read_version = cls.MIN_READ_VERSION 

185 return self 

186 

187 @classmethod 

188 def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: 

189 """Inject ``$id`` and ``title`` into the subclass's JSON Schema, and 

190 register the subclass in the schema-name registry. 

191 

192 Populates ``model_config['json_schema_extra']`` with values derived 

193 from the subclass's ``SCHEMA_NAME`` / ``SCHEMA_VERSION`` ClassVars, 

194 then registers the subclass so it can be looked up by schema name. 

195 Subclasses that haven't declared the ClassVars are skipped. 

196 """ 

197 super().__pydantic_init_subclass__(**kwargs) 

198 name = cls.__dict__.get("SCHEMA_NAME") 

199 version = cls.__dict__.get("SCHEMA_VERSION") 

200 if name is None or version is None: 

201 return 

202 json_schema_extra = cls.model_config.get("json_schema_extra") or {} 

203 if isinstance(json_schema_extra, dict): 203 ↛ 215line 203 didn't jump to line 215 because the condition on line 203 was always true

204 existing = dict(json_schema_extra) 

205 # Always override: a subclass of a concrete schema (e.g. 

206 # visit_image subclassing masked_image) inherits its parent's 

207 # already-stamped values through the merged model_config, and 

208 # this hook only runs when the subclass declares its own 

209 # SCHEMA_NAME / SCHEMA_VERSION for these to be derived from. 

210 existing["$id"] = f"{cls.SCHEMA_URL_BASE}/{name}-{version}" 

211 existing["title"] = name 

212 cls.model_config = {**cls.model_config, "json_schema_extra": existing} 

213 # Local import to avoid the _io -> _common circular dependency at 

214 # module load time. 

215 from ._io import register_schema_class 

216 

217 register_schema_class(cls) 

218 

219 @abstractmethod 

220 def deserialize(self, archive: InputArchive[Any], **kwargs: Any) -> Any: 

221 """Return the in-memory object that was serialized to this tree. 

222 

223 Parameters 

224 ---------- 

225 archive 

226 The input archive to read from. 

227 **kwargs 

228 Additional keyword arguments specific to this type. 

229 

230 Raises 

231 ------ 

232 ~lsst.images.serialization.InvalidParameterError 

233 Raised for unsupported ``**kwargs``. 

234 

235 Notes 

236 ----- 

237 Subclass implementations may take additional keyword-only arguments. 

238 Callers that invoke this method without knowing what those might be 

239 should catch `TypeError` and re-raise as 

240 `~lsst.images.serialization.InvalidParameterError` if they pass 

241 additional keyword arguments. 

242 """ 

243 raise NotImplementedError() 

244 

245 def deserialize_component(self, component: str, archive: InputArchive[Any], **kwargs: Any) -> Any: 

246 """Return a component in-memory object that was serialized to this 

247 tree. 

248 

249 Parameters 

250 ---------- 

251 component 

252 Name of the component to read. 

253 archive 

254 The input archive to read from. 

255 **kwargs 

256 Additional keyword arguments specific to this type. 

257 

258 Raises 

259 ------ 

260 ~lsst.images.serialization.InvalidComponentError 

261 Raise if ``component`` is not recognized. 

262 ~lsst.images.serialization.InvalidParameterError 

263 Raised for unsupported ``**kwargs``. 

264 

265 Notes 

266 ----- 

267 The default implementation for this method tries to get an attribute 

268 with the component's name from ``self``, and then: 

269 

270 - returns `None` if it is `None`; 

271 - calls `deserialize` on that object if it is also an 

272 `~lsst.images.serialization.ArchiveTree`; 

273 - returns it directly otherwise. 

274 

275 If there is no such attribute, it raises 

276 `~lsst.images.serialization.InvalidComponentError`. 

277 

278 ``**kwargs`` are forwarded to component `deserialize` methods, but 

279 are otherwise not checked. Subclasses are generally expected to 

280 implement this method to do that checking and handle any components 

281 for which the other will not work, and then delegate to `super` at 

282 the end. 

283 """ 

284 try: 

285 component_model = getattr(self, component) 

286 except AttributeError: 

287 raise InvalidComponentError( 

288 f"Component {component!r} is not recognized by {type(self).__name__}." 

289 ) from None 

290 if component_model is None: 

291 return None 

292 if isinstance(component_model, ArchiveTree): 

293 return component_model.deserialize(archive, **kwargs) 

294 return component_model 

295 

296 

297class DevelopmentSchemaWarning(UserWarning): 

298 """Warning that a file is being written with a development schema.""" 

299 

300 

301def is_development_version(version: str) -> bool: 

302 """Return whether a schema version string is a PEP 440 development release. 

303 

304 Parameters 

305 ---------- 

306 version 

307 Schema version string, e.g. ``1.0.0`` or ``1.0.0.dev0``. 

308 """ 

309 return Version(version).is_devrelease 

310 

311 

312def _iter_archive_trees(obj: Any) -> Iterator[ArchiveTree]: 

313 """Yield every `ArchiveTree` embedded in a serialized tree.""" 

314 if isinstance(obj, ArchiveTree): 

315 yield obj 

316 if isinstance(obj, pydantic.BaseModel): 

317 for field_name in type(obj).model_fields: 

318 yield from _iter_archive_trees(getattr(obj, field_name)) 

319 elif isinstance(obj, list | tuple): 

320 for item in obj: 

321 yield from _iter_archive_trees(item) 

322 elif isinstance(obj, dict): 

323 for value in obj.values(): 

324 yield from _iter_archive_trees(value) 

325 

326 

327def warn_for_development_schemas(root: ArchiveTree) -> None: 

328 """Emit a `DevelopmentSchemaWarning` if a serialized tree contains any 

329 schema still in development. 

330 

331 Parameters 

332 ---------- 

333 root 

334 Top-level serialized tree about to be written. 

335 """ 

336 developing = sorted( 

337 { 

338 tree.schema_url 

339 for tree in _iter_archive_trees(root) 

340 if is_development_version(type(tree).SCHEMA_VERSION) 

341 } 

342 ) 

343 if developing: 

344 warnings.warn( 

345 "Writing a file with development schema(s) " 

346 f"{', '.join(developing)}; such files are not for production and " 

347 "may not remain readable.", 

348 DevelopmentSchemaWarning, 

349 stacklevel=3, 

350 ) 

351 

352 

353class ArchiveReadError(RuntimeError): 

354 """Exception raised when the contents of an archive cannot be read.""" 

355 

356 

357class InvalidParameterError(ArchiveReadError): 

358 """Exception raised by `ArchiveTree.deserialize` or 

359 `ArchiveTree.deserialize_component` when passed an invalid keyword 

360 argument. 

361 """ 

362 

363 

364class InvalidComponentError(ArchiveReadError): 

365 """Exception `ArchiveTree.deserialize_component` when passed an invalid 

366 component name. 

367 """ 

368 

369 

370class ArchiveAccessRequiredError(RuntimeError): 

371 """Exception raised when a deserialization needs data from the file. 

372 

373 Raised by all data-access methods of 

374 `~lsst.images.serialization.DetachedArchive`, signaling that the 

375 requested object cannot be deserialized from the JSON tree alone. 

376 

377 Notes 

378 ----- 

379 This deliberately does not inherit from `ArchiveReadError`: it signals 

380 that a live archive is required rather than that an archive is corrupt, 

381 and it must not be swallowed by ``except ArchiveReadError`` handlers. 

382 """ 

383 

384 

385class OpaqueArchiveMetadata(Protocol): 

386 """Interface for opaque archive metadata. 

387 

388 In addition to implementing the methods defined here, all implementations 

389 must be pickleable. 

390 """ 

391 

392 def copy(self) -> Self | None: 

393 """Copy, reference, or discard metadata when its holding object is 

394 copied. 

395 """ 

396 ... 

397 

398 def subset(self, bbox: Box) -> Self | None: 

399 """Copy, reference, or discard metadata when a subset of its its 

400 holding object is extracted. 

401 

402 Parameters 

403 ---------- 

404 bbox 

405 Bounding box of the subset being extracted. 

406 """ 

407 ... 

408 

409 

410def no_header_updates(header: astropy.io.fits.Header) -> None: 

411 """Do not make any modifications to the given FITS header. 

412 

413 Parameters 

414 ---------- 

415 header 

416 FITS header that is left unchanged. 

417 """ 

418 

419 

420def _parse_major(version: str) -> int: 

421 """Return the integer major component of a major.minor.patch string. 

422 

423 Raises 

424 ------ 

425 ArchiveReadError 

426 If ``version`` is not a non-empty string of the form 

427 ``major.minor.patch`` with integer components. 

428 """ 

429 if not isinstance(version, str) or not version: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

430 raise ArchiveReadError(f"Schema version {version!r} is not a non-empty string.") 

431 head = version.split(".", 1)[0] 

432 try: 

433 return int(head) 

434 except ValueError as exc: 

435 raise ArchiveReadError(f"Schema version {version!r} has non-integer major.") from exc 

436 

437 

438def _check_compat( 

439 name: str, 

440 on_disk_version: str, 

441 on_disk_min_read: int, 

442 in_code_version: str, 

443) -> None: 

444 """Raise `ArchiveReadError` if a tree written with the given 

445 schema_version/min_read_version cannot be read by the current code. 

446 

447 See :ref:`lsst.images-schema-versioning` for the compatibility rule. 

448 """ 

449 in_code_major = _parse_major(in_code_version) 

450 if on_disk_min_read > in_code_major: 

451 raise ArchiveReadError( 

452 f"{name}: tree requires reader major >= {on_disk_min_read}; this release is {in_code_version}." 

453 ) 

454 

455 

456def _check_format_version(name: str, on_disk: int, in_code: int) -> None: 

457 """Raise `ArchiveReadError` if a backend file's container layout 

458 version is newer than this release knows how to read. 

459 """ 

460 if on_disk > in_code: 

461 raise ArchiveReadError( 

462 f"{name}: on-disk container format version {on_disk} is " 

463 f"newer than this release ({in_code}); cannot read." 

464 )