Coverage for python/astro_metadata_translator/serialize/flat_dict.py: 100%

153 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 09:05 +0000

1# This file is part of astro_metadata_translator. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

6# See the LICENSE 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 

12"""Flatten the simple form of an ObservationInfo into scalar key/value pairs. 

13 

14The simple form produced by `ObservationInfo.to_simple` contains tuples 

15for some properties. A FITS header can only hold scalars, so each 

16element of a tuple is written to its own key with an ``_N`` index 

17suffix. Keys can carry an opaque prefix joined by a separator and can 

18be upper-cased to suit FITS conventions. The reverse direction finds 

19the keys again case-insensitively, with or without knowing the prefix. 

20""" 

21 

22from __future__ import annotations 

23 

24__all__ = ( 

25 "FITS_FLAT_DICT_PREFIX", 

26 "FITS_FLAT_DICT_SEP", 

27 "FlatValue", 

28 "detect_prefix_and_sep", 

29 "find_flat_keys", 

30 "find_translator_name", 

31 "flatten_simple", 

32 "make_flat_key", 

33 "unflatten", 

34 "validate_sep", 

35) 

36 

37import math 

38import re 

39from collections.abc import Collection, Mapping 

40from typing import Any 

41 

42from ..properties import PROPERTIES 

43 

44FITS_FLAT_DICT_PREFIX = "OBS_INFO" 

45"""Recommended key prefix when writing to a FITS header.""" 

46 

47FITS_FLAT_DICT_SEP = " " 

48"""Recommended key separator when writing to a FITS header.""" 

49 

50FlatValue = str | int | float | bool 

51"""Scalar value types permitted in a flat dict.""" 

52 

53_FLAT_TYPES = (str, int, float, bool) 

54 

55_TRANSLATOR_FLAT = "translator" 

56"""Flat name used for the ``_translator`` entry of the simple form.""" 

57 

58_TRANSLATOR_SIMPLE = "_translator" 

59_EXT_PREFIX = "ext_" 

60_CORE_NAMES = frozenset(PROPERTIES) | {_TRANSLATOR_FLAT} 

61 

62# Longest names first so the regex alternation prefers the full name. 

63_NAME_ALTERNATION = "|".join(re.escape(n) for n in sorted(_CORE_NAMES, key=len, reverse=True)) 

64 

65# A lower-cased key is an optional prefix and one-character non-word 

66# separator, then a core name, then an optional ``_N`` index. The greedy 

67# prefix group and the end anchor let a prefix contain the separator. 

68_DETECT_RE = re.compile( 

69 rf"^(?:(?P<prefix>.+)(?P<sep>[^\w]))?(?P<name>{_NAME_ALTERNATION})(?:_(?P<idx>[0-9]+))?$" 

70) 

71 

72_INDEX_RE = re.compile(r"^(?P<stem>.+)_(?P<idx>[0-9]+)$") 

73 

74 

75def validate_sep(sep: str) -> None: 

76 """Check that a separator is usable in flat keys. 

77 

78 Parameters 

79 ---------- 

80 sep : `str` 

81 Separator to check. 

82 

83 Raises 

84 ------ 

85 ValueError 

86 Raised if the separator is not exactly one character or is a 

87 letter, digit, or underscore. Those characters appear inside 

88 property names and would make keys ambiguous. 

89 """ 

90 if len(sep) != 1: 

91 raise ValueError(f"Separator for flat keys must be a single character. Got {sep!r}.") 

92 if re.match(r"\w$", sep): 

93 raise ValueError(f"Separator for flat keys can not be a letter, digit, or underscore. Got {sep!r}.") 

94 

95 

96def make_flat_key(flat_name: str, *, prefix: str, sep: str, use_upper: bool) -> str: 

97 """Build the full key for a flat name. 

98 

99 Parameters 

100 ---------- 

101 flat_name : `str` 

102 Flat property name, for example ``datetime_begin_0``. 

103 prefix : `str` 

104 Prefix emitted exactly as given. Empty for no prefix. 

105 sep : `str` 

106 Separator placed between the prefix and the flat name. Unused 

107 when the prefix is empty. 

108 use_upper : `bool` 

109 If `True` the flat name is upper-cased. 

110 

111 Returns 

112 ------- 

113 key : `str` 

114 The full key. 

115 """ 

