Coverage for python / astro_metadata_translator / translator.py: 42%

402 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-14 01:48 -0800

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"""Classes and support code for metadata translation.""" 

13 

14from __future__ import annotations 

15 

16__all__ = ("MetadataTranslator", "StubTranslator", "cache_translation") 

17 

18import importlib 

19import inspect 

20import logging 

21import math 

22import numbers 

23import warnings 

24from abc import abstractmethod 

25from collections.abc import Callable, Iterable, Iterator, Mapping, MutableMapping, Sequence 

26from importlib.metadata import entry_points 

27from typing import TYPE_CHECKING, Any, ClassVar 

28 

29import astropy.io.fits.card 

30import astropy.time 

31import astropy.units as u 

32from astropy.coordinates import Angle 

33 

34from .properties import PROPERTIES, PropertyDefinition 

35 

36if TYPE_CHECKING: 

37 import astropy.coordinates 

38 from lsst.resources import ResourcePathExpression 

39 

40log = logging.getLogger(__name__) 

41 

42# Location of the root of the corrections resource files 

43CORRECTIONS_RESOURCE_ROOT = "corrections" 

44 

45"""Cache of version strings indexed by class.""" 

46_VERSION_CACHE: dict[type, str] = {} 

47 

48 

49def cache_translation(func: Callable, method: str | None = None) -> Callable: 

50 """Cache the result of a translation method. 

51 

52 Parameters 

53 ---------- 

54 func : `~collections.abc.Callable` 

55 Translation method to cache. 

56 method : `str`, optional 

57 Name of the translation method to cache. Not needed if the decorator 

58 is used around a normal method, but necessary when the decorator is 

59 being used in a metaclass. 

60 

61 Returns 

62 ------- 

63 wrapped : `~collections.abc.Callable` 

64 Method wrapped by the caching function. 

65 

66 Notes 

67 ----- 

68 Especially useful when a translation uses many other translation 

69 methods or involves significant computation. 

70 Should be used only on ``to_x()`` methods. 

71 

72 .. code-block:: python 

73 

74 @cache_translation 

75 def to_detector_num(self): 

76 .... 

77 """ 

78 name = func.__name__ if method is None else method 

79 

80 def func_wrapper(self: MetadataTranslator) -> Any: 

81 if name not in self._translation_cache: 

82 self._translation_cache[name] = func(self) 

83 return self._translation_cache[name] 

84 

85 func_wrapper.__doc__ = func.__doc__ 

86 func_wrapper.__name__ = f"{name}_cached" 

87 return func_wrapper 

88 

89 

90class MetadataTranslator: 

91 """Per-instrument metadata translation support. 

92 

93 Parameters 

94 ---------- 

95 header : `dict`-like 

96 Representation of an instrument header that can be manipulated 

97 as if it was a `dict`. 

98 filename : `str`, optional 

99 Name of the file whose header is being translated. For some 

100 datasets with missing header information this can sometimes 

101 allow for some fixups in translations. 

102 """ 

103 

104 # These are all deliberately empty in the base class. 

105 name: str | None = None 

106 """The declared name of the translator.""" 

107 

108 default_search_path: Sequence[str] | None = None 

109 """Default search path to use to locate header correction files.""" 

110 

111 default_resource_package = __name__.split(".")[0] 

112 """Module name to use to locate the correction resources.""" 

113 

114 default_resource_root: str | None = None 

115 """Default package resource path root to use to locate header correction 

116 files within the ``default_resource_package`` package.""" 

117 

118 _trivial_map: dict[str, str | list[str] | tuple[Any, ...]] = {} 

119 """Dict of one-to-one mappings for header translation from standard 

120 property to corresponding keyword.""" 

121 

122 _const_map: dict[str, Any] = {} 

123 """Dict defining a constant for specified standard properties.""" 

124 

125 translators: dict[str, type[MetadataTranslator]] = {} 

126 """All registered metadata translation classes.""" 

127 

128 supported_instrument: str | None = None 

129 """Name of instrument understood by this translation class.""" 

130 

131 all_properties: dict[str, PropertyDefinition] = {} 

132 """All the valid properties for this translator including extensions.""" 

133 

134 extensions: dict[str, PropertyDefinition] = {} 

135 """Extension properties (`str`: `PropertyDefinition`) 

136 

137 Some instruments have important properties beyond the standard set; this is 

138 the place to declare that they exist, and they will be treated in the same 

139 way as the standard set, except that their names will everywhere be 

140 prefixed with ``ext_``. 

141 

142 Each property is indexed by name (`str`), with a corresponding 

143 `PropertyDefinition`. 

144 """ 

145 

146 _sky_observation_types: tuple[str, ...] = ("science", "object") 

147 """Observation types that correspond to an observation where the detector 

148 can see sky photons. This is used by the default implementation of 

149 ``can_see_sky`` determination.""" 

150 

151 _non_sky_observation_types: tuple[str, ...] = ("bias", "dark") 

152 """Observation types that correspond to an observation where the detector 

153 can not see sky photons. This is used by the default implementation of 

154 ``can_see_sky`` determination.""" 

155 

156 # Static typing requires that we define the standard dynamic properties 

157 # statically. 

158 if TYPE_CHECKING: 

159 to_telescope: ClassVar[Callable[[MetadataTranslator], str]] 

160 to_instrument: ClassVar[Callable[[MetadataTranslator], str]] 

161 to_location: ClassVar[Callable[[MetadataTranslator], astropy.coordinates.EarthLocation]] 

162 to_exposure_id: ClassVar[Callable[[MetadataTranslator], int]] 

163 to_visit_id: ClassVar[Callable[[MetadataTranslator], int]] 

164 to_physical_filter: ClassVar[Callable[[MetadataTranslator], str]] 

165 to_datetime_begin: ClassVar[Callable[[MetadataTranslator], astropy.time.Time]] 

166 to_datetime_end: ClassVar[Callable[[MetadataTranslator], astropy.time.Time]] 

167 to_exposure_time: ClassVar[Callable[[MetadataTranslator], u.Quantity]] 

168 to_dark_time: ClassVar[Callable[[MetadataTranslator], u.Quantity]] 

169 to_boresight_airmass: ClassVar[Callable[[MetadataTranslator], float]] 

170 to_boresight_rotation_angle: ClassVar[Callable[[MetadataTranslator], u.Quantity]] 

171 to_boresight_rotation_coord: ClassVar[Callable[[MetadataTranslator], str]] 

172 to_detector_num: ClassVar[Callable[[MetadataTranslator], int]] 

173 to_detector_name: ClassVar[Callable[[MetadataTranslator], str]] 

174 to_detector_serial: ClassVar[Callable[[MetadataTranslator], str]] 

