Coverage for python/lsst/images/serialization/_frozen_schemas.py: 86%

126 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-24 09:07 +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"""Frozen JSON schema files for the serialization data models. 

12 

13Every `~lsst.images.serialization.ArchiveTree` subclass has a canonical JSON 

14Schema derived from its pydantic model. These are written to git-committed 

15``schemas/`` files so the published schema at 

16``https://images.lsst.io/schemas/{name}-{version}`` is a stable artifact 

17rather than whatever the code currently produces, and so superseded versions 

18remain available after the models move on. 

19""" 

20 

21from __future__ import annotations 

22 

23__all__ = ( 

24 "FrozenSchemaError", 

25 "available_schema_classes", 

26 "check_frozen_schemas", 

27 "dump_schema", 

28 "frozen_schema_filename", 

29 "frozen_schema_path", 

30 "schema_dependencies", 

31 "write_frozen_schemas", 

32) 

33 

34import importlib.metadata 

35import json 

36import re 

37from pathlib import Path 

38from typing import Any, TypeVar, get_args, get_origin 

39 

40from ._asdf_utils import ArrayReferenceModel 

41from ._common import ArchiveTree, is_development_version 

42from ._io import ( 

43 _BUILTIN_SCHEMA_PROVIDERS, 

44 _REGISTRY, 

45 _SCHEMA_ENTRY_POINT_GROUP, 

46 class_for_schema, 

47 parameterize_tree, 

48) 

49 

50 

51class FrozenSchemaError(RuntimeError): 

52 """A finalized frozen schema would change without a version bump.""" 

53 

54 

55def available_schema_classes(package: str = "lsst.images") -> list[type[ArchiveTree]]: 

56 """Return every `~lsst.images.serialization.ArchiveTree` subclass owned 

57 by ``package``, sorted by schema name. 

58 

59 Parameters 

60 ---------- 

61 package 

62 Only classes whose defining module is this package (or a 

63 subpackage of it) are returned, so a package freezes and publishes 

64 exactly the schemas it owns, and schema classes created elsewhere 

65 (e.g. test doubles) are never picked up by accident. 

66 

67 Notes 

68 ----- 

69 Candidate schemas come from the in-memory registry, the built-in lazy 

70 providers, and the ``lsst.images.schemas`` entry point group, so an 

71 external package's schemas are found even when nothing has imported 

72 their modules yet. 

73 """ 

74 entry_point_names = { 

75 entry_point.name for entry_point in importlib.metadata.entry_points(group=_SCHEMA_ENTRY_POINT_GROUP) 

76 } 

77 classes: list[type[ArchiveTree]] = [] 

78 for name in sorted(set(_REGISTRY) | set(_BUILTIN_SCHEMA_PROVIDERS) | entry_point_names): 

79 cls = class_for_schema(name) 

80 if cls is None: 80 ↛ 81line 80 didn't jump to line 81 because the condition on line 80 was never true

81 raise RuntimeError(f"Schema {name!r} is registered but its class could not be loaded.") 

82 if cls.__module__ != package and not cls.__module__.startswith(f"{package}."): 

83 continue 

84 classes.append(cls) 

85 return classes 

86 

87 

88def _summary_description(text: str) -> str: 

89 """Return a docstring-derived description trimmed to its summary. 

90 

91 Text is kept up to (but not including) the first numpydoc section header: 

92 a non-blank line immediately followed by a line of dashes at least as long 

93 as it. Text with no such header (e.g. a one-line ``pydantic.Field`` 

94 description) is returned unchanged, so only the sectioned content of a 

95 class docstring (``Notes``, ``Parameters``, ...) is dropped and the summary 

96 plus extended summary are kept. 

97 """ 

98 lines = text.split("\n") 

99 for i in range(len(lines) - 1): 

100 header = lines[i].strip() 

101 underline = lines[i + 1].strip() 

102 if header and re.fullmatch(r"-+", underline) and len(underline) >= len(header): 

103 return "\n".join(lines[:i]).rstrip() 

104 return text 

105 

106 

