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

391 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-06 03:48 -0700

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 typing import TYPE_CHECKING, Any, ClassVar 

27 

28import astropy.io.fits.card 

29import astropy.time 

30import astropy.units as u 

31from astropy.coordinates import Angle 

32 

33from .properties import PROPERTIES, PropertyDefinition 

34 

35if TYPE_CHECKING: 

36 import astropy.coordinates 

37 

38log = logging.getLogger(__name__) 

39 

40# Location of the root of the corrections resource files 

41CORRECTIONS_RESOURCE_ROOT = "corrections" 

42 

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

44_VERSION_CACHE: dict[type, str] = dict() 

45 

46 

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

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

49 

50 Parameters 

51 ---------- 

52 func : `~collections.abc.Callable` 

53 Translation method to cache. 

54 method : `str`, optional 

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

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

57 being used in a metaclass. 

58 

59 Returns 

60 ------- 

61 wrapped : `~collections.abc.Callable` 

62 Method wrapped by the caching function. 

63 

64 Notes 

65 ----- 

66 Especially useful when a translation uses many other translation 

67 methods or involves significant computation. 

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

69 

70 .. code-block:: python 

71 

72 @cache_translation 

73 def to_detector_num(self): 

74 .... 

75 """ 

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

77 

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

79 if name not in self._translation_cache: 

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

81 return self._translation_cache[name] 

82 

83 func_wrapper.__doc__ = func.__doc__ 

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

85 return func_wrapper 

86 

87 

88class MetadataTranslator: 

89 """Per-instrument metadata translation support. 

90 

91 Parameters 

92 ---------- 

93 header : `dict`-like 

94 Representation of an instrument header that can be manipulated 

95 as if it was a `dict`. 

96 filename : `str`, optional 

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

98 datasets with missing header information this can sometimes 

99 allow for some fixups in translations. 

100 """ 

101 

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

103 name: str | None = None 

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

105 

106 default_search_path: Sequence[str] | None = None 

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

108 

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

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

111 

112 default_resource_root: str | None = None 

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

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

115 

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

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

118 property to corresponding keyword.""" 

119 

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

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

122 

123 translators: dict[str, type[MetadataTranslator]] = dict() 

124 """All registered metadata translation classes.""" 

125 

126 supported_instrument: str | None = None 

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

128 

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

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

131 

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

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

134 

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

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

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

138 prefixed with ``ext_``. 

139 

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

141 `PropertyDefinition`. 

142 """ 

143 

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

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

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

147 ``can_see_sky`` determination.""" 

148 

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

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

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

152 ``can_see_sky`` determination.""" 

153 

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

155 # statically. 

156 if TYPE_CHECKING: 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

184 

185 @classmethod 

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

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

188 this class. 

189 

190 Parameters 

191 ---------- 

192 name : `str` 

193 Name of the attribute to test. 

194 

195 Returns 

196 ------- 

197 in_class : `bool` 

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

199 specific subclass. 

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

201 but is defined in a parent class. 

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

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

204 typos in their mapping tables). 

205 

206 Notes 

207 ----- 

208 Retrieves the attribute associated with the given name. 

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

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

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

212 """ 

213 # The attribute to compare. 

214 if not hasattr(cls, name): 

215 return None 

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

217 

218 # Get all the classes in the hierarchy 

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

220 

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

222 # current class 

223 mro.pop(0) 

224 

225 for parent in mro: 

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

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

228 if hasattr(parent, name): 

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

230 return False 

231 return True 

232 

233 @classmethod 

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

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

236 

237 Parameters 

238 ---------- 

239 property_key : `str` 

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

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

242 Value to return for this translator. 

243 

244 Returns 

245 ------- 

246 f : `~collections.abc.Callable` 

247 Function returning the constant. 

248 """ 

249 

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

251 return constant 

252 

253 if property_key in cls.all_properties: 

254 property_doc = cls.all_properties[property_key].doc 

255 return_type = cls.all_properties[property_key].py_type 

256 else: 

257 return_type = type(constant) 

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

259 

260 if return_type.__module__ == "builtins": 

261 full_name = return_type.__name__ 

262 else: 

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

264 

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

266 

267 Returns 

268 ------- 

269 translation : `{full_name}` 

270 Translated property. 

271 """ 