175 to_detector_group: ClassVar[Callable[[MetadataTranslator], str | None]] 

176 to_detector_exposure_id: ClassVar[Callable[[MetadataTranslator], int]] 

177 to_object: ClassVar[Callable[[MetadataTranslator], str]] 

178 to_temperature: ClassVar[Callable[[MetadataTranslator], u.Quantity]] 

179 to_pressure: ClassVar[Callable[[MetadataTranslator], u.Quantity]] 

180 to_relative_humidity: ClassVar[Callable[[MetadataTranslator], float]] 

181 to_tracking_radec: ClassVar[Callable[[MetadataTranslator], astropy.coordinates.SkyCoord]] 

182 to_altaz_begin: ClassVar[Callable[[MetadataTranslator], astropy.coordinates.AltAz]] 

183 to_science_program: ClassVar[Callable[[MetadataTranslator], str]] 

184 to_observation_type: ClassVar[Callable[[MetadataTranslator], str]] 

185 to_observation_id: ClassVar[Callable[[MetadataTranslator], str]] 

186 

187 @classmethod 

188 def defined_in_this_class(cls, name: str) -> bool | None: 

189 """Report if the specified class attribute is defined specifically in 

190 this class. 

191 

192 Parameters 

193 ---------- 

194 name : `str` 

195 Name of the attribute to test. 

196 

197 Returns 

198 ------- 

199 in_class : `bool` 

200 `True` if there is a attribute of that name defined in this 

201 specific subclass. 

202 `False` if the method is not defined in this specific subclass 

203 but is defined in a parent class. 

204 Returns `None` if the attribute is not defined anywhere 

205 in the class hierarchy (which can happen if translators have 

206 typos in their mapping tables). 

207 

208 Notes 

209 ----- 

210 Retrieves the attribute associated with the given name. 

211 Then looks in all the parent classes to determine whether that 

212 attribute comes from a parent class or from the current class. 

213 Attributes are compared using :py:func:`id`. 

214 """ 

215 # The attribute to compare. 

216 if not hasattr(cls, name): 

217 return None 

218 attr_id = id(getattr(cls, name)) 

219 

220 # Get all the classes in the hierarchy 

221 mro = list(inspect.getmro(cls)) 

222 

223 # Remove the first entry from the list since that will be the 

224 # current class 

225 mro.pop(0) 

226 

227 for parent in mro: 

228 # Some attributes may only exist in subclasses. Skip base classes 

229 # that are missing the attribute (such as object). 

230 if hasattr(parent, name): 

231 if id(getattr(parent, name)) == attr_id: 

232 return False 

233 return True 

234 

235 @classmethod 

236 def _make_const_mapping(cls, property_key: str, constant: Any) -> Callable: 

237 """Make a translator method that returns a constant value. 

238 

239 Parameters 

240 ---------- 

241 property_key : `str` 

242 Name of the property to be calculated (for the docstring). 

243 constant : `str` or `numbers.Number` 

244 Value to return for this translator. 

245 

246 Returns 

247 ------- 

248 f : `~collections.abc.Callable` 

249 Function returning the constant. 

250 """ 

251 

252 def constant_translator(self: MetadataTranslator) -> Any: 

253 return constant 

254 

255 if property_key in cls.all_properties: 

256 property_doc = cls.all_properties[property_key].doc 

257 return_type = cls.all_properties[property_key].py_type 

258 else: 

259 return_type = type(constant) 

260 property_doc = f"Returns constant value for '{property_key}' property" 

261 

262 if return_type.__module__ == "builtins": 

263 full_name = return_type.__name__ 

264 else: 

265 full_name = f"{return_type.__module__}.{return_type.__qualname__}" 

266 

267 constant_translator.__doc__ = f"""{property_doc} 

268 

269 Returns 

270 ------- 

271 translation : `{full_name}` 

272 Translated property. 

273 """ 

274 return constant_translator 

275 

276 @classmethod 

277 def _make_trivial_mapping( 

278 cls, 

279 property_key: str, 

280 header_key: str | Sequence[str], 

281 default: Any | None = None, 

282 minimum: Any | None = None, 

283 maximum: Any | None = None, 

284 unit: astropy.unit.Unit | None = None, 

285 checker: Callable | None = None, 

286 ) -> Callable: 

287 """Make a translator method returning a header value. 

288 

289 The header value can be converted to a `~astropy.units.Quantity` 

290 if desired, and can also have its value validated. 

291 

292 See `MetadataTranslator.validate_value()` for details on the use 

293 of default parameters. 

294 

295 Parameters 

296 ---------- 

297 property_key : `str` 

298 Name of the translator to be constructed (for the docstring). 

299 header_key : `str` or `list` of `str` 

300 Name of the key to look up in the header. If a `list` each 

301 key will be tested in turn until one matches. This can deal with 

302 header styles that evolve over time. 

303 default : `numbers.Number` or `astropy.units.Quantity`, `str`, optional 

304 If not `None`, default value to be used if the parameter read from 

305 the header is not defined or if the header is missing. 

306 minimum : `numbers.Number` or `astropy.units.Quantity`, optional 

307 If not `None`, and if ``default`` is not `None`, minimum value 

308 acceptable for this parameter. 

309 maximum : `numbers.Number` or `astropy.units.Quantity`, optional 

310 If not `None`, and if ``default`` is not `None`, maximum value 

311 acceptable for this parameter. 

312 unit : `astropy.units.Unit`, optional 

313 If not `None`, the value read from the header will be converted 

314 to a `~astropy.units.Quantity`. Only supported for numeric values. 

315 checker : `~collections.abc.Callable`, optional 

316 Callback function to be used by the translator method in case the 

317 keyword is not present. Function will be executed as if it is 

318 a method of the translator class. Running without raising an 

319 exception will allow the default to be used. Should usually raise 

320 `KeyError`. 

321 

322 Returns 

323 ------- 

324 t : `~collections.abc.Callable` 

325 Function implementing a translator with the specified 

326 parameters. 

327 """ 

328 if property_key in cls.all_properties: 328 ↛ 332line 328 didn't jump to line 332 because the condition on line 328 was always true

329 property_doc = cls.all_properties[property_key].doc 

330 return_type = cls.all_properties[property_key].str_type 

331 else: 

332 return_type = "str` or `numbers.Number" 

333 property_doc = f"Map '{header_key}' header keyword to '{property_key}' property" 

334 

335 def trivial_translator(self: MetadataTranslator) -> Any: 

336 if unit is not None: 

337 q = self.quantity_from_card( 

338 header_key, unit, default=default, minimum=minimum, maximum=maximum, checker=checker 

339 ) 

340 # Convert to Angle if this quantity is an angle 

341 if return_type == "astropy.coordinates.Angle": 

342 q = Angle(q) 