107def _summarize_descriptions(node: Any) -> None: 

108 """Trim every ``description`` in a schema tree to its summary, in place.""" 

109 if isinstance(node, dict): 

110 description = node.get("description") 

111 if isinstance(description, str): 

112 node["description"] = _summary_description(description) 

113 for value in node.values(): 

114 _summarize_descriptions(value) 

115 elif isinstance(node, list): 

116 for item in node: 

117 _summarize_descriptions(item) 

118 

119 

120def dump_schema(tree_cls: type[ArchiveTree]) -> dict[str, Any]: 

121 """Return the JSON Schema for ``tree_cls``. 

122 

123 Parameters 

124 ---------- 

125 tree_cls 

126 Serialization model class to dump. 

127 

128 Notes 

129 ----- 

130 Generic trees are parameterized over 

131 `~lsst.images.serialization.ArrayReferenceModel`, matching the convention 

132 used by ``lsst-images-admin diagram``. Model descriptions derived from 

133 class docstrings are trimmed to their summary so the numpydoc sections 

134 that follow it do not leak into the published schema. 

135 """ 

136 schema = parameterize_tree(tree_cls, ArrayReferenceModel).model_json_schema() 

137 # A recursive model (e.g. sum_field) produces a root that is just a $ref 

138 # into $defs, with the class's json_schema_extra landing on the $def. 

139 # Hoist the canonical identity to the document root so every frozen 

140 # document self-identifies; $ref siblings are valid in draft 2020-12. 

141 schema.setdefault("$id", f"{tree_cls.SCHEMA_URL_BASE}/{tree_cls.SCHEMA_NAME}-{tree_cls.SCHEMA_VERSION}") 

142 schema.setdefault("title", tree_cls.SCHEMA_NAME) 

143 # Nested ArchiveTree definitions inherit their class's $id, but $id 

144 # starts a new resolution scope in draft 2020-12, which would break the 

145 # root-relative "#/$defs/..." references pydantic generates inside them. 

146 # Record the canonical URL under a non-reserved key instead, which 

147 # validators ignore and documentation tooling can still use to identify 

148 # published sub-schemas. 

149 for definition in schema.get("$defs", {}).values(): 

150 if isinstance(definition, dict) and "$id" in definition: 

151 definition["x-lsst-schema-url"] = definition.pop("$id") 

152 _summarize_descriptions(schema) 

153 return schema 

154 

155 

156def frozen_schema_filename(tree_cls: type[ArchiveTree]) -> str: 

157 """Return the frozen-schema filename for ``tree_cls``. 

158 

159 Parameters 

160 ---------- 

161 tree_cls 

162 Serialization model class to name the file for. 

163 """ 

164 return f"{tree_cls.SCHEMA_NAME}-{tree_cls.SCHEMA_VERSION}.json" 

165 

166 

167def frozen_schema_path(directory: Path, tree_cls: type[ArchiveTree]) -> Path: 

168 """Return the frozen-schema file path for ``tree_cls`` under 

169 ``directory``. 

170 

171 Parameters 

172 ---------- 

173 directory 

174 Directory holding the frozen schema files. 

175 tree_cls 

176 Serialization model class to locate the file for. 

177 

178 Notes 

179 ----- 

180 Files are laid out as ``{name}/{name}-{version}.json``: one 

181 subdirectory per schema so the directory stays navigable as versions 

182 accumulate, with the full name-version filename kept so a file is 

183 self-identifying when copied elsewhere. 

184 """ 

185 return directory / tree_cls.SCHEMA_NAME / frozen_schema_filename(tree_cls) 

186 

187 

188def _canonical_text(schema: dict[str, Any]) -> str: 

189 """Return the canonical file serialization of ``schema``.""" 

190 return json.dumps(schema, indent=2, sort_keys=True) + "\n" 

191 

192 

193def _declaring_classes(tree_cls: type[ArchiveTree]) -> list[type[ArchiveTree]]: 