116 name = flat_name.upper() if use_upper else flat_name 

117 if prefix: 

118 return f"{prefix}{sep}{name}" 

119 return name 

120 

121 

122def flatten_simple( 

123 simple: Mapping[str, Any], 

124 *, 

125 prefix: str = "", 

126 sep: str = ".", 

127 use_upper: bool | None = None, 

128 encode_non_finite: bool = False, 

129) -> dict[str, FlatValue]: 

130 """Flatten a simple-form mapping into scalar key/value pairs. 

131 

132 Parameters 

133 ---------- 

134 simple : `~collections.abc.Mapping` [`str`, `~typing.Any`] 

135 Mapping as returned by `ObservationInfo.to_simple`. 

136 prefix : `str`, optional 

137 Prefix for every key. Emitted exactly as given. 

138 sep : `str`, optional 

139 Separator between prefix and flat name. Must be one character 

140 that is not a letter, digit, or underscore. 

141 use_upper : `bool` or `None`, optional 

142 If `None` the flat names are upper-cased when the first 

143 character of ``prefix`` is upper case, otherwise left lower 

144 case. `True` or `False` force the case. 

145 encode_non_finite : `bool`, optional 

146 If `True`, ``NaN`` and infinite floats are written as the strings 

147 ``NAN``, ``+INFINITY``, and ``-INFINITY``, the spellings cfitsio 

148 and afw use for such values in FITS headers. By default they are 

149 passed through as floats. 

150 

151 Returns 

152 ------- 

153 flat : `dict` [`str`, `str` | `int` | `float` | `bool`] 

154 Flat mapping. Each element of a list or tuple value becomes its 

155 own entry named ``{name}_{index}`` counting from 0. The 

156 ``_translator`` entry is renamed ``translator``. 

157 

158 Raises 

159 ------ 

160 ValueError 

161 Raised if ``sep`` is not valid. 

162 TypeError 

163 Raised if a value is not a `str`, `int`, `float`, `bool`, or a 

164 one-level list or tuple of those. 

165 """ 

166 validate_sep(sep) 

167 if use_upper is None: 

168 # Follow the case of the prefix when the caller has no preference. 

169 use_upper = prefix[0].isupper() if prefix else False 

170 flat: dict[str, FlatValue] = {} 

171 

172 def _add(flat_name: str, value: Any, property_name: str) -> None: 

173 if not isinstance(value, _FLAT_TYPES): 

174 raise TypeError( 

175 f"Value for property {property_name!r} must be str, int, float, or bool " 

176 f"(or a flat sequence of those). Got {type(value).__name__}." 

177 ) 

178 if encode_non_finite and isinstance(value, float) and not math.isfinite(value): 

179 value = _encode_non_finite(value) 

180 flat[make_flat_key(flat_name, prefix=prefix, sep=sep, use_upper=use_upper)] = value 

181 

182 for name, value in simple.items(): 

183 flat_name = _TRANSLATOR_FLAT if name == _TRANSLATOR_SIMPLE else name 

184 if isinstance(value, list | tuple): 

185 for i, element in enumerate(value): 

186 _add(f"{flat_name}_{i}", element, name) 

187 else: 

188 _add(flat_name, value, name) 

189 return flat 

190 

191 

192def _split_index(flat_name: str) -> tuple[str, int | None]: 

193 """Split a trailing ``_N`` index from a flat name, if present. 

194 

195 Parameters 

196 ---------- 

197 flat_name : `str` 

198 Lower-case flat name such as ``datetime_begin_0``. 

199 

200 Returns 

201 ------- 

202 stem : `str` 

203 The name without the index, or the whole name if there is none. 

204 index : `int` or `None` 

205 The index, or `None` if the name has no ``_N`` suffix. 

206 """ 

207 match = _INDEX_RE.match(flat_name) 

208 if match is None: 

209 return flat_name, None 

210 return match.group("stem"), int(match.group("idx")) 

211 

212 

213def _is_flat_name(flat_name: str) -> bool: 