343 return q 

344 

345 keywords = header_key if isinstance(header_key, list) else [header_key] 

346 for key in keywords: 

347 if self.is_key_ok(key): 

348 value = self._header[key] 

349 if default is not None and not isinstance(value, str): 

350 value = self.validate_value(value, default, minimum=minimum, maximum=maximum) 

351 self._used_these_cards(key) 

352 break 

353 else: 

354 # No keywords found, use default, checking first, or raise 

355 # A None default is only allowed if a checker is provided. 

356 if checker is not None: 

357 try: 

358 checker(self) 

359 except Exception as e: 

360 raise KeyError(f"Could not find {keywords} in header") from e 

361 return default 

362 elif default is not None: 

363 value = default 

364 else: 

365 raise KeyError(f"Could not find {keywords} in header") 

366 

367 # If we know this is meant to be a string, force to a string. 

368 # Sometimes headers represent items as integers which generically 

369 # we want as strings (eg OBSID). Sometimes also floats are 

370 # written as "NaN" strings. 

371 casts = {"str": str, "float": float, "int": int} 

372 if return_type in casts and not isinstance(value, casts[return_type]) and value is not None: 

373 value = casts[return_type](value) 

374 

375 return value 

376 

377 # Docstring inheritance means it is confusing to specify here 

378 # exactly which header value is being used. 

379 trivial_translator.__doc__ = f"""{property_doc} 

380 

381 Returns 

382 ------- 

383 translation : `{return_type}` 

384 Translated value derived from the header. 

385 """ 

386 return trivial_translator 

387 

388 @classmethod 

389 def __init_subclass__(cls, **kwargs: Any) -> None: 

390 """Register all subclasses with the base class and create dynamic 

391 translator methods. 

392 

393 The method provides two facilities. Firstly, every subclass 

394 of `MetadataTranslator` that includes a ``name`` class property is 

395 registered as a translator class that could be selected when automatic 

396 header translation is attempted. Only name translator subclasses that 

397 correspond to a complete instrument. Translation classes providing 

398 generic translation support for multiple instrument translators should 

399 not be named. 

400 

401 The second feature of this method is to convert simple translations 

402 to full translator methods. Sometimes a translation is fixed (for 

403 example a specific instrument name should be used) and rather than 

404 provide a full ``to_property()`` translation method the mapping can be 

405 defined in a class variable named ``_constMap``. Similarly, for 

406 one-to-one trivial mappings from a header to a property, 

407 ``_trivialMap`` can be defined. Trivial mappings are a dict mapping a 

408 generic property to either a header keyword, or a tuple consisting of 

409 the header keyword and a dict containing key value pairs suitable for 

410 the `MetadataTranslator.quantity_from_card` method. 

411 

412 Parameters 

413 ---------- 

414 **kwargs : `dict` 

415 Arbitrary parameters passed to parent class. 

416 """ 

417 super().__init_subclass__(**kwargs) 

418 

419 # Only register classes with declared names 

420 if hasattr(cls, "name") and cls.name is not None: 

421 if cls.name in MetadataTranslator.translators: 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true

422 log.warning( 

423 "%s: Replacing %s translator with %s", 

424 cls.name, 

425 MetadataTranslator.translators[cls.name], 

426 cls, 

427 ) 

428 MetadataTranslator.translators[cls.name] = cls 

429 

430 # Check that we have not inherited constant/trivial mappings from 

431 # parent class that we have already applied. Empty maps are always 

432 # assumed okay 

433 const_map = cls._const_map if cls._const_map and cls.defined_in_this_class("_const_map") else {} 

434 trivial_map = ( 

435 cls._trivial_map if cls._trivial_map and cls.defined_in_this_class("_trivial_map") else {} 

436 ) 

437 

438 # Check for shadowing 

439 trivials = set(trivial_map.keys()) 

440 constants = set(const_map.keys()) 

441 both = trivials & constants 

442 if both: 442 ↛ 443line 442 didn't jump to line 443 because the condition on line 442 was never true

443 log.warning("%s: defined in both const_map and trivial_map: %s", cls.__name__, ", ".join(both)) 

444 

445 all = trivials | constants 

446 for name in all: 

447 if cls.defined_in_this_class(f"to_{name}"): 447 ↛ 450line 447 didn't jump to line 450 because the condition on line 447 was never true

448 # Must be one of trivial or constant. If in both then constant 

449 # overrides trivial. 

450 location = "by _trivial_map" 

451 if name in constants: 

452 location = "by _const_map" 

453 log.warning( 

454 "%s: %s is defined explicitly but will be replaced %s", cls.__name__, name, location 

455 ) 

456 

457 properties = set(PROPERTIES) | {"ext_" + pp for pp in cls.extensions} 

458 cls.all_properties = dict(PROPERTIES) 

459 cls.all_properties.update(cls.extensions) 

460 

461 # Go through the trival mappings for this class and create 

462 # corresponding translator methods 

463 for property_key, header_key in trivial_map.items(): 

464 kwargs = {} 

465 if type(header_key) is tuple: 

466 kwargs = header_key[1] 

467 header_key = header_key[0] 

468 translator = cls._make_trivial_mapping(property_key, header_key, **kwargs) 

469 method = f"to_{property_key}" 

470 translator.__name__ = f"{method}_trivial_in_{cls.__name__}" 

471 setattr(cls, method, cache_translation(translator, method=method)) 

472 if property_key not in properties: 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true

473 log.warning(f"Unexpected trivial translator for '{property_key}' defined in {cls}") 

474 

475 # Go through the constant mappings for this class and create 

476 # corresponding translator methods 

477 for property_key, constant in const_map.items(): 

478 translator = cls._make_const_mapping(property_key, constant) 

479 method = f"to_{property_key}" 

480 translator.__name__ = f"{method}_constant_in_{cls.__name__}" 

481 setattr(cls, method, translator) 

482 if property_key not in properties: 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true

483 log.warning(f"Unexpected constant translator for '{property_key}' defined in {cls}") 

484 

485 def __init__(self, header: Mapping[str, Any], filename: str | None = None) -> None: 

486 self._header = header 

487 self.filename = filename 

488 self._used_cards: set[str] = set() 

489 

490 # Prefix to use for warnings about failed translations 

491 self._log_prefix_cache: str | None = None 

492 

493 # Cache assumes header is read-only once stored in object 

494 self._translation_cache: dict[str, Any] = {} 

495 

496 @classmethod 

497 @abstractmethod 

498 def can_translate(cls, header: Mapping[str, Any], filename: str | None = None) -> bool: 