194 """Return the classes in ``tree_cls``'s MRO that declare a schema, nearest 

195 first. 

196 

197 A class declares a schema when it sets both ``SCHEMA_NAME`` and 

198 ``SCHEMA_VERSION`` itself, the same convention 

199 ``ArchiveTree.__pydantic_init_subclass__`` uses to decide what to register. 

200 The first entry is the schema ``tree_cls`` *is*; any that follow are 

201 schemas it inherits from. A pydantic-parameterized generic (``Image[P]``) 

202 has no declarations of its own, so it resolves to the generic it was made 

203 from. 

204 """ 

205 return [ 

206 base 

207 for base in tree_cls.__mro__ 

208 if isinstance(base, type) 

209 and issubclass(base, ArchiveTree) 

210 and "SCHEMA_NAME" in base.__dict__ 

211 and "SCHEMA_VERSION" in base.__dict__ 

212 ] 

213 

214 

215def _archive_trees_in(annotation: Any) -> set[type[ArchiveTree]]: 

216 """Return every `ArchiveTree` subclass reachable from a type annotation. 

217 

218 Containers, unions and type variable bounds are all descended into, so a 

219 model reached only as ``dict[str, Model | None]`` is still found. Nested 

220 models are not themselves expanded; that is the caller's job. 

221 """ 

222 found: set[type[ArchiveTree]] = set() 

223 stack: list[Any] = [annotation] 

224 seen: set[int] = set() 

225 while stack: 

226 node = stack.pop() 

227 if id(node) in seen: 

228 continue 

229 seen.add(id(node)) 

230 # Check the origin before isinstance(node, type): a parameterized 

231 # builtin such as dict[str, X] answers to both on some Python 

232 # versions, and only the origin branch descends into its arguments. 

233 origin = get_origin(node) 

234 if origin is not None: 

235 stack.append(origin) 

236 stack.extend(get_args(node)) 

237 elif isinstance(node, type): 

238 if issubclass(node, ArchiveTree): 

239 found.add(node) 

240 elif isinstance(node, TypeVar) and node.__bound__ is not None: 

241 stack.append(node.__bound__) 

242 return found 

243 

244 

245def schema_dependencies(tree_cls: type[ArchiveTree]) -> dict[str, type[ArchiveTree]]: 

246 """Return every other schema whose content is inlined into ``tree_cls``'s 

247 frozen document. 

248 

249 Parameters 

250 ---------- 

251 tree_cls 

252 Serialization model class to inspect. 

253 

254 Returns 

255 ------- 

256 `dict` [ `str`, `type` ] 

257 Mapping from ``SCHEMA_NAME`` to the class declaring it, for every 

258 schema ``tree_cls`` depends on, directly or transitively. 

259 ``tree_cls``'s own schema is never included, so a self-referential 

260 model reports nothing. 

261 

262 Notes 

263 ----- 

264 Fields and base classes are both followed. An embedded model records its 

265 version in the document as a ``x-lsst-schema-url``, but pydantic flattens 

266 an inherited model's fields into the subclass and records nothing, so a 

267 base's version cannot be recovered from the document it contributed to. 

268 """ 

269 own = _declaring_classes(tree_cls) 

270 own_name = own[0].SCHEMA_NAME if own else None 

271 result: dict[str, type[ArchiveTree]] = {} 

272 stack: list[type[ArchiveTree]] = [tree_cls] 

273 visited: set[type[ArchiveTree]] = set() 

274 while stack: 

275 current = stack.pop() 

276 if current in visited: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 continue 

278 visited.add(current) 

279 # Skip the first declaration, which is the schema `current` itself is. 

280 candidates = list(_declaring_classes(current)[1:]) 

281 for field in current.model_fields.values(): 

282 for referenced in _archive_trees_in(field.annotation): 

283 candidates.extend(_declaring_classes(referenced)[:1]) 

284 for candidate in candidates: 

285 name = candidate.SCHEMA_NAME 

286 if name == own_name or name in result: 

287 continue 

288 result[name] = candidate 

289 stack.append(candidate) 

290 return result 

291 

292 