272 return constant_translator 

273 

274 @classmethod 

275 def _make_trivial_mapping( 

276 cls, 

277 property_key: str, 

278 header_key: str | Sequence[str], 

279 default: Any | None = None, 

280 minimum: Any | None = None, 

281 maximum: Any | None = None, 

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

283 checker: Callable | None = None, 

284 ) -> Callable: 

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

286 

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

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

289 

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

291 of default parameters. 

292 

293 Parameters 

294 ---------- 

295 property_key : `str` 

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

297 header_key : `str` or `list` of `str` 

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

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

300 header styles that evolve over time. 

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

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

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

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

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

306 acceptable for this parameter. 

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

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

309 acceptable for this parameter. 

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

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

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

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

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

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

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

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

318 `KeyError`. 

319 

320 Returns 

321 ------- 

322 t : `~collections.abc.Callable` 

323 Function implementing a translator with the specified 

324 parameters. 

325 """ 

326 if property_key in cls.all_properties: 326 ↛ 330line 326 didn't jump to line 330, because the condition on line 326 was never false

327 property_doc = cls.all_properties[property_key].doc 

328 return_type = cls.all_properties[property_key].str_type 

329 else: 

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

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

332 

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

334 if unit is not None: 

335 q = self.quantity_from_card( 

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

337 ) 

338 # Convert to Angle if this quantity is an angle 

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

340 q = Angle(q) 

341 return q 

342 

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

344 for key in keywords: 

345 if self.is_key_ok(key): 

346 value = self._header[key] 

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

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

349 self._used_these_cards(key) 

350 break 

351 else: 

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

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

354 if checker is not None: 

355 try: 

356 checker(self) 

357 except Exception: 

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

359 return default 

360 elif default is not None: 

361 value = default 

362 else: 

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

364 

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

366 # Sometimes headers represent items as integers which generically 

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

368 # written as "NaN" strings. 

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

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

371 value = casts[return_type](value) 

372 

373 return value 

374 

375 # Docstring inheritance means it is confusing to specify here 

376 # exactly which header value is being used. 

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

378 

379 Returns 

380 ------- 

381 translation : `{return_type}` 

382 Translated value derived from the header. 

383 """ 

384 return trivial_translator 

385 

386 @classmethod 

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

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

389 translator methods. 

390 

391 The method provides two facilities. Firstly, every subclass 

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

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

394 header translation is attempted. Only name translator subclasses that 

395 correspond to a complete instrument. Translation classes providing 

396 generic translation support for multiple instrument translators should 

397 not be named. 

398 

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

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

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

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

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

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

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

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

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

408 the `MetadataTranslator.quantity_from_card` method. 

409 

410 Parameters 

411 ---------- 

412 **kwargs : `dict` 

413 Arbitrary parameters passed to parent class. 

414 """ 

415 super().__init_subclass__(**kwargs) 

416 

417 # Only register classes with declared names 

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

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

420 log.warning( 

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

422 cls.name, 

423 MetadataTranslator.translators[cls.name], 

424 cls, 

425 ) 

426 MetadataTranslator.translators[cls.name] = cls 

427 

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

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

430 # assumed okay 

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

432 trivial_map = ( 

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

434 ) 

435 

436 # Check for shadowing 

437 trivials = set(trivial_map.keys()) 

438 constants = set(const_map.keys()) 

439 both = trivials & constants 

440 if both: 440 ↛ 441line 440 didn't jump to line 441, because the condition on line 440 was never true

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

442 

443 all = trivials | constants 

444 for name in all: 

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

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

447 # overrides trivial. 

448 location = "by _trivial_map" 

449 if name in constants: 

450 location = "by _const_map" 

451 log.warning( 

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

453 ) 

454 

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

456 cls.all_properties = dict(PROPERTIES) 

457 cls.all_properties.update(cls.extensions) 

458 

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

460 # corresponding translator methods 

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

462 kwargs = {} 

463 if type(header_key) is tuple: 

464 kwargs = header_key[1] 

465 header_key = header_key[0] 

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

467 method = f"to_{property_key}" 

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

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

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

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

472 

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

474 # corresponding translator methods 

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

476 translator = cls._make_const_mapping(property_key, constant) 

477 method = f"to_{property_key}" 

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

479 setattr(cls, method, translator) 

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

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

482 

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

484 self._header = header 

485 self.filename = filename 

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

487 

488 # Prefix to use for warnings about failed translations 

489 self._log_prefix_cache: str | None = None 

490 

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

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

493 

494 @classmethod 

495 @abstractmethod 

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

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

498 supplied header. 

499 

500 Parameters 

501 ---------- 

502 header : `dict`-like 

503 Header to convert to standardized form. 

504 filename : `str`, optional 

505 Name of file being translated. 

506 

507 Returns 

508 ------- 

509 can : `bool` 

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

511 otherwise. 

512 """ 