499 """Indicate whether this translation class can translate the 

500 supplied header. 

501 

502 Parameters 

503 ---------- 

504 header : `dict`-like 

505 Header to convert to standardized form. 

506 filename : `str`, optional 

507 Name of file being translated. 

508 

509 Returns 

510 ------- 

511 can : `bool` 

512 `True` if the header is recognized by this class. `False` 

513 otherwise. 

514 """ 

515 raise NotImplementedError() 

516 

517 @classmethod 

518 def can_translate_with_options( 

519 cls, header: Mapping[str, Any], options: dict[str, Any], filename: str | None = None 

520 ) -> bool: 

521 """Determine if a header can be translated with different criteria. 

522 

523 Parameters 

524 ---------- 

525 header : `dict`-like 

526 Header to convert to standardized form. 

527 options : `dict` 

528 Headers to try to determine whether this header can 

529 be translated by this class. If a card is found it will 

530 be compared with the expected value and will return that 

531 comparison. Each card will be tried in turn until one is 

532 found. 

533 filename : `str`, optional 

534 Name of file being translated. 

535 

536 Returns 

537 ------- 

538 can : `bool` 

539 `True` if the header is recognized by this class. `False` 

540 otherwise. 

541 

542 Notes 

543 ----- 

544 Intended to be used from within `can_translate` implementations 

545 for specific translators. Is not intended to be called directly 

546 from `determine_translator`. 

547 """ 

548 for card, value in options.items(): 

549 if card in header: 

550 return header[card] == value 

551 return False 

552 

553 @classmethod 

554 def determine_translator( 

555 cls, header: Mapping[str, Any], filename: str | None = None 

556 ) -> type[MetadataTranslator]: 

557 """Determine a translation class by examining the header. 

558 

559 Parameters 

560 ---------- 

561 header : `dict`-like 

562 Representation of a header. 

563 filename : `str`, optional 

564 Name of file being translated. 

565 

566 Returns 

567 ------- 

568 translator : `MetadataTranslator` 

569 Translation class that knows how to extract metadata from 

570 the supplied header. 

571 

572 Raises 

573 ------ 

574 ValueError 

575 None of the registered translation classes understood the supplied 

576 header. 

577 """ 

578 file_msg = "" 

579 if filename is not None: 

580 file_msg = f" from {filename}" 

581 for name, trans in cls.translators.items(): 

582 if trans.can_translate(header, filename=filename): 

583 log.debug("Using translation class %s%s", name, file_msg) 

584 return trans 

585 

586 plugins = [p.name for p in entry_points(group="astro_metadata_translators")] 

587 plugin_msg = "" 

588 if plugins: 

589 plugin_names = ", ".join(plugins) 

590 plugin_msg = f". (available plugins: {plugin_names})" 

591 

592 raise ValueError( 

593 f"None of the registered translation classes {list(cls.translators.keys())}" 

594 f" understood this header{file_msg}{plugin_msg}" 

595 ) 

596 

597 @classmethod 

598 def translator_version(cls) -> str: 

599 """Return the version string for this translator class. 

600 

601 Returns 

602 ------- 

603 version : `str` 

604 String identifying the version of this translator. 

605 

606 Notes 

607 ----- 

608 Assumes that the version is available from the ``__version__`` 

609 variable in the parent module. If this is not the case a translator 

610 should subclass this method. 

611 """ 

612 if cls in _VERSION_CACHE: 

613 return _VERSION_CACHE[cls] 

614 

615 version = "unknown" 

616 module_name = cls.__module__ 

617 components = module_name.split(".") 

618 while components: 

619 # This class has already been imported so importing it 

620 # should work. 

621 module = importlib.import_module(".".join(components)) 

622 if hasattr(module, v := "__version__"): 

623 version = getattr(module, v) 

624 if version == "unknown": 

625 # LSST software will have a fingerprint 

626 if hasattr(module, v := "__fingerprint__"): 

627 version = getattr(module, v) 

628 break 

629 else: 

630 # Remove last component from module name and try again 

631 components.pop() 

632 

633 _VERSION_CACHE[cls] = version 

634 return version 

635 

636 @classmethod 

637 def fix_header( 

638 cls, header: MutableMapping[str, Any], instrument: str, obsid: str, filename: str | None = None 

639 ) -> bool: 

640 """Apply global fixes to a supplied header. 

641 

642 Parameters 

643 ---------- 

644 header : `dict` 

645 The header to correct. Correction is in place. 

646 instrument : `str` 

647 The name of the instrument. 

648 obsid : `str` 

649 Unique observation identifier associated with this header. 

650 Will always be provided. 

651 filename : `str`, optional 

652 Filename associated with this header. May not be set since headers 

653 can be fixed independently of any filename being known. 

654 

655 Returns 

656 ------- 

657 modified : `bool` 

658 `True` if a correction was applied. 

659 

660 Notes 

661 ----- 

662 This method is intended to support major discrepancies in headers 

663 such as: 

664 

665 * Periods of time where headers are known to be incorrect in some 

666 way that can be fixed either by deriving the correct value from 

667 the existing value or understanding the that correction is static 

668 for the given time. This requires that the date header is 

669 known. 

670 * The presence of a certain value is always wrong and should be 

671 corrected with a new static value regardless of date. 

672 

673 It is assumed that one off problems with headers have been applied 

674 before this method is called using the per-obsid correction system. 

675 

676 Usually called from `astro_metadata_translator.fix_header`. 

677 

678 For log messages, do not assume that the filename will be present. 

679 Always write log messages to fall back on using the ``obsid`` if 

680 ``filename`` is `None`. 

681 """ 

682 return False 

683 

684 @staticmethod 

685 def _construct_log_prefix(obsid: str, filename: str | None = None) -> str: 

686 """Construct a log prefix string from the obsid and filename. 

687 

688 Parameters 

689 ---------- 

690 obsid : `str` 

691 The observation identifier. 

692 filename : `str`, optional 

693 The filename associated with the header being translated. 

694 Can be `None`. 

695 """ 

696 if filename: 

697 return f"{filename}({obsid})" 

698 return obsid 

699 

700 @property 

701 def _log_prefix(self) -> str: 

702 """Return standard prefix that can be used for log messages to report 

703 useful context. 

704 

705 Will be either the filename and obsid, or just the obsid depending 

706 on whether a filename is known. 

707 

708 Returns 

709 ------- 

710 prefix : `str` 

711 The prefix to use. 

712 """ 

713 if self._log_prefix_cache is None: 

714 # Protect against the unfortunate event of the obsid failing to 

715 # be calculated. This should be rare but should not prevent a log 

716 # message from appearing. 

717 try: 

718 obsid = self.to_observation_id() 

719 except Exception: 

720 obsid = "unknown_obsid" 

721 self._log_prefix_cache = self._construct_log_prefix(obsid, self.filename) 

722 return self._log_prefix_cache 

723 