293def write_frozen_schemas(directory: Path, package: str = "lsst.images") -> list[Path]: 

294 """Write the frozen schema file for every current schema. 

295 

296 Parameters 

297 ---------- 

298 directory 

299 Directory to write the ``{name}-{version}.json`` files into; created 

300 if necessary. 

301 package 

302 Package whose schemas to freeze; see 

303 `~lsst.images.serialization.available_schema_classes`. 

304 

305 Returns 

306 ------- 

307 `list` [ `pathlib.Path` ] 

308 Paths that were created or rewritten. 

309 

310 Notes 

311 ----- 

312 Schemas at a development version (a PEP 440 ``.devN`` release) are skipped 

313 and never frozen. A finalized schema is frozen only on its first write; an 

314 existing frozen file is immutable, so a live-model change to it raises 

315 rather than overwriting. Frozen files for superseded versions are never 

316 touched, so old schema URLs keep resolving. 

317 

318 A schema is frozen only if every schema it depends on is already finalized; 

319 see `schema_dependencies`. The check runs when a frozen file is first 

320 written, so an already-committed file is never re-validated. 

321 

322 Raises 

323 ------ 

324 FrozenSchemaError 

325 If a finalized schema's frozen file exists and the live model would 

326 change its content; bump ``SCHEMA_VERSION`` instead of overwriting. 

327 Also raised if a schema being frozen depends on a schema still in 

328 development. 

329 """ 

330 changed: list[Path] = [] 

331 for cls in available_schema_classes(package): 

332 if is_development_version(cls.SCHEMA_VERSION): 

333 continue 

334 path = frozen_schema_path(directory, cls) 

335 text = _canonical_text(dump_schema(cls)) 

336 if path.exists(): 

337 if path.read_text() != text: 

338 raise FrozenSchemaError( 

339 f"{cls.SCHEMA_NAME}-{cls.SCHEMA_VERSION} is finalized and frozen; " 

340 "bump SCHEMA_VERSION to change it rather than overwriting the frozen file." 

341 ) 

342 continue 

343 if developing := sorted( 

344 f"{name}-{dependency.SCHEMA_VERSION}" 

345 for name, dependency in schema_dependencies(cls).items() 

346 if is_development_version(dependency.SCHEMA_VERSION) 

347 ): 

348 raise FrozenSchemaError( 

349 f"{cls.SCHEMA_NAME}-{cls.SCHEMA_VERSION} cannot be frozen: it depends on " 

350 f"development schema(s) {', '.join(developing)}, whose content it inlines and " 

351 "which are still free to change. Finalize them first, or return this schema " 

352 "to a development version." 

353 ) 

354 path.parent.mkdir(parents=True, exist_ok=True) 

355 path.write_text(text) 

356 changed.append(path) 

357 return changed 

358 

359 

360def check_frozen_schemas(directory: Path, package: str = "lsst.images") -> list[str]: 

361 """Check the frozen schema files against the current models. 

362 

363 Parameters 

364 ---------- 

365 directory 

366 Directory holding the frozen ``{name}-{version}.json`` files. 

367 package 

368 Package whose schemas to check; see 

369 `~lsst.images.serialization.available_schema_classes`. 

370 

371 Returns 

372 ------- 

373 `list` [ `str` ] 

374 One problem description per current schema whose frozen file is 

375 missing or does not match the current model; empty when the frozen 

376 files are up to date. 

377 

378 Notes 

379 ----- 

380 Schemas at a development version (a PEP 440 ``.devN`` release) are 

381 skipped and not reported as missing. 

382 """ 

383 problems: list[str] = [] 

384 for cls in available_schema_classes(package): 

385 if is_development_version(cls.SCHEMA_VERSION): 

386 continue 

387 path = frozen_schema_path(directory, cls) 

388 if not path.exists(): 

389 problems.append(f"{path.relative_to(directory)}: missing") 

390 elif path.read_text() != _canonical_text(dump_schema(cls)): 

391 problems.append(f"{path.relative_to(directory)}: finalized schema changed; bump SCHEMA_VERSION") 

392 return problems