513 raise NotImplementedError() 

514 

515 @classmethod 

516 def can_translate_with_options( 

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

518 ) -> bool: 

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

520 

521 Parameters 

522 ---------- 

523 header : `dict`-like 

524 Header to convert to standardized form. 

525 options : `dict` 

526 Headers to try to determine whether this header can 

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

528 be compared with the expected value and will return that 

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

530 found. 

531 filename : `str`, optional 

532 Name of file being translated. 

533 

534 Returns 

535 ------- 

536 can : `bool` 

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

538 otherwise. 

539 

540 Notes 

541 ----- 

542 Intended to be used from within `can_translate` implementations 

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

544 from `determine_translator`. 

545 """ 

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

547 if card in header: 

548 return header[card] == value 

549 return False 

550 

551 @classmethod 

552 def determine_translator( 

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

554 ) -> type[MetadataTranslator]: 

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

556 

557 Parameters 

558 ---------- 

559 header : `dict`-like 

560 Representation of a header. 

561 filename : `str`, optional 

562 Name of file being translated. 

563 

564 Returns 

565 ------- 

566 translator : `MetadataTranslator` 

567 Translation class that knows how to extract metadata from 

568 the supplied header. 

569 

570 Raises 

571 ------ 

572 ValueError 

573 None of the registered translation classes understood the supplied 

574 header. 

575 """ 

576 file_msg = "" 

577 if filename is not None: 

578 file_msg = f" from {filename}" 

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

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

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

582 return trans 

583 

584 raise ValueError( 

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

586 f" understood this header{file_msg}" 

587 ) 

588 

589 @classmethod 

590 def translator_version(cls) -> str: 

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

592 

593 Returns 

594 ------- 

595 version : `str` 

596 String identifying the version of this translator. 

597 

598 Notes 

599 ----- 

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

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

602 should subclass this method. 

603 """ 

604 if cls in _VERSION_CACHE: 

605 return _VERSION_CACHE[cls] 

606 

607 version = "unknown" 

608 module_name = cls.__module__ 

609 components = module_name.split(".") 

610 while components: 

611 # This class has already been imported so importing it 

612 # should work. 

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

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

615 version = getattr(module, v) 

616 if version == "unknown": 

617 # LSST software will have a fingerprint 

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

619 version = getattr(module, v) 

620 break 

621 else: 

622 # Remove last component from module name and try again 

623 components.pop() 

624 

625 _VERSION_CACHE[cls] = version 

626 return version 

627 

628 @classmethod 

629 def fix_header( 

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

631 ) -> bool: 

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

633 

634 Parameters 

635 ---------- 

636 header : `dict` 

637 The header to correct. Correction is in place. 

638 instrument : `str` 

639 The name of the instrument. 

640 obsid : `str` 

641 Unique observation identifier associated with this header. 

642 Will always be provided. 

643 filename : `str`, optional 

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

645 can be fixed independently of any filename being known. 

646 

647 Returns 

648 ------- 

649 modified : `bool` 

650 `True` if a correction was applied. 

651 

652 Notes 

653 ----- 

654 This method is intended to support major discrepancies in headers 

655 such as: 

656 

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

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

659 the existing value or understanding the that correction is static 

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

661 known. 

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

663 corrected with a new static value regardless of date. 

664 

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

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

667 

668 Usually called from `astro_metadata_translator.fix_header`. 

669 

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

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

672 ``filename`` is `None`. 

673 """ 