724 def _used_these_cards(self, *args: str) -> None: 

725 """Indicate that the supplied cards have been used for translation. 

726 

727 Parameters 

728 ---------- 

729 *args : sequence of `str` 

730 Keywords used to process a translation. 

731 """ 

732 self._used_cards.update(set(args)) 

733 

734 def cards_used(self) -> frozenset[str]: 

735 """Cards used during metadata extraction. 

736 

737 Returns 

738 ------- 

739 used : `frozenset` of `str` 

740 Cards used when extracting metadata. 

741 """ 

742 return frozenset(self._used_cards) 

743 

744 @staticmethod 

745 def validate_value( 

746 value: float, default: float, minimum: float | None = None, maximum: float | None = None 

747 ) -> float: 

748 """Validate the supplied value, returning a new value if out of range. 

749 

750 Parameters 

751 ---------- 

752 value : `float` 

753 Value to be validated. 

754 default : `float` 

755 Default value to use if supplied value is invalid or out of range. 

756 Assumed to be in the same units as the value expected in the 

757 header. 

758 minimum : `float` 

759 Minimum possible valid value, optional. If the calculated value 

760 is below this value, the default value will be used. 

761 maximum : `float` 

762 Maximum possible valid value, optional. If the calculated value 

763 is above this value, the default value will be used. 

764 

765 Returns 

766 ------- 

767 value : `float` 

768 Either the supplied value, or a default value. 

769 """ 

770 if value is None or math.isnan(value): 

771 value = default 

772 else: 

773 if minimum is not None and value < minimum: 

774 value = default 

775 elif maximum is not None and value > maximum: 

776 value = default 

777 return value 

778 

779 @staticmethod 

780 def is_keyword_defined(header: Mapping[str, Any], keyword: str | None) -> bool: 

781 """Return `True` if the value associated with the named keyword is 

782 present in the supplied header and defined. 

783 

784 Parameters 

785 ---------- 

786 header : `dict`-lik 

787 Header to use as reference. 

788 keyword : `str` 

789 Keyword to check against header. 

790 

791 Returns 

792 ------- 

793 is_defined : `bool` 

794 `True` if the header is present and not-`None`. `False` otherwise. 

795 """ 

796 if keyword is None or keyword not in header: 

797 return False 

798 

799 if header[keyword] is None: 

800 return False 

801 

802 # Special case Astropy undefined value 

803 if isinstance(header[keyword], astropy.io.fits.card.Undefined): 

804 return False 

805 

806 return True 

807 

808 def resource_root(self) -> tuple[str | None, str | None]: 

809 """Return package resource to use to locate correction resources within 

810 an installed package. 

811 

812 Returns 

813 ------- 

814 resource_package : `str` 

815 Package resource name. `None` if no package resource are to be 

816 used. 

817 resource_root : `str` 

818 The name of the resource root. `None` if no package resources 

819 are to be used. 

820 """ 

821 return (self.default_resource_package, self.default_resource_root) 

822 

823 def search_paths(self) -> list[str]: 

824 """Search paths to use when searching for header fix up correction 

825 files. 

826 

827 Returns 

828 ------- 

829 paths : `list` 

830 Directory paths to search. Can be an empty list if no special 

831 directories are defined. 

832 

833 Notes 

834 ----- 

835 Uses the classes ``default_search_path`` property if defined. 

836 """ 

837 if self.default_search_path is not None: 

838 return list(self.default_search_path) 

839 return [] 

840 

841 def is_key_ok(self, keyword: str | None) -> bool: 

842 """Return `True` if the value associated with the named keyword is 

843 present in this header and defined. 

844 

845 Parameters 

846 ---------- 

847 keyword : `str` 

848 Keyword to check against header. 

849 

850 Returns 

851 ------- 

852 is_ok : `bool` 

853 `True` if the header is present and not-`None`. `False` otherwise. 

854 """ 

855 return self.is_keyword_defined(self._header, keyword) 

856 

857 def are_keys_ok(self, keywords: Iterable[str]) -> bool: 

858 """Are the supplied keys all present and defined?. 

859 

860 Parameters 

861 ---------- 

862 keywords : iterable of `str` 

863 Keywords to test. 

864 

865 Returns 

866 ------- 

867 all_ok : `bool` 

868 `True` if all supplied keys are present and defined. 

869 """ 

870 for k in keywords: 

871 if not self.is_key_ok(k): 

872 return False 

873 return True 

874 

875 def quantity_from_card( 

876 self, 

877 keywords: str | Sequence[str], 

878 unit: u.Unit, 

879 default: float | None = None, 

880 minimum: float | None = None, 

881 maximum: float | None = None, 

882 checker: Callable | None = None, 

883 ) -> u.Quantity: 

884 """Calculate a Astropy Quantity from a header card and a unit. 

885 

886 Parameters 

887 ---------- 

888 keywords : `str` or `list` of `str` 

889 Keyword to use from header. If a list each keyword will be tried 

890 in turn until one matches. 

891 unit : `astropy.units.UnitBase` 

892 Unit of the item in the header. 

893 default : `float`, optional 

894 Default value to use if the header value is invalid. Assumed 

895 to be in the same units as the value expected in the header. If 

896 None, no default value is used. 

897 minimum : `float`, optional 

898 Minimum possible valid value, optional. If the calculated value 

899 is below this value, the default value will be used. 

900 maximum : `float`, optional 

901 Maximum possible valid value, optional. If the calculated value 

902 is above this value, the default value will be used. 

903 checker : `~collections.abc.Callable`, optional 

904 Callback function to be used by the translator method in case the 

905 keyword is not present. Function will be executed as if it is 

906 a method of the translator class. Running without raising an 

907 exception will allow the default to be used. Should usually raise 

908 `KeyError`. 

909 

910 Returns 

911 ------- 

912 q : `astropy.units.Quantity` 

913 Quantity representing the header value. 

914 

915 Raises 

916 ------ 

917 KeyError 

918 The supplied header key is not present. 

919 """ 

920 keyword_list = [keywords] if isinstance(keywords, str) else list(keywords) 

921 for k in keyword_list: 

922 if self.is_key_ok(k): 

923 value = self._header[k] 

924 keyword = k 

925 break 

926 else: 

927 if checker is not None: 

928 try: 

929 checker(self) 

930 value = default 

931 if value is not None: 

932 value = u.Quantity(value, unit=unit) 

933 return value 

934 except Exception: 

935 pass 

936 raise KeyError(f"Could not find {keywords} in header") 

937 if isinstance(value, str): 

938 # Sometimes the header has the wrong type in it but this must 

939 # be a number if we are creating a quantity. 

940 value = float(value) 

941 self._used_these_cards(keyword) 

942 if default is not None: 

943 value = self.validate_value(value, default, maximum=maximum, minimum=minimum) 