214 """Report whether a lower-case name could have come from flattening. 

215 

216 Parameters 

217 ---------- 

218 flat_name : `str` 

219 Lower-case candidate with any prefix already removed. 

220 

221 Returns 

222 ------- 

223 is_flat : `bool` 

224 `True` for a core property name, ``translator``, an indexed core 

225 property name, or any name starting with ``ext_``. 

226 """ 

227 if flat_name.startswith(_EXT_PREFIX): 

228 return len(flat_name) > len(_EXT_PREFIX) 

229 if flat_name in _CORE_NAMES: 

230 return True 

231 stem, idx = _split_index(flat_name) 

232 return idx is not None and stem in PROPERTIES 

233 

234 

235def detect_prefix_and_sep( 

236 flat: Mapping[str, Any], *, anchor_names: Collection[str] | None = None 

237) -> tuple[str, str] | None: 

238 """Work out the prefix and separator used for flat keys in a mapping. 

239 

240 Parameters 

241 ---------- 

242 flat : `~collections.abc.Mapping` [`str`, `~typing.Any`] 

243 Mapping that may contain keys written by `flatten_simple` 

244 alongside unrelated keys, for example a FITS header. 

245 anchor_names : `~collections.abc.Collection` [`str`] or `None`, optional 

246 Property names whose indexed form, such as ``datetime_begin_0``, 

247 may anchor detection. `None` allows any core property name. 

248 Callers that know which properties are tuple-valued should pass 

249 them so that an unrelated key such as ``OBJECT_1`` can not 

250 anchor. 

251 

252 Returns 

253 ------- 

254 detected : `tuple` [`str`, `str`] or `None` 

255 Lower-cased ``(prefix, sep)``. Both are empty strings when the 

256 keys have no prefix. `None` if no flat keys were found. 

257 

258 Raises 

259 ------ 

260 ValueError 

261 Raised if two prefixes match equally well. 

262 

263 Notes 

264 ----- 

265 This is a heuristic. Core property names such as ``object`` and 

266 ``temperature`` also occur as ordinary FITS cards, so every key that 

267 looks like a core name votes for its ``(prefix, sep)`` and the group 

268 with the most distinct names wins. Only groups that include an 

269 indexed name such as ``datetime_begin_0`` are considered, because 

270 every flattened observation has one and ordinary headers do not. 

271 """ 

272 votes: dict[tuple[str, str], set[str]] = {} 

273 anchored: set[tuple[str, str]] = set() 

274 anchors = None if anchor_names is None else {name.lower() for name in anchor_names} 

275 for key in flat: 

276 # Non-string keys can never match a flat name. 

277 match = _DETECT_RE.match(str(key).lower()) 

278 if match is None: 

279 continue 

280 group = (match.group("prefix") or "", match.group("sep") or "") 

281 votes.setdefault(group, set()).add(match.group("name")) 

282 if match.group("idx") is not None and (anchors is None or match.group("name") in anchors): 

283 anchored.add(group) 

284 

285 candidates = {group: names for group, names in votes.items() if group in anchored} 

286 if not candidates: 

287 return None 

288 ranked = sorted(candidates.items(), key=lambda item: len(item[1]), reverse=True) 

289 if len(ranked) > 1 and len(ranked[0][1]) == len(ranked[1][1]): 

290 raise ValueError( 

291 "Can not determine the ObservationInfo key prefix: " 

292 f"{ranked[0][0]!r} and {ranked[1][0]!r} match equally well." 

293 ) 

294 return ranked[0][0] 

295 

296 

297def find_flat_keys( 

298 flat: Mapping[str, Any], 

299 *, 

300 prefix: str | None = None, 

301 sep: str = ".", 

302 anchor_names: Collection[str] | None = None, 

303 unique: bool = True, 

304) -> dict[str, str]: 