674 return False 

675 

676 @staticmethod 

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

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

679 

680 Parameters 

681 ---------- 

682 obsid : `str` 

683 The observation identifier. 

684 filename : `str`, optional 

685 The filename associated with the header being translated. 

686 Can be `None`. 

687 """ 

688 if filename: 

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

690 return obsid 

691 

692 @property 

693 def _log_prefix(self) -> str: 

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

695 useful context. 

696 

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

698 on whether a filename is known. 

699 

700 Returns 

701 ------- 

702 prefix : `str` 

703 The prefix to use. 

704 """ 

705 if self._log_prefix_cache is None: 

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

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

708 # message from appearing. 

709 try: 

710 obsid = self.to_observation_id() 

711 except Exception: 

712 obsid = "unknown_obsid" 

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

714 return self._log_prefix_cache 

715 

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

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

718 

719 Parameters 

720 ---------- 

721 *args : sequence of `str` 

722 Keywords used to process a translation. 

723 """ 

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

725 

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

727 """Cards used during metadata extraction. 

728 

729 Returns 

730 ------- 

731 used : `frozenset` of `str` 

732 Cards used when extracting metadata. 

733 """ 

734 return frozenset(self._used_cards) 

735 

736 @staticmethod 

737 def validate_value( 

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

739 ) -> float: 

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

741 

742 Parameters 

743 ---------- 

744 value : `float` 

745 Value to be validated. 

746 default : `float` 

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

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

749 header. 

750 minimum : `float` 

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

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

753 maximum : `float` 

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

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

756 

757 Returns 

758 ------- 

759 value : `float` 

760 Either the supplied value, or a default value. 

761 """ 

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

763 value = default 

764 else: 

765 if minimum is not None and value < minimum: 

766 value = default 

767 elif maximum is not None and value > maximum: 

768 value = default 

769 return value 

770 

771 @staticmethod 

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

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

774 present in the supplied header and defined. 

775 

776 Parameters 

777 ---------- 

778 header : `dict`-lik 

779 Header to use as reference. 

780 keyword : `str` 

781 Keyword to check against header. 

782 

783 Returns 

784 ------- 

785 is_defined : `bool` 

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

787 """ 

788 if keyword is None or keyword not in header: 

789 return False 

790 

791 if header[keyword] is None: 

792 return False 

793 

794 # Special case Astropy undefined value 

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

796 return False 

797 

798 return True 

799 

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

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

802 an installed package. 

803 

804 Returns 

805 ------- 

806 resource_package : `str` 

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

808 used. 

809 resource_root : `str` 

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

811 are to be used. 

812 """ 

813 return (self.default_resource_package, self.default_resource_root) 

814 

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

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

817 files. 

818 

819 Returns 

820 ------- 

821 paths : `list` 

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

823 directories are defined. 

824 

825 Notes 

826 ----- 

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

828 """ 

829 if self.default_search_path is not None: 

830 return [p for p in self.default_search_path] 

831 return [] 

832 

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

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

835 present in this header and defined. 

836 

837 Parameters 

838 ---------- 

839 keyword : `str` 

840 Keyword to check against header. 

841 

842 Returns 

843 ------- 

844 is_ok : `bool` 

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

846 """ 

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

848 

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

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

851 

852 Parameters 

853 ---------- 

854 keywords : iterable of `str` 

855 Keywords to test. 

856 

857 Returns 

858 ------- 

859 all_ok : `bool` 

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