944 return u.Quantity(value, unit=unit) 

945 

946 def _join_keyword_values(self, keywords: Iterable[str], delim: str = "+") -> str: 

947 """Join values of all defined keywords with the specified delimiter. 

948 

949 Parameters 

950 ---------- 

951 keywords : iterable of `str` 

952 Keywords to look for in header. 

953 delim : `str`, optional 

954 Character to use to join the values together. 

955 

956 Returns 

957 ------- 

958 joined : `str` 

959 String formed from all the keywords found in the header with 

960 defined values joined by the delimiter. Empty string if no 

961 defined keywords found. 

962 """ 

963 values = [] 

964 for k in keywords: 

965 if self.is_key_ok(k): 

966 values.append(self._header[k]) 

967 self._used_these_cards(k) 

968 

969 if values: 

970 joined = delim.join(str(v) for v in values) 

971 else: 

972 joined = "" 

973 

974 return joined 

975 

976 @cache_translation 

977 def to_detector_unique_name(self) -> str: 

978 """Return a unique name for the detector. 

979 

980 Base class implementation attempts to combine ``detector_name`` with 

981 ``detector_group``. Group is only used if not `None`. 

982 

983 Can be over-ridden by specialist translator class. 

984 

985 Returns 

986 ------- 

987 name : `str` 

988 ``detector_group``_``detector_name`` if ``detector_group`` is 

989 defined, else the ``detector_name`` is assumed to be unique. 

990 If neither return a valid value an exception is raised. 

991 

992 Raises 

993 ------ 

994 NotImplementedError 

995 Raised if neither detector_name nor detector_group is defined. 

996 """ 

997 name = self.to_detector_name() 

998 group = self.to_detector_group() 

999 

1000 if group is None and name is None: 

1001 raise NotImplementedError("Can not determine unique name from detector_group and detector_name") 

1002 

1003 if group is not None: 

1004 return f"{group}_{name}" 

1005 

1006 return name 

1007 

1008 @cache_translation 

1009 def to_exposure_group(self) -> str | None: 

1010 """Return the group label associated with this exposure. 

1011 

1012 Base class implementation returns the ``exposure_id`` in string 

1013 form. A subclass may do something different. 

1014 

1015 Returns 

1016 ------- 

1017 name : `str` 

1018 The ``exposure_id`` converted to a string. 

1019 """ 

1020 exposure_id = self.to_exposure_id() 

1021 if exposure_id is None: 

1022 # mypy does not think this can ever happen but play it safe 

1023 # with subclasses. 

1024 return None # type: ignore 

1025 else: 

1026 return str(exposure_id) 

1027 

1028 @cache_translation 

1029 def to_observation_reason(self) -> str: 

1030 """Return the reason this observation was taken. 

1031 

1032 Base class implementation returns the ``science`` if the 

1033 ``observation_type`` is science, else ``unknown``. 

1034 A subclass may do something different. 

1035 

1036 Returns 

1037 ------- 

1038 name : `str` 

1039 The reason for this observation. 

1040 """ 

1041 obstype = self.to_observation_type() 

1042 if obstype == "science": 

1043 return "science" 

1044 return "unknown" 

1045 

1046 @cache_translation 

1047 def to_exposure_time_requested(self) -> astropy.units.Quantity: 

1048 """Return the requested exposure time in seconds. 

1049 

1050 Base class implementations returns the same value as ``exposure_time``. 

1051 This information may not be available for all instruments. 

1052 

1053 Returns 

1054 ------- 

1055 exptime : `astropy.units.Quantity` 

1056 The recorded exposure time in seconds. 

1057 """ 

1058 return self.to_exposure_time() 

1059 

1060 @cache_translation 

1061 def to_altaz_end(self) -> astropy.coordinates.AltAz | None: 

1062 """Return the AltAz for the end of the observation. 

1063 

1064 Base class implementation returns `None`. Subclasses should override 

1065 if the value is known. 

1066 

1067 Returns 

1068 ------- 

1069 altaz : `astropy.coordinates.AltAz` or `None` 

1070 The AltAz for the end of the observation. 

1071 """ 

1072 return None 

1073 

1074 @classmethod 

1075 def observing_date_to_offset(cls, observing_date: astropy.time.Time) -> astropy.time.TimeDelta | None: 

1076 """Calculate the observing day offset to apply for a given observation. 

1077 

1078 In some cases the definition of the observing day offset has changed 

1079 during the lifetime of the instrument. For example lab data might 

1080 have a different offset to that when the instrument is on the 

1081 telescope. 

1082 

1083 Parameters 

1084 ---------- 

1085 observing_date : `astropy.time.Time` 

1086 The observation date. 

1087 

1088 Returns 

1089 ------- 

1090 offset : `astropy.time.TimeDelta` or `None` 

1091 The offset to apply when calculating the observing day for a 

1092 specific time of observation. `None` implies the offset 

1093 is not known for that date. 

1094 """ 

1095 return None 

1096 

1097 @classmethod 

1098 def observing_date_to_observing_day( 

1099 cls, observing_date: astropy.time.Time, offset: astropy.time.TimeDelta | int | None 

1100 ) -> int: 

1101 """Return the YYYYMMDD integer corresponding to the observing day. 

1102 

1103 The offset is subtracted from the time of observation before 

1104 calculating the year, month and day. 

1105 

1106 Parameters 

1107 ---------- 

1108 observing_date : `astropy.time.Time` 

1109 The observation date. 

1110 offset : `astropy.time.TimeDelta` | `numbers.Real` | None 

1111 The offset to subtract from the observing date when calculating 

1112 the observing day. If a plain number is given it is taken to be 

1113 in units of seconds. If `None` no offset is applied. 

1114 

1115 Returns 

1116 ------- 

1117 day : `int` 

1118 The observing day as an integer of form YYYYMMDD. 

1119 

1120 Notes 

1121 ----- 

1122 For example, if the offset is +12 hours both 2023-07-06T13:00 and 

1123 2023-07-07T11:00 will return an observing day of 20230706 because 

1124 the observing day goes from 2023-07-06T12:00 to 2023-07-07T12:00. 

1125 """ 

1126 observing_date = observing_date.tai 

1127 if offset: 

1128 if isinstance(offset, numbers.Real): 

1129 offset = astropy.time.TimeDelta(offset, format="sec", scale="tai") 

1130 observing_date -= offset 

1131 return int(observing_date.strftime("%Y%m%d")) 

1132 

1133 @cache_translation 

1134 def to_observing_day_offset(self) -> astropy.time.TimeDelta | None: 