305 """Find the keys in a mapping that were written by `flatten_simple`. 

306 

307 Parameters 

308 ---------- 

309 flat : `~collections.abc.Mapping` [`str`, `~typing.Any`] 

310 Mapping to scan. Keys are matched case-insensitively. 

311 prefix : `str` or `None`, optional 

312 Prefix used when the keys were written. `None` detects the 

313 prefix and separator with `detect_prefix_and_sep`, in which case 

314 ``sep`` is ignored. Any other value, including the empty string, 

315 is used as given. 

316 sep : `str`, optional 

317 Separator used when the keys were written. Only used with an 

318 explicit non-empty ``prefix``. 

319 anchor_names : `~collections.abc.Collection` [`str`] or `None`, optional 

320 Passed to `detect_prefix_and_sep` when detecting the prefix. 

321 unique : `bool`, optional 

322 If `True` two keys that collapse to the same flat name, for 

323 example differing only in case, raise `ValueError`. If `False` 

324 both are returned, which suits callers that remove keys. 

325 

326 Returns 

327 ------- 

328 found : `dict` [`str`, `str`] 

329 Each matching key exactly as it appears in ``flat``, mapped to 

330 its lower-case flat name with the prefix removed. Empty if 

331 nothing was found. 

332 

333 Raises 

334 ------ 

335 ValueError 

336 Raised if ``sep`` is invalid, if detection finds a tie, or if 

337 ``unique`` is `True` and two keys collapse to the same flat name. 

338 """ 

339 if prefix is None: 

340 detected = detect_prefix_and_sep(flat, anchor_names=anchor_names) 

341 if detected is None: 

342 return {} 

343 prefix, sep = detected 

344 elif prefix: 

345 validate_sep(sep) 

346 

347 lead = f"{prefix}{sep}".lower() if prefix else "" 

348 found: dict[str, str] = {} 

349 seen: dict[str, str] = {} 

350 for key in flat: 

351 # Non-string keys can never match a flat name. 

352 lower = str(key).lower() 

353 if lead: 

354 if not lower.startswith(lead): 

355 continue 

356 flat_name = lower[len(lead) :] 

357 else: 

358 flat_name = lower 

359 if not _is_flat_name(flat_name): 

360 continue 

361 if unique and flat_name in seen: 

362 raise ValueError(f"Keys {seen[flat_name]!r} and {key!r} both map to flat name {flat_name!r}.") 

363 seen[flat_name] = key 

364 found[key] = flat_name 

365 return found 

366 

367 

368def find_translator_name(flat: Mapping[str, Any], flat_keys: Mapping[str, str]) -> str | None: 

369 """Return the translator name recorded in flat keys, if any. 

370 

371 Parameters 

372 ---------- 

373 flat : `~collections.abc.Mapping` [`str`, `~typing.Any`] 

374 The mapping holding the values. 

375 flat_keys : `~collections.abc.Mapping` [`str`, `str`] 

376 Keys found by `find_flat_keys`. 

377 

378 Returns 

379 ------- 

380 name : `str` or `None` 

381 The translator name, or `None` if the key is absent. 

382 """ 

383 for key, flat_name in flat_keys.items(): 

384 if flat_name == _TRANSLATOR_FLAT: 

385 return str(flat[key]) 

386 return None 

387 

388 

389def _encode_non_finite(value: float) -> str: 

390 """Return the FITS string form of a non-finite float. 

391 

392 Parameters 

393 ---------- 

394 value : `float` 

395 A ``NaN`` or infinite value. 

396 

397 Returns 

398 ------- 

399 encoded : `str` 

400 ``NAN``, ``+INFINITY``, or ``-INFINITY``, as written by cfitsio. 

401 """ 

402 if math.isnan(value): 

403 return "NAN" 

404 return "+INFINITY" if value > 0 else "-INFINITY" 

405 

406 

407def _decode_float(value: Any, name: str) -> Any: 

408 """Convert a string holding a float back to a float. 

409 

410 Parameters 

411 ---------- 

412 value : `~typing.Any` 

413 Value read for a float property. Anything other than a `str` is 

414 returned unchanged. 

415 name : `str` 

416 Property name, used in the error message. 

417 

418 Returns 

419 ------- 

420 decoded : `~typing.Any` 

421 The float parsed from the string, or the original value. 

422 

423 Raises 

424 ------ 

425 ValueError 

426 Raised if the string does not parse as a float. 

427 """ 

428 if not isinstance(value, str): 

429 return value 

430 try: 

431 return float(value) 

432 except ValueError: 

433 raise ValueError(f"Value {value!r} for float property {name!r} is not a number.") from None 