861 """ 

862 for k in keywords: 

863 if not self.is_key_ok(k): 

864 return False 

865 return True 

866 

867 def quantity_from_card( 

868 self, 

869 keywords: str | Sequence[str], 

870 unit: u.Unit, 

871 default: float | None = None, 

872 minimum: float | None = None, 

873 maximum: float | None = None, 

874 checker: Callable | None = None, 

875 ) -> u.Quantity: 

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

877 

878 Parameters 

879 ---------- 

880 keywords : `str` or `list` of `str` 

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

882 in turn until one matches. 

883 unit : `astropy.units.UnitBase` 

884 Unit of the item in the header. 

885 default : `float`, optional 

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

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

888 None, no default value is used. 

889 minimum : `float`, optional 

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

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

892 maximum : `float`, optional 

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

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

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

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

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

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

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

900 `KeyError`. 

901 

902 Returns 

903 ------- 

904 q : `astropy.units.Quantity` 

905 Quantity representing the header value. 

906 

907 Raises 

908 ------ 

909 KeyError 

910 The supplied header key is not present. 

911 """ 

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

913 for k in keyword_list: 

914 if self.is_key_ok(k): 

915 value = self._header[k] 

916 keyword = k 

917 break 

918 else: 

919 if checker is not None: 

920 try: 

921 checker(self) 

922 value = default 

923 if value is not None: 

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

925 return value 

926 except Exception: 

927 pass 

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

929 if isinstance(value, str): 

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

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

932 value = float(value) 

933 self._used_these_cards(keyword) 

934 if default is not None: 

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

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

937 

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

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

940 

941 Parameters 

942 ---------- 

943 keywords : iterable of `str` 

944 Keywords to look for in header. 

945 delim : `str`, optional 

946 Character to use to join the values together. 

947 

948 Returns 

949 ------- 

950 joined : `str` 

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

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

953 defined keywords found. 

954 """ 

955 values = [] 

956 for k in keywords: 

957 if self.is_key_ok(k): 

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

959 self._used_these_cards(k) 

960 

961 if values: 

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

963 else: 

964 joined = "" 

965 

966 return joined 

967 

968 @cache_translation 

969 def to_detector_unique_name(self) -> str: 

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

971 

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

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

974 

975 Can be over-ridden by specialist translator class. 

976 

977 Returns 

978 ------- 

979 name : `str` 

980 ``detector_group``_``detector_name`` if ``detector_group`` is 

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

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

983 

984 Raises 

985 ------ 

986 NotImplementedError 

987 Raised if neither detector_name nor detector_group is defined. 

988 """ 

989 name = self.to_detector_name() 

990 group = self.to_detector_group() 

991 

992 if group is None and name is None: 

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

994 

995 if group is not None: 

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

997 

998 return name 

999 

1000 @cache_translation 

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

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

1003 

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

1005 form. A subclass may do something different. 

1006 

1007 Returns 

1008 ------- 

1009 name : `str` 

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

1011 """ 

1012 exposure_id = self.to_exposure_id() 

1013 if exposure_id is None: 

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

1015 # with subclasses. 

1016 return None # type: ignore 

1017 else: 

1018 return str(exposure_id) 

1019 

1020 @cache_translation 

1021 def to_observation_reason(self) -> str: 

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

1023 

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

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

1026 A subclass may do something different. 

1027 

1028 Returns 

1029 ------- 

1030 name : `str` 

1031 The reason for this observation. 

1032 """ 

1033 obstype = self.to_observation_type() 

1034 if obstype == "science": 

1035 return "science" 

1036 return "unknown" 

1037 

1038 @classmethod 

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

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

1041 

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

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

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

1045 telescope. 

1046 

1047 Parameters 

1048 ---------- 

1049 observing_date : `astropy.time.Time` 

1050 The observation date. 

1051 

1052 Returns 

1053 ------- 

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

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

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

1057 is not known for that date. 

1058 """ 

1059 return None 

1060 

1061 @classmethod 

1062 def observing_date_to_observing_day( 

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

1064 ) -> int: 

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

1066 

1067 The offset is subtracted from the time of observation before 

1068 calculating the year, month and day. 

1069 

1070 Parameters 

1071 ---------- 

1072 observing_date : `astropy.time.Time` 

1073 The observation date. 

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

1075 The offset to subtract from the observing date when calculating 

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

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

1078 

1079 Returns 

1080 ------- 

1081 day : `int` 

1082 The observing day as an integer of form YYYYMMDD. 

1083 

1084 Notes 

1085 ----- 

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

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

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

1089 """ 

