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

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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.
12"""Classes and support code for metadata translation"""
14__all__ = ("MetadataTranslator", "StubTranslator", "cache_translation")
16from abc import abstractmethod
17import inspect
18import logging
19import warnings
20import math
22import astropy.units as u
23import astropy.io.fits.card
24from astropy.coordinates import Angle
26from .properties import PROPERTIES
28log = logging.getLogger(__name__)
30# Location of the root of the corrections resource files
31CORRECTIONS_RESOURCE_ROOT = "corrections"
34def cache_translation(func, method=None):
35 """Decorator to cache the result of a translation method.
37 Especially useful when a translation uses many other translation
38 methods. Should be used only on ``to_x()`` methods.
40 Parameters
41 ----------
42 func : `function`
43 Translation method to cache.
44 method : `str`, optional
45 Name of the translation method to cache. Not needed if the decorator
46 is used around a normal method, but necessary when the decorator is
47 being used in a metaclass.
49 Returns
50 -------
51 wrapped : `function`
52 Method wrapped by the caching function.
53 """
54 name = func.__name__ if method is None else method
56 def func_wrapper(self):
57 if name not in self._translation_cache:
58 self._translation_cache[name] = func(self)
59 return self._translation_cache[name]
60 func_wrapper.__doc__ = func.__doc__
61 func_wrapper.__name__ = f"{name}_cached"
62 return func_wrapper
65class MetadataTranslator:
66 """Per-instrument metadata translation support
68 Parameters
69 ----------
70 header : `dict`-like
71 Representation of an instrument header that can be manipulated
72 as if it was a `dict`.
73 filename : `str`, optional
74 Name of the file whose header is being translated. For some
75 datasets with missing header information this can sometimes
76 allow for some fixups in translations.
77 """
79 # These are all deliberately empty in the base class.
80 default_search_path = None
81 """Default search path to use to locate header correction files."""
83 default_resource_package = __name__.split(".")[0]
84 """Module name to use to locate the correction resources."""
86 default_resource_root = None
87 """Default package resource path root to use to locate header correction
88 files within the ``default_resource_package`` package."""
90 _trivial_map = {}
91 """Dict of one-to-one mappings for header translation from standard
92 property to corresponding keyword."""
94 _const_map = {}
95 """Dict defining a constant for specified standard properties."""
97 translators = dict()
98 """All registered metadata translation classes."""
100 supported_instrument = None
101 """Name of instrument understood by this translation class."""
103 @classmethod
104 def defined_in_this_class(cls, name):
105 """Report if the specified class attribute is defined specifically in
106 this class.
108 Parameters
109 ----------
110 name : `str`
111 Name of the attribute to test.
113 Returns
114 -------
115 in_class : `bool`
116 `True` if there is a attribute of that name defined in this
117 specific subclass.
118 `False` if the method is not defined in this specific subclass
119 but is defined in a parent class.
120 Returns `None` if the attribute is not defined anywhere
121 in the class hierarchy (which can happen if translators have
122 typos in their mapping tables).
124 Notes
125 -----
126 Retrieves the attribute associated with the given name.
127 Then looks in all the parent classes to determine whether that
128 attribute comes from a parent class or from the current class.
129 Attributes are compared using `id()`.
130 """
131 # The attribute to compare.
132 if not hasattr(cls, name):
133 return None
134 attr_id = id(getattr(cls, name))
136 # Get all the classes in the hierarchy
137 mro = list(inspect.getmro(cls))
139 # Remove the first entry from the list since that will be the
140 # current class
141 mro.pop(0)
143 for parent in mro:
144 # Some attributes may only exist in subclasses. Skip base classes
145 # that are missing the attribute (such as object).
146 if hasattr(parent, name):
147 if id(getattr(parent, name)) == attr_id:
148 return False
149 return True
151 @staticmethod
152 def _make_const_mapping(property_key, constant):
153 """Make a translator method that returns a constant value.
155 Parameters
156 ----------
157 property_key : `str`
158 Name of the property to be calculated (for the docstring).
159 constant : `str` or `numbers.Number`
160 Value to return for this translator.
162 Returns
163 -------
164 f : `function`
165 Function returning the constant.
166 """
167 def constant_translator(self):
168 return constant
170 if property_key in PROPERTIES: 170 ↛ 173line 170 didn't jump to line 173, because the condition on line 170 was never false
171 property_doc, return_type, _ = PROPERTIES[property_key]
172 else:
173 return_type = type(constant).__name__
174 property_doc = f"Returns constant value for '{property_key}' property"
176 constant_translator.__doc__ = f"""{property_doc}
178 Returns
179 -------
180 translation : `{return_type}`
181 Translated property.
182 """
183 return constant_translator
185 @staticmethod
186 def _make_trivial_mapping(property_key, header_key, default=None, minimum=None, maximum=None,
187 unit=None, checker=None):
188 """Make a translator method returning a header value.
190 The header value can be converted to a `~astropy.units.Quantity`
191 if desired, and can also have its value validated.
193 See `MetadataTranslator.validate_value()` for details on the use
194 of default parameters.
196 Parameters
197 ----------
198 property_key : `str`
199 Name of the translator to be constructed (for the docstring).
200 header_key : `str` or `list` of `str`
201 Name of the key to look up in the header. If a `list` each
202 key will be tested in turn until one matches. This can deal with
203 header styles that evolve over time.
204 default : `numbers.Number` or `astropy.units.Quantity`, `str`, optional
205 If not `None`, default value to be used if the parameter read from
206 the header is not defined or if the header is missing.
207 minimum : `numbers.Number` or `astropy.units.Quantity`, optional
208 If not `None`, and if ``default`` is not `None`, minimum value
209 acceptable for this parameter.
210 maximum : `numbers.Number` or `astropy.units.Quantity`, optional
211 If not `None`, and if ``default`` is not `None`, maximum value
212 acceptable for this parameter.
213 unit : `astropy.units.Unit`, optional
214 If not `None`, the value read from the header will be converted
215 to a `~astropy.units.Quantity`. Only supported for numeric values.
216 checker : `function`, optional
217 Callback function to be used by the translator method in case the
218 keyword is not present. Function will be executed as if it is
219 a method of the translator class. Running without raising an
220 exception will allow the default to be used. Should usually raise
221 `KeyError`.
223 Returns
224 -------
225 t : `function`
226 Function implementing a translator with the specified
227 parameters.
228 """
229 if property_key in PROPERTIES: 229 ↛ 232line 229 didn't jump to line 232, because the condition on line 229 was never false
230 property_doc, return_type, _ = PROPERTIES[property_key]
231 else:
232 return_type = "str` or `numbers.Number"
233 property_doc = f"Map '{header_key}' header keyword to '{property_key}' property"
235 def trivial_translator(self):
236 if unit is not None:
237 q = self.quantity_from_card(header_key, unit,
238 default=default, minimum=minimum, maximum=maximum,
239 checker=checker)
240 # Convert to Angle if this quantity is an angle
241 if return_type == "astropy.coordinates.Angle":
242 q = Angle(q)
243 return q
245 keywords = header_key if isinstance(header_key, list) else [header_key]
246 for key in keywords:
247 if self.is_key_ok(key):
248 value = self._header[key]
249 if default is not None and not isinstance(value, str):
250 value = self.validate_value(value, default, minimum=minimum, maximum=maximum)
251 self._used_these_cards(key)
252 break
253 else:
254 # No keywords found, use default, checking first, or raise
255 # A None default is only allowed if a checker is provided.
256 if checker is not None:
257 try:
258 checker(self)
259 return default
260 except Exception:
261 raise KeyError(f"Could not find {keywords} in header")
262 value = default
263 elif default is not None:
264 value = default
265 else:
266 raise KeyError(f"Could not find {keywords} in header")
268 # If we know this is meant to be a string, force to a string.
269 # Sometimes headers represent items as integers which generically
270 # we want as strings (eg OBSID). Sometimes also floats are
271 # written as "NaN" strings.
272 casts = {"str": str, "float": float, "int": int}
273 if return_type in casts and not isinstance(value, casts[return_type]) and value is not None:
274 value = casts[return_type](value)
276 return value
278 # Docstring inheritance means it is confusing to specify here
279 # exactly which header value is being used.
280 trivial_translator.__doc__ = f"""{property_doc}
282 Returns
283 -------
284 translation : `{return_type}`
285 Translated value derived from the header.
286 """
287 return trivial_translator
289 @classmethod
290 def __init_subclass__(cls, **kwargs):
291 """Register all subclasses with the base class and create dynamic
292 translator methods.
294 The method provides two facilities. Firstly, every subclass
295 of `MetadataTranslator` that includes a ``name`` class property is
296 registered as a translator class that could be selected when automatic
297 header translation is attempted. Only name translator subclasses that
298 correspond to a complete instrument. Translation classes providing
299 generic translation support for multiple instrument translators should
300 not be named.
302 The second feature of this method is to convert simple translations
303 to full translator methods. Sometimes a translation is fixed (for
304 example a specific instrument name should be used) and rather than
305 provide a full ``to_property()`` translation method the mapping can be
306 defined in a class variable named ``_constMap``. Similarly, for
307 one-to-one trivial mappings from a header to a property,
308 ``_trivialMap`` can be defined. Trivial mappings are a dict mapping a
309 generic property to either a header keyword, or a tuple consisting of
310 the header keyword and a dict containing key value pairs suitable for
311 the `MetadataTranslator.quantity_from_card()` method.
312 """
313 super().__init_subclass__(**kwargs)
315 # Only register classes with declared names
316 if hasattr(cls, "name") and cls.name is not None:
317 if cls.name in MetadataTranslator.translators: 317 ↛ 318line 317 didn't jump to line 318, because the condition on line 317 was never true
318 log.warning("%s: Replacing %s translator with %s",
319 cls.name, MetadataTranslator.translators[cls.name], cls)
320 MetadataTranslator.translators[cls.name] = cls
322 # Check that we have not inherited constant/trivial mappings from
323 # parent class that we have already applied. Empty maps are always
324 # assumed okay
325 const_map = cls._const_map if cls._const_map and cls.defined_in_this_class("_const_map") else {}
326 trivial_map = cls._trivial_map \
327 if cls._trivial_map and cls.defined_in_this_class("_trivial_map") else {}
329 # Check for shadowing
330 trivials = set(trivial_map.keys())
331 constants = set(const_map.keys())
332 both = trivials & constants
333 if both: 333 ↛ 334line 333 didn't jump to line 334, because the condition on line 333 was never true
334 log.warning("%s: defined in both const_map and trivial_map: %s",
335 cls.__name__, ", ".join(both))
337 all = trivials | constants
338 for name in all:
339 if cls.defined_in_this_class(f"to_{name}"): 339 ↛ 342line 339 didn't jump to line 342, because the condition on line 339 was never true
340 # Must be one of trivial or constant. If in both then constant
341 # overrides trivial.
342 location = "by _trivial_map"
343 if name in constants:
344 location = "by _const_map"
345 log.warning("%s: %s is defined explicitly but will be replaced %s",
346 cls.__name__, name, location)
348 # Go through the trival mappings for this class and create
349 # corresponding translator methods
350 for property_key, header_key in trivial_map.items():
351 kwargs = {}
352 if type(header_key) == tuple:
353 kwargs = header_key[1]
354 header_key = header_key[0]
355 translator = cls._make_trivial_mapping(property_key, header_key, **kwargs)
356 method = f"to_{property_key}"
357 translator.__name__ = f"{method}_trivial_in_{cls.__name__}"
358 setattr(cls, method, cache_translation(translator, method=method))
359 if property_key not in PROPERTIES: 359 ↛ 360line 359 didn't jump to line 360, because the condition on line 359 was never true
360 log.warning(f"Unexpected trivial translator for '{property_key}' defined in {cls}")
362 # Go through the constant mappings for this class and create
363 # corresponding translator methods
364 for property_key, constant in const_map.items():
365 translator = cls._make_const_mapping(property_key, constant)
366 method = f"to_{property_key}"
367 translator.__name__ = f"{method}_constant_in_{cls.__name__}"
368 setattr(cls, method, translator)
369 if property_key not in PROPERTIES: 369 ↛ 370line 369 didn't jump to line 370, because the condition on line 369 was never true
370 log.warning(f"Unexpected constant translator for '{property_key}' defined in {cls}")
372 def __init__(self, header, filename=None):
373 self._header = header
374 self.filename = filename
375 self._used_cards = set()
377 # Cache assumes header is read-only once stored in object
378 self._translation_cache = {}
380 @classmethod
381 @abstractmethod
382 def can_translate(cls, header, filename=None):
383 """Indicate whether this translation class can translate the
384 supplied header.
386 Parameters
387 ----------
388 header : `dict`-like
389 Header to convert to standardized form.
390 filename : `str`, optional
391 Name of file being translated.
393 Returns
394 -------
395 can : `bool`
396 `True` if the header is recognized by this class. `False`
397 otherwise.
398 """
399 raise NotImplementedError()
401 @classmethod
402 def can_translate_with_options(cls, header, options, filename=None):
403 """Helper method for `can_translate` allowing options.
405 Parameters
406 ----------
407 header : `dict`-like
408 Header to convert to standardized form.
409 options : `dict`
410 Headers to try to determine whether this header can
411 be translated by this class. If a card is found it will
412 be compared with the expected value and will return that
413 comparison. Each card will be tried in turn until one is
414 found.
415 filename : `str`, optional
416 Name of file being translated.
418 Returns
419 -------
420 can : `bool`
421 `True` if the header is recognized by this class. `False`
422 otherwise.
424 Notes
425 -----
426 Intended to be used from within `can_translate` implementations
427 for specific translators. Is not intended to be called directly
428 from `determine_translator`.
429 """
430 for card, value in options.items():
431 if card in header:
432 return header[card] == value
433 return False
435 @classmethod
436 def determine_translator(cls, header, filename=None):
437 """Determine a translation class by examining the header
439 Parameters
440 ----------
441 header : `dict`-like
442 Representation of a header.
443 filename : `str`, optional
444 Name of file being translated.
446 Returns
447 -------
448 translator : `MetadataTranslator`
449 Translation class that knows how to extract metadata from
450 the supplied header.
452 Raises
453 ------
454 ValueError
455 None of the registered translation classes understood the supplied
456 header.
457 """
458 for name, trans in cls.translators.items():
459 if trans.can_translate(header, filename=filename):
460 log.debug(f"Using translation class {name}")
461 return trans
462 else:
463 raise ValueError(f"None of the registered translation classes {list(cls.translators.keys())}"
464 " understood this header")
466 @classmethod
467 def fix_header(cls, header):
468 """Apply global fixes to a supplied header.
470 Parameters
471 ----------
472 header : `dict`
473 The header to correct. Correction is in place.
475 Returns
476 -------
477 modified : `bool`
478 `True` if a correction was applied.
480 Notes
481 -----
482 This method is intended to support major discrepancies in headers
483 such as:
485 * Periods of time where headers are known to be incorrect in some
486 way that can be fixed either by deriving the correct value from
487 the existing value or understanding the that correction is static
488 for the given time. This requires that the date header is
489 known.
490 * The presence of a certain value is always wrong and should be
491 corrected with a new static value regardless of date.
493 It is assumed that one off problems with headers have been applied
494 before this method is called using the per-obsid correction system.
496 Usually called from `astro_metadata_translator.fix_header`.
497 """
498 return False
500 def _used_these_cards(self, *args):
501 """Indicate that the supplied cards have been used for translation.
503 Parameters
504 ----------
505 args : sequence of `str`
506 Keywords used to process a translation.
507 """
508 self._used_cards.update(set(args))
510 def cards_used(self):
511 """Cards used during metadata extraction.
513 Returns
514 -------
515 used : `frozenset` of `str`
516 Cards used when extracting metadata.
517 """
518 return frozenset(self._used_cards)
520 @staticmethod
521 def validate_value(value, default, minimum=None, maximum=None):
522 """Validate the supplied value, returning a new value if out of range
524 Parameters
525 ----------
526 value : `float`
527 Value to be validated.
528 default : `float`
529 Default value to use if supplied value is invalid or out of range.
530 Assumed to be in the same units as the value expected in the
531 header.
532 minimum : `float`
533 Minimum possible valid value, optional. If the calculated value
534 is below this value, the default value will be used.
535 maximum : `float`
536 Maximum possible valid value, optional. If the calculated value
537 is above this value, the default value will be used.
539 Returns
540 -------
541 value : `float`
542 Either the supplied value, or a default value.
543 """
544 if value is None or math.isnan(value):
545 value = default
546 else:
547 if minimum is not None and value < minimum:
548 value = default
549 elif maximum is not None and value > maximum:
550 value = default
551 return value
553 @staticmethod
554 def is_keyword_defined(header, keyword):
555 """Return `True` if the value associated with the named keyword is
556 present in the supplied header and defined.
558 Parameters
559 ----------
560 header : `dict`-lik
561 Header to use as reference.
562 keyword : `str`
563 Keyword to check against header.
565 Returns
566 -------
567 is_defined : `bool`
568 `True` if the header is present and not-`None`. `False` otherwise.
569 """
570 if keyword not in header:
571 return False
573 if header[keyword] is None:
574 return False
576 # Special case Astropy undefined value
577 if isinstance(header[keyword], astropy.io.fits.card.Undefined):
578 return False
580 return True
582 def resource_root(self):
583 """Package resource to use to locate correction resources within an
584 installed package.
586 Returns
587 -------
588 resource_package : `str`
589 Package resource name. `None` if no package resource are to be
590 used.
591 resource_root : `str`
592 The name of the resource root. `None` if no package resources
593 are to be used.
594 """
595 return (self.default_resource_package, self.default_resource_root)
597 def search_paths(self):
598 """Search paths to use when searching for header fix up correction
599 files.
601 Returns
602 -------
603 paths : `list`
604 Directory paths to search. Can be an empty list if no special
605 directories are defined.
607 Notes
608 -----
609 Uses the classes ``default_search_path`` property if defined.
610 """
611 if self.default_search_path is not None:
612 return [self.default_search_path]
613 return []
615 def is_key_ok(self, keyword):
616 """Return `True` if the value associated with the named keyword is
617 present in this header and defined.
619 Parameters
620 ----------
621 keyword : `str`
622 Keyword to check against header.
624 Returns
625 -------
626 is_ok : `bool`
627 `True` if the header is present and not-`None`. `False` otherwise.
628 """
629 return self.is_keyword_defined(self._header, keyword)
631 def are_keys_ok(self, keywords):
632 """Are the supplied keys all present and defined?
634 Parameters
635 ----------
636 keywords : iterable of `str`
637 Keywords to test.
639 Returns
640 -------
641 all_ok : `bool`
642 `True` if all supplied keys are present and defined.
643 """
644 for k in keywords:
645 if not self.is_key_ok(k):
646 return False
647 return True
649 def quantity_from_card(self, keywords, unit, default=None, minimum=None, maximum=None, checker=None):
650 """Calculate a Astropy Quantity from a header card and a unit.
652 Parameters
653 ----------
654 keywords : `str` or `list` of `str`
655 Keyword to use from header. If a list each keyword will be tried
656 in turn until one matches.
657 unit : `astropy.units.UnitBase`
658 Unit of the item in the header.
659 default : `float`, optional
660 Default value to use if the header value is invalid. Assumed
661 to be in the same units as the value expected in the header. If
662 None, no default value is used.
663 minimum : `float`, optional
664 Minimum possible valid value, optional. If the calculated value
665 is below this value, the default value will be used.
666 maximum : `float`, optional
667 Maximum possible valid value, optional. If the calculated value
668 is above this value, the default value will be used.
669 checker : `function`, optional
670 Callback function to be used by the translator method in case the
671 keyword is not present. Function will be executed as if it is
672 a method of the translator class. Running without raising an
673 exception will allow the default to be used. Should usually raise
674 `KeyError`.
676 Returns
677 -------
678 q : `astropy.units.Quantity`
679 Quantity representing the header value.
681 Raises
682 ------
683 KeyError
684 The supplied header key is not present.
685 """
686 keywords = keywords if isinstance(keywords, list) else [keywords]
687 for k in keywords:
688 if self.is_key_ok(k):
689 value = self._header[k]
690 keyword = k
691 break
692 else:
693 if checker is not None:
694 try:
695 checker(self)
696 value = default
697 if value is not None:
698 value = u.Quantity(value, unit=unit)
699 return value
700 except Exception:
701 pass
702 raise KeyError(f"Could not find {keywords} in header")
703 if isinstance(value, str):
704 # Sometimes the header has the wrong type in it but this must
705 # be a number if we are creating a quantity.
706 value = float(value)
707 self._used_these_cards(keyword)
708 if default is not None:
709 value = self.validate_value(value, default, maximum=maximum, minimum=minimum)
710 return u.Quantity(value, unit=unit)
712 def _join_keyword_values(self, keywords, delim="+"):
713 """Join values of all defined keywords with the specified delimiter.
715 Parameters
716 ----------
717 keywords : iterable of `str`
718 Keywords to look for in header.
719 delim : `str`, optional
720 Character to use to join the values together.
722 Returns
723 -------
724 joined : `str`
725 String formed from all the keywords found in the header with
726 defined values joined by the delimiter. Empty string if no
727 defined keywords found.
728 """
729 values = []
730 for k in keywords:
731 if self.is_key_ok(k):
732 values.append(self._header[k])
733 self._used_these_cards(k)
735 if values:
736 joined = delim.join(str(v) for v in values)
737 else:
738 joined = ""
740 return joined
742 @cache_translation
743 def to_detector_unique_name(self):
744 """Return a unique name for the detector.
746 Base class implementation attempts to combine ``detector_name`` with
747 ``detector_group``. Group is only used if not `None`.
749 Can be over-ridden by specialist translator class.
751 Returns
752 -------
753 name : `str`
754 ``detector_group``_``detector_name`` if ``detector_group`` is
755 defined, else the ``detector_name`` is assumed to be unique.
756 If neither return a valid value an exception is raised.
758 Raises
759 ------
760 NotImplementedError
761 Raised if neither detector_name nor detector_group is defined.
762 """
763 name = self.to_detector_name()
764 group = self.to_detector_group()
766 if group is None and name is None:
767 raise NotImplementedError("Can not determine unique name from detector_group and detector_name")
769 if group is not None:
770 return f"{group}_{name}"
772 return name
774 @cache_translation
775 def to_exposure_group(self):
776 """Return the group label associated with this exposure.
778 Base class implementation returns the ``exposure_id`` in string
779 form. A subclass may do something different.
781 Returns
782 -------
783 name : `str`
784 The ``exposure_id`` converted to a string.
785 """
786 exposure_id = self.to_exposure_id()
787 if exposure_id is None:
788 return None
789 else:
790 return str(exposure_id)
792 @cache_translation
793 def to_observation_reason(self):
794 """Return the reason this observation was taken.
796 Base class implementation returns the ``science`` if the
797 ``observation_type`` is science, else ``unknown``.
798 A subclass may do something different.
800 Returns
801 -------
802 name : `str`
803 The reason for this observation.
804 """
805 obstype = self.to_observation_type()
806 if obstype == "science":
807 return "science"
808 return "unknown"
811def _make_abstract_translator_method(property, doc, return_typedoc, return_type):
812 """Create a an abstract translation method for this property.
814 Parameters
815 ----------
816 property : `str`
817 Name of the translator for property to be created.
818 doc : `str`
819 Description of the property.
820 return_typedoc : `str`
821 Type string of this property (used in the doc string).
822 return_type : `class`
823 Type of this property.
825 Returns
826 -------
827 m : `function`
828 Translator method for this property.
829 """
830 def to_property(self):
831 raise NotImplementedError(f"Translator for '{property}' undefined.")
833 to_property.__doc__ = f"""Return value of {property} from headers.
835 {doc}
837 Returns
838 -------
839 {property} : `{return_typedoc}`
840 The translated property.
841 """
842 return to_property
845# Make abstract methods for all the translators methods.
846# Unfortunately registering them as abstractmethods does not work
847# as these assignments come after the class has been created.
848# Assigning to __abstractmethods__ directly does work but interacts
849# poorly with the metaclass automatically generating methods from
850# _trivialMap and _constMap.
852# Allow for concrete translator methods to exist in the base class
853# These translator methods can be defined in terms of other properties
854CONCRETE = set()
856for name, description in PROPERTIES.items():
857 method = f"to_{name}"
858 if not MetadataTranslator.defined_in_this_class(method):
859 setattr(MetadataTranslator, f"to_{name}",
860 abstractmethod(_make_abstract_translator_method(name, *description)))
861 else:
862 CONCRETE.add(method)
865class StubTranslator(MetadataTranslator):
866 """Translator where all the translations are stubbed out and issue
867 warnings.
869 This translator can be used as a base class whilst developing a new
870 translator. It allows testing to proceed without being required to fully
871 define all translation methods. Once complete the class should be
872 removed from the inheritance tree.
874 """
875 pass
878def _make_forwarded_stub_translator_method(cls, property, doc, return_typedoc, return_type):
879 """Create a stub translation method for this property that calls the
880 base method and catches `NotImplementedError`.
882 Parameters
883 ----------
884 cls : `class`
885 Class to use when referencing `super()`. This would usually be
886 `StubTranslator`.
887 property : `str`
888 Name of the translator for property to be created.
889 doc : `str`
890 Description of the property.
891 return_typedoc : `str`
892 Type string of this property (used in the doc string).
893 return_type : `class`
894 Type of this property.
896 Returns
897 -------
898 m : `function`
899 Stub translator method for this property.
900 """
901 method = f"to_{property}"
903 def to_stub(self):
904 parent = getattr(super(cls, self), method, None)
905 try:
906 if parent is not None:
907 return parent()
908 except NotImplementedError:
909 pass
911 warnings.warn(f"Please implement translator for property '{property}' for translator {self}",
912 stacklevel=3)
913 return None
915 to_stub.__doc__ = f"""Unimplemented forwarding translator for {property}.
917 {doc}
919 Calls the base class translation method and if that fails with
920 `NotImplementedError` issues a warning reminding the implementer to
921 override this method.
923 Returns
924 -------
925 {property} : `None` or `{return_typedoc}`
926 Always returns `None`.
927 """
928 return to_stub
931# Create stub translation methods for each property. These stubs warn
932# rather than fail and should be overridden by translators.
933for name, description in PROPERTIES.items():
934 setattr(StubTranslator, f"to_{name}", _make_forwarded_stub_translator_method(StubTranslator,
935 name, *description))