434 

435 

436def unflatten( 

437 flat: Mapping[str, Any], 

438 flat_keys: Mapping[str, str], 

439 *, 

440 extension_names: Collection[str] = (), 

441 expected_lengths: Mapping[str, int] | None = None, 

442 float_names: Collection[str] = (), 

443) -> dict[str, Any]: 

444 """Rebuild the simple form from flat keys. 

445 

446 Parameters 

447 ---------- 

448 flat : `~collections.abc.Mapping` [`str`, `~typing.Any`] 

449 The mapping holding the values. 

450 flat_keys : `~collections.abc.Mapping` [`str`, `str`] 

451 Keys found by `find_flat_keys`, mapping each original key to 

452 its lower-case flat name. 

453 extension_names : `~collections.abc.Collection` [`str`], optional 

454 Extension property names (without the ``ext_`` prefix) defined 

455 by the relevant translator. A trailing ``_N`` on an ``ext_`` key 

456 is treated as an index only when the stem is one of these, and 

457 the original spelling is restored in the result. 

458 expected_lengths : `~collections.abc.Mapping` [`str`, `int`], optional 

459 Required tuple length for each indexed name. A name listed here 

460 must have exactly that many elements. Names not listed only need 

461 a contiguous run of indexes starting at 0. 

462 float_names : `~collections.abc.Collection` [`str`], optional 

463 Simple-form names of properties whose values, or tuple elements, 

464 are floats. A `str` found for one of these is converted with 

465 `float`, which accepts the ``NAN``, ``+INFINITY``, and 

466 ``-INFINITY`` strings written by `flatten_simple`, cfitsio, and 

467 afw, as well as the ``NaN`` and ``Infinity`` spellings used in 

468 JSON. 

469 

470 Returns 

471 ------- 

472 simple : `dict` [`str`, `~typing.Any`] 

473 Simple form suitable for `ObservationInfo.from_simple`. Indexed 

474 entries are gathered into tuples and ``translator`` is renamed 

475 back to ``_translator``. 

476 

477 Raises 

478 ------ 

479 ValueError 

480 Raised if the indexes for a name do not form a contiguous run 

481 starting at 0, or do not match the expected length. Also raised 

482 if a name appears both as a single value and as indexed values, 

483 or if a string for a name in ``float_names`` is not a number. 

484 """ 

485 ext_by_lower = {f"{_EXT_PREFIX}{name.lower()}": f"{_EXT_PREFIX}{name}" for name in extension_names} 

486 

487 scalars: dict[str, Any] = {} 

488 indexed: dict[str, dict[int, Any]] = {} 

489 for key, flat_name in flat_keys.items(): 

490 value = flat[key] 

491 stem, idx = _split_index(flat_name) 

492 if idx is not None and (stem in PROPERTIES or stem in ext_by_lower): 

493 indexed.setdefault(stem, {})[idx] = value 

494 else: 

495 scalars[flat_name] = value 

496 

497 if conflicts := sorted(scalars.keys() & indexed.keys()): 

498 raise ValueError(f"Properties {conflicts} appear both as a single value and as indexed values.") 

499 

500 for stem, parts in indexed.items(): 

501 n_expected = len(parts) if expected_lengths is None else expected_lengths.get(stem, len(parts)) 

502 expected = list(range(n_expected)) 

503 if sorted(parts) != expected: 

504 raise ValueError( 

505 f"Indexed values for {stem!r} must have indexes {expected}. Got indexes {sorted(parts)}." 

506 ) 

507 scalars[stem] = tuple(parts[i] for i in expected) 

508 

509 simple: dict[str, Any] = {} 

510 for flat_name, value in scalars.items(): 

511 if flat_name == _TRANSLATOR_FLAT: 

512 simple[_TRANSLATOR_SIMPLE] = value 

513 else: 

514 simple[ext_by_lower.get(flat_name, flat_name)] = value 

515 

516 for name in float_names: 

517 if name not in simple: 

518 continue 

519 value = simple[name] 

520 if isinstance(value, tuple): 

521 simple[name] = tuple(_decode_float(element, name) for element in value) 

522 else: 

523 simple[name] = _decode_float(value, name) 

524 return simple