1135 """Return the offset required to calculate observing day. 

1136 

1137 Base class implementation returns `None`. 

1138 

1139 Returns 

1140 ------- 

1141 offset : `astropy.time.TimeDelta` or `None` 

1142 The offset to apply. Returns `None` if the offset is not defined. 

1143 

1144 Notes 

1145 ----- 

1146 This offset must be subtracted from a time of observation to calculate 

1147 the observing day. This offset must be added to the YYYYMMDDT00:00 

1148 observing day to calculate the time span coverage of the observing day. 

1149 """ 

1150 datetime_begin = self.to_datetime_begin() 

1151 if datetime_begin is None: 

1152 return None 

1153 return self.observing_date_to_offset(datetime_begin) 

1154 

1155 @cache_translation 

1156 def to_observing_day(self) -> int: 

1157 """Return the YYYYMMDD integer corresponding to the observing day. 

1158 

1159 Base class implementation uses the TAI date of the start of the 

1160 observation corrected by the observing day offset. If that offset 

1161 is `None` no offset will be applied. 

1162 

1163 The offset is subtracted from the time of observation before 

1164 calculating the year, month and day. 

1165 

1166 Returns 

1167 ------- 

1168 day : `int` 

1169 The observing day as an integer of form YYYYMMDD. If the header 

1170 is broken and is unable to obtain a date of observation, ``0`` 

1171 is returned and the assumption is made that the problem will 

1172 be caught elsewhere. 

1173 

1174 Notes 

1175 ----- 

1176 For example, if the offset is +12 hours both 2023-07-06T13:00 and 

1177 2023-07-07T11:00 will return an observing day of 20230706 because 

1178 the observing day goes from 2023-07-06T12:00 to 2023-07-07T12:00. 

1179 """ 

1180 datetime_begin = self.to_datetime_begin() 

1181 if datetime_begin is None: 

1182 return 0 

1183 offset = self.to_observing_day_offset() 

1184 return self.observing_date_to_observing_day(datetime_begin.tai, offset) 

1185 

1186 @cache_translation 

1187 def to_observation_counter(self) -> int: 

1188 """Return an integer corresponding to how this observation relates 

1189 to other observations. 

1190 

1191 Base class implementation returns ``0`` to indicate that it is not 

1192 known how an observatory will define a counter. Some observatories 

1193 may not use the concept, others may use a counter that increases 

1194 for every observation taken for that instrument, and others may 

1195 define it to be a counter within an observing day. 

1196 

1197 Returns 

1198 ------- 

1199 sequence : `int` 

1200 The observation counter. Always ``0`` for this implementation. 

1201 """ 

1202 return 0 

1203 

1204 @cache_translation 

1205 def to_group_counter_start(self) -> int: 

1206 """Return the observation counter of the observation that began 

1207 this group. 

1208 

1209 The definition of the relevant group is up to the metadata 

1210 translator. It can be the first observation in the exposure_group 

1211 or the first observation in the visit, but must be derivable 

1212 from the metadata of this observation. 

1213 

1214 Returns 

1215 ------- 

1216 counter : `int` 

1217 The observation counter for the start of the relevant group. 

1218 Default implementation always returns the observation counter 

1219 of this observation. 

1220 """ 

1221 return self.to_observation_counter() 

1222 

1223 @cache_translation 

1224 def to_group_counter_end(self) -> int: 

1225 """Return the observation counter of the observation that ends 

1226 this group. 

1227 

1228 The definition of the relevant group is up to the metadata 

1229 translator. It can be the last observation in the exposure_group 

1230 or the last observation in the visit, but must be derivable 

1231 from the metadata of this observation. It is of course possible 

1232 that the last observation in the group does not exist if a sequence 

1233 of observations was not completed. 

1234 

1235 Returns 

1236 ------- 

1237 counter : `int` 

1238 The observation counter for the end of the relevant group. 

1239 Default implementation always returns the observation counter 

1240 of this observation. 

1241 """ 

1242 return self.to_observation_counter() 

1243 

1244 @cache_translation 

1245 def to_has_simulated_content(self) -> bool: 

1246 """Return a boolean indicating whether any part of the observation 

1247 was simulated. 

1248 

1249 Returns 

1250 ------- 

1251 is_simulated : `bool` 

1252 `True` if this exposure has simulated content. This can be 

1253 if some parts of the metadata or data were simulated. Default 

1254 implementation always returns `False`. 

1255 """ 

1256 return False 

1257 

1258 @cache_translation 

1259 def to_focus_z(self) -> u.Quantity: 

1260 """Return a default defocal distance of 0.0 mm if there is no 

1261 keyword for defocal distance in the header. The default 

1262 keyword for defocal distance is ``FOCUSZ``. 

1263 

1264 Returns 

1265 ------- 

1266 focus_z: `astropy.units.Quantity` 

1267 The defocal distance from header or the 0.0mm default. 

1268 """ 

1269 return 0.0 * u.mm 

1270 

1271 @cache_translation 

1272 def to_can_see_sky(self) -> bool | None: 

1273 """Return whether the observation can see the sky or not. 

1274 

1275 Returns 

1276 ------- 

1277 can_see_sky : `bool` or `None` 

1278 `True` if the detector is receiving photons from the sky. 

1279 `False` if the sky is not visible to the detector. 

1280 `None` if the metadata translator does not know one way or the 

1281 other. 

1282 

1283 Notes 

1284 ----- 

1285 The base class translator uses a simple heuristic of returning 

1286 `True` if the observation type is "science" or "object" and `False` 

1287 if the observation type is "bias" or "dark". For all other cases it 

1288 will return `None`. 

1289 """ 

1290 obs_type = self.to_observation_type() 

1291 if obs_type is not None: 

1292 obs_type = obs_type.lower() 

1293 

1294 if obs_type in self._sky_observation_types: 

1295 return True 

1296 if obs_type in self._non_sky_observation_types: 

1297 return False 

1298 return None 

1299 

1300 @classmethod 

1301 def determine_translatable_headers( 

1302 cls, filename: ResourcePathExpression, primary: MutableMapping[str, Any] | None = None 

1303 ) -> Iterator[MutableMapping[str, Any]]: 