1090 observing_date = observing_date.tai 

1091 if offset: 

1092 if isinstance(offset, numbers.Real): 

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

1094 observing_date -= offset 

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

1096 

1097 @cache_translation 

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

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

1100 

1101 Base class implementation returns `None`. 

1102 

1103 Returns 

1104 ------- 

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

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

1107 

1108 Notes 

1109 ----- 

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

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

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

1113 """ 

1114 datetime_begin = self.to_datetime_begin() 

1115 if datetime_begin is None: 

1116 return None 

1117 return self.observing_date_to_offset(datetime_begin) 

1118 

1119 @cache_translation 

1120 def to_observing_day(self) -> int: 

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

1122 

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

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

1125 is `None` no offset will be applied. 

1126 

1127 The offset is subtracted from the time of observation before 

1128 calculating the year, month and day. 

1129 

1130 Returns 

1131 ------- 

1132 day : `int` 

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

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

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

1136 be caught elsewhere. 

1137 

1138 Notes 

1139 ----- 

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

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

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

1143 """ 

1144 datetime_begin = self.to_datetime_begin() 

1145 if datetime_begin is None: 

1146 return 0 

1147 offset = self.to_observing_day_offset() 

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

1149 

1150 @cache_translation 

1151 def to_observation_counter(self) -> int: 

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

1153 to other observations. 

1154 

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

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

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

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

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

1160 

1161 Returns 

1162 ------- 

1163 sequence : `int` 

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

1165 """ 

1166 return 0 

1167 

1168 @cache_translation 

1169 def to_group_counter_start(self) -> int: 

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

1171 this group. 

1172 

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

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

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

1176 from the metadata of this observation. 

1177 

1178 Returns 

1179 ------- 

1180 counter : `int` 

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

1182 Default implementation always returns the observation counter 

1183 of this observation. 

1184 """ 

1185 return self.to_observation_counter() 

1186 

1187 @cache_translation 

1188 def to_group_counter_end(self) -> int: 

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

1190 this group. 

1191 

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

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

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

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

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

1197 of observations was not completed. 

1198 

1199 Returns 

1200 ------- 

1201 counter : `int` 

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

1203 Default implementation always returns the observation counter 

1204 of this observation. 

1205 """ 

1206 return self.to_observation_counter() 

1207 

1208 @cache_translation 

1209 def to_has_simulated_content(self) -> bool: 

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

1211 was simulated. 

1212 

1213 Returns 

1214 ------- 

1215 is_simulated : `bool` 

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

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

1218 implementation always returns `False`. 

1219 """ 

1220 return False 

1221 

1222 @cache_translation 

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

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

1225 keyword for defocal distance in the header. The default 

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

1227 

1228 Returns 

1229 ------- 

1230 focus_z: `astropy.units.Quantity` 

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

1232 """ 

1233 return 0.0 * u.mm 

1234 

1235 @cache_translation 

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

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

1238 

1239 Returns 

1240 ------- 

1241 can_see_sky : `bool` or `None` 

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

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

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

1245 other. 

1246 

1247 Notes 

1248 ----- 

1249 The base class translator uses a simple heuristic of returning 

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

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

1252 will return `None`. 

1253 """ 

1254 obs_type = self.to_observation_type() 

1255 if obs_type is not None: 

1256 obs_type = obs_type.lower() 

1257 

1258 if obs_type in self._sky_observation_types: 

1259 return True 

1260 if obs_type in self._non_sky_observation_types: 

1261 return False 

1262 return None 

1263 

1264 @classmethod 

1265 def determine_translatable_headers( 

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

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

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

1269 

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

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

1272 two headers. 

1273 

1274 In the base class implementation it is assumed that 

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

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

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

1278 content is already known. 

1279 

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

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

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

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

1284 technique is best for that instrument. 

1285 

1286 Subclasses can return multiple headers and ignore the externally 

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

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

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

1290 

1291 Parameters 

1292 ---------- 

1293 filename : `str` 

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

1295 primary : `dict`-like, optional 

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

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

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

1299 instruments where the primary header is the only relevant 

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

1301 action. 

1302 

1303 Yields 

1304 ------ 

1305 headers : iterator of `dict`-like 

1306 A header usable for metadata translation. For this base 

1307 implementation it will be either the supplied primary header 

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

1309 ever yield a single header. 

1310 

1311 Notes 

1312 ----- 

1313 Each translator class can have code specifically tailored to its 

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

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

1316 caller to have read the first header and then called 

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

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

1319 translation. 

1320 """ 

1321 if primary is not None: 

1322 yield primary 

1323 else: 

1324 # Prevent circular import by deferring 

1325 from .file_helpers import read_basic_metadata_from_file 

1326 

1327 # Merge primary and secondary header if they exist. 

1328 header = read_basic_metadata_from_file(filename, -1) 

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

1330 yield header 

1331 

1332 

1333def _make_abstract_translator_method( 

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

1335) -> Callable: 

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

1337 

1338 Parameters 

1339 ---------- 

1340 property : `str` 

1341 Name of the translator for property to be created. 

1342 doc : `str` 

1343 Description of the property. 

1344 return_typedoc : `str` 

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

1346 return_type : `type` 

1347 Type of this property. 

1348 

1349 Returns 

1350 ------- 

1351 m : `~collections.abc.Callable` 

1352 Translator method for this property. 

1353 """ 

1354 

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

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

1357 

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

1359 

1360 {doc} 

1361 

1362 Returns 

1363 ------- 

1364 {property} : `{return_typedoc}` 

1365 The translated property. 

1366 """ 

1367 return to_property 

1368 

1369 

1370# Make abstract methods for all the translators methods. 

1371# Unfortunately registering them as abstractmethods does not work 

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

1373# Assigning to __abstractmethods__ directly does work but interacts 

1374# poorly with the metaclass automatically generating methods from 

1375# _trivialMap and _constMap. 

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

1377# need abstract methods created for them. 

1378 

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

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

1381CONCRETE = set() 

1382 

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

1384 method = f"to_{name}" 

1385 if not MetadataTranslator.defined_in_this_class(method): 

1386 setattr( 

1387 MetadataTranslator, 

1388 f"to_{name}", 

1389 abstractmethod( 

1390 _make_abstract_translator_method( 

1391 name, definition.doc, definition.str_type, definition.py_type 

1392 ) 

1393 ), 

1394 ) 

1395 else: 

1396 CONCRETE.add(method) 

1397 

1398 

1399class StubTranslator(MetadataTranslator): 

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

1401 warnings. 

1402 

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

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

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

1406 removed from the inheritance tree. 

1407 """ 

1408 

1409 pass 

1410 

1411 

1412def _make_forwarded_stub_translator_method( 

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

1414) -> Callable: 

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

1416 base method and catches `NotImplementedError`. 

1417 

1418 Parameters 

1419 ---------- 

1420 cls_ : `type` 

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

1422 `StubTranslator`. 

1423 property : `str` 

1424 Name of the translator for property to be created. 

1425 doc : `str` 

1426 Description of the property. 

1427 return_typedoc : `str` 

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

1429 return_type : `type` 

1430 Type of this property. 

1431 

1432 Returns 

1433 ------- 

1434 m : `~collections.abc.Callable` 

1435 Stub translator method for this property. 

1436 """ 

1437 method = f"to_{property}" 

1438 

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

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

1441 try: 

1442 if parent is not None: 

1443 return parent() 

1444 except NotImplementedError: 

1445 pass 

1446 

1447 warnings.warn( 

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

1449 ) 

1450 return None 

1451 

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

1453 

1454 {doc} 

1455 

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

1457 `NotImplementedError` issues a warning reminding the implementer to 

1458 override this method. 

1459 

1460 Returns 

1461 ------- 

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

1463 Always returns `None`. 

1464 """ 

1465 return to_stub 

1466 

1467 

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

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

1470for name, description in PROPERTIES.items(): 

1471 setattr( 

1472 StubTranslator, 

1473 f"to_{name}", 

1474 _make_forwarded_stub_translator_method( 

1475 StubTranslator, name, definition.doc, definition.str_type, definition.py_type # type: ignore 

1476 ), 

1477 )