1304 """Given a file return all the headers usable for metadata translation. 

1305 

1306 This method can optionally be given a header from the file. This 

1307 header will generally be the primary header or a merge of the first 

1308 two headers. 

1309 

1310 In the base class implementation it is assumed that 

1311 this supplied header is the only useful header for metadata translation 

1312 and it will be returned unchanged if given. This can avoid 

1313 unnecessarily re-opening the file and re-reading the header when the 

1314 content is already known. 

1315 

1316 If no header is supplied, a header will be read from the supplied 

1317 file using `~.file_helpers.read_basic_metadata_from_file`, allowing it 

1318 to merge the primary and secondary header of a multi-extension FITS 

1319 file. Subclasses can read the header from the data file using whatever 

1320 technique is best for that instrument. 

1321 

1322 Subclasses can return multiple headers and ignore the externally 

1323 supplied header. They can also merge it with another header and return 

1324 a new derived header if that is required by the particular data file. 

1325 There is no requirement for the supplied header to be used. 

1326 

1327 Parameters 

1328 ---------- 

1329 filename : `str` or `lsst.resources.ResourcePathExpression` 

1330 Path to a file in a format understood by this translator. 

1331 primary : `dict`-like, optional 

1332 The primary header obtained by the caller. This is sometimes 

1333 already known, for example if a system is trying to bootstrap 

1334 without already knowing what data is in the file. For many 

1335 instruments where the primary header is the only relevant 

1336 header, the primary header will be returned with no further 

1337 action. 

1338 

1339 Yields 

1340 ------ 

1341 headers : iterator of `dict`-like 

1342 A header usable for metadata translation. For this base 

1343 implementation it will be either the supplied primary header 

1344 or a header read from the file. This implementation will only 

1345 ever yield a single header. 

1346 

1347 Notes 

1348 ----- 

1349 Each translator class can have code specifically tailored to its 

1350 own file format. It is important not to call this method with 

1351 an incorrect translator class. The normal paradigm is for the 

1352 caller to have read the first header and then called 

1353 `determine_translator()` on the result to work out which translator 

1354 class to then call to obtain the real headers to be used for 

1355 translation. 

1356 """ 

1357 if primary is not None: 

1358 yield primary 

1359 else: 

1360 # Prevent circular import by deferring 

1361 from .file_helpers import read_basic_metadata_from_file 

1362 

1363 # Merge primary and secondary header if they exist. 

1364 header = read_basic_metadata_from_file(filename, -1) 

1365 assert header is not None # for mypy since can_raise=True 

1366 yield header 

1367 

1368 

1369def _make_abstract_translator_method( 

1370 property: str, doc: str, return_typedoc: str, return_type: type 

1371) -> Callable: 

1372 """Create a an abstract translation method for this property. 

1373 

1374 Parameters 

1375 ---------- 

1376 property : `str` 

1377 Name of the translator for property to be created. 

1378 doc : `str` 

1379 Description of the property. 

1380 return_typedoc : `str` 

1381 Type string of this property (used in the doc string). 

1382 return_type : `type` 

1383 Type of this property. 

1384 

1385 Returns 

1386 ------- 

1387 m : `~collections.abc.Callable` 

1388 Translator method for this property. 

1389 """ 

1390 

1391 def to_property(self: MetadataTranslator) -> None: 

1392 raise NotImplementedError(f"Translator for '{property}' undefined.") 

1393 

1394 to_property.__doc__ = f"""Return value of {property} from headers. 

1395 

1396 {doc} 

1397 

1398 Returns 

1399 ------- 

1400 {property} : `{return_typedoc}` 

1401 The translated property. 

1402 """ 

1403 return to_property 

1404 

1405 

1406# Make abstract methods for all the translators methods. 

1407# Unfortunately registering them as abstractmethods does not work 

1408# as these assignments come after the class has been created. 

1409# Assigning to __abstractmethods__ directly does work but interacts 

1410# poorly with the metaclass automatically generating methods from 

1411# _trivialMap and _constMap. 

1412# Note that subclasses that provide extension properties are assumed to not 

1413# need abstract methods created for them. 

1414 

1415# Allow for concrete translator methods to exist in the base class 

1416# These translator methods can be defined in terms of other properties 

1417CONCRETE = set() 

1418 

1419for name, definition in PROPERTIES.items(): 

1420 method = f"to_{name}" 

1421 if not MetadataTranslator.defined_in_this_class(method): 

1422 setattr( 

1423 MetadataTranslator, 

1424 f"to_{name}", 

1425 abstractmethod( 

1426 _make_abstract_translator_method( 

1427 name, definition.doc, definition.str_type, definition.py_type 

1428 ) 

1429 ), 

1430 ) 

1431 else: 

1432 CONCRETE.add(method) 

1433 

1434 

1435class StubTranslator(MetadataTranslator): 

1436 """Translator where all the translations are stubbed out and issue 

1437 warnings. 

1438 

1439 This translator can be used as a base class whilst developing a new 

1440 translator. It allows testing to proceed without being required to fully 

1441 define all translation methods. Once complete the class should be 

1442 removed from the inheritance tree. 

1443 """ 

1444 

1445 pass 

1446 

1447 

1448def _make_forwarded_stub_translator_method( 

1449 cls_: type[MetadataTranslator], property: str, doc: str, return_typedoc: str, return_type: type 

1450) -> Callable: 

1451 """Create a stub translation method for this property that calls the 

1452 base method and catches `NotImplementedError`. 

1453 

1454 Parameters 

1455 ---------- 

1456 cls_ : `type` 

1457 Class to use when referencing `super()`. This would usually be 

1458 `StubTranslator`. 

1459 property : `str` 

1460 Name of the translator for property to be created. 

1461 doc : `str` 

1462 Description of the property. 

1463 return_typedoc : `str` 

1464 Type string of this property (used in the doc string). 

1465 return_type : `type` 

1466 Type of this property. 

1467 

1468 Returns 

1469 ------- 

1470 m : `~collections.abc.Callable` 

1471 Stub translator method for this property. 

1472 """ 

1473 method = f"to_{property}" 

1474 

1475 def to_stub(self: MetadataTranslator) -> Any: 

1476 parent = getattr(super(cls_, self), method, None) 

1477 try: 

1478 if parent is not None: 

1479 return parent() 

1480 except NotImplementedError: 

1481 pass 

1482 

1483 warnings.warn( 

1484 f"Please implement translator for property '{property}' for translator {self}", stacklevel=3 

1485 ) 

1486 return None 

1487 

1488 to_stub.__doc__ = f"""Unimplemented forwarding translator for {property}. 

1489 

1490 {doc} 

1491 

1492 Calls the base class translation method and if that fails with 

1493 `NotImplementedError` issues a warning reminding the implementer to 

1494 override this method. 

1495 

1496 Returns 

1497 ------- 

1498 {property} : `None` or `{return_typedoc}` 

1499 Always returns `None`. 

1500 """ 

1501 return to_stub 

1502 

1503 

1504# Create stub translation methods for each property. These stubs warn 

1505# rather than fail and should be overridden by translators. 

1506for name in PROPERTIES: 

1507 setattr( 

1508 StubTranslator, 

1509 f"to_{name}", 

1510 _make_forwarded_stub_translator_method( 

1511 StubTranslator, # type: ignore 

1512 name, 

1513 definition.doc, 

1514 definition.str_type, 

1515 definition.py_type, 

1516 ), 

1517 )