from collections import UserList from fnmatch import fnmatch from typing import Optional import copy from astropy.time import Time import pathlib from urllib.parse import urlparse import os from traitlets import ( Bool, CaselessStrEnum, Dict, Enum, Float, Int, Integer, List, Long, TraitError, TraitType, Unicode, observe, Set, CRegExp, ) from traitlets.config import boolean_flag as flag from .component import non_abstract_children __all__ = [ "Path", "Int", "Integer", "Float", "Unicode", "Enum", "Long", "List", "Bool", "Set", "CRegExp", "Dict", "flag", "TraitError", "observe", "CaselessStrEnum", "create_class_enum_trait", "classes_with_traits", "has_traits", "TelescopeParameter", "FloatTelescopeParameter", "IntTelescopeParameter", "AstroTime", ] import logging logger = logging.getLogger(__name__) class AstroTime(TraitType): """ A trait representing a point in Time, as understood by `astropy.time`""" def validate(self, obj, value): """ try to parse and return an ISO time string """ try: the_time = Time(value) the_time.format = "iso" return the_time except ValueError: return self.error(obj, value) def info(self): info = "an ISO8601 datestring or Time instance" if self.allow_none: info += "or None" return info class Path(TraitType): """ A path Trait for input/output files. Parameters ---------- exists: boolean or None If True, path must exist, if False path must not exist directory_ok: boolean If False, path must not be a directory file_ok: boolean If False, path must not be a file """ def __init__(self, *args, exists=None, directory_ok=True, file_ok=True, **kwargs): default_value = kwargs.pop("default_value", None) super().__init__(*args, default_value=default_value, allow_none=True, **kwargs) self.exists = exists self.directory_ok = directory_ok self.file_ok = file_ok def info(self): info = "a pathlib.Path or non-empty str for " if self.exists is True: info += "an existing" elif self.exists is False: info += "a not existing" else: info += "a" if self.directory_ok and self.file_ok: info += " directory or file" else: if self.file_ok: info += " file" if self.directory_ok: info += "directory" if self.allow_none: info += " or None" return info def validate(self, obj, value): if isinstance(value, bytes): value = os.fsdecode(value) if not isinstance(value, (str, pathlib.Path)): return self.error(obj, value) if isinstance(value, str): if value == "": return self.error(obj, value) try: url = urlparse(value) except ValueError: return self.error(obj, value) if url.scheme not in ("", "file"): return self.error(obj, value) value = pathlib.Path(url.netloc, url.path) value = value.absolute() exists = value.exists() if self.exists is not None: if exists != self.exists: raise TraitError( 'Path "{}" {} exist'.format( value, "does not" if self.exists else "must not" ) ) if exists: if not self.directory_ok and value.is_dir(): raise TraitError(f'Path "{value}" must not be a directory') if not self.file_ok and value.is_file(): raise TraitError(f'Path "{value}" must not be a file') return value def create_class_enum_trait(base_class, default_value, help=None): """create a configurable CaselessStrEnum traitlet from baseclass the enumeration should contain all names of non_abstract_children() of said baseclass and the default choice should be given by `base_class._default` name. default must be specified and must be the name of one child-class """ if help is None: help = "{} to use.".format(base_class.__name__) choices = [cls.__name__ for cls in non_abstract_children(base_class)] if default_value not in choices: raise ValueError(f"{default_value} is not in choices: {choices}") return CaselessStrEnum( choices, default_value=default_value, allow_none=False, help=help, ).tag(config=True) def classes_with_traits(base_class): """ Returns a list of the base class plus its non-abstract children if they have traits """ all_classes = [base_class] + non_abstract_children(base_class) return [cls for cls in all_classes if has_traits(cls)] def has_traits(cls, ignore=("config", "parent")): """True if cls has any traits apart from the usual ones all our components have at least 'config' and 'parent' as traitlets this is inherited from `traitlets.config.Configurable` so we ignore them here. """ return bool(set(cls.class_trait_names()) - set(ignore)) class TelescopePatternList(UserList): """ Representation for a list of telescope pattern tuples. This is a helper class used by the Trait TelescopeParameter as its value type """ def __init__(self, *args): super().__init__(*args) self._lookup = None self._subarray = None @property def tel(self): """ access the value per telescope_id, e.g. `param.tel[2]`""" if self._lookup: return self._lookup else: raise RuntimeError( "No TelescopeParameterLookup was registered. You must " "call attach_subarray() first" ) def attach_subarray(self, subarray: "ctapipe.instrument.SubarrayDescription"): """ Register a SubarrayDescription so that the user-specified values can be looked up by tel_id. This must be done before using the `.tel[x]` property """ self._subarray = subarray self._lookup.attach_subarray(subarray) class TelescopeParameterLookup: def __init__(self, telescope_parameter_list): """ Handles the lookup of corresponding configuration value from a list of tuples for a telid. Parameters ---------- telescope_parameter_list : list List of tuples in the form `[(command, argument, value), ...]` """ # self._telescope_parameter_list = copy.deepcopy(telescope_parameter_list) self._telescope_parameter_list = copy.deepcopy(telescope_parameter_list) self._value_for_tel_id = None self._subarray = None self._subarray_global_value = None for param in telescope_parameter_list: if param[1] == "*": self._subarray_global_value = param[2] def attach_subarray(self, subarray): """ Prepare the TelescopeParameter by informing it of the subarray description Parameters ---------- subarray: ctapipe.instrument.SubarrayDescription Description of the subarray (includes mapping of tel_id to tel_type) """ self._subarray = subarray self._value_for_tel_id = {} for command, arg, value in self._telescope_parameter_list: if command == "type": matched_tel_types = [ str(t) for t in subarray.telescope_types if fnmatch(str(t), arg) ] logger.debug(f"argument '{arg}' matched: {matched_tel_types}") if len(matched_tel_types) == 0: logger.warning( "TelescopeParameter type argument '%s' did not match " "any known telescope types", arg, ) for tel_type in matched_tel_types: for tel_id in subarray.get_tel_ids_for_type(tel_type): self._value_for_tel_id[tel_id] = value elif command == "id": self._value_for_tel_id[int(arg)] = value else: raise ValueError(f"Unrecognized command: {command}") def __getitem__(self, tel_id: Optional[int]): """ Returns the resolved parameter for the given telescope id """ if tel_id is None: if self._subarray_global_value is not None: return self._subarray_global_value else: raise KeyError("No subarray global value set for TelescopeParameter") if self._value_for_tel_id is None: raise ValueError( "TelescopeParameterLookup: No subarray attached, call " "`attach_subarray` first before trying to access a value by tel_id" ) try: return self._value_for_tel_id[tel_id] except KeyError: raise KeyError( f"TelescopeParameterLookup: no " f"parameter value was set for telescope with tel_id=" f"{tel_id}. Please set it explicitly, " f"or by telescope type or '*'." ) class TelescopeParameter(List): """ Allow a parameter value to be specified as a simple value (of type *dtype*), or as a list of patterns that match different telescopes. The patterns are given as a list of 3-tuples in in the form: `[(command, argument, value), ...]`. Command can be one of: - 'type': argument is then a telescope type string (e.g. `('type', 'SST_ASTRI_CHEC', 4.0)` to apply to all telescopes of that type, or use a wildcard like "LST*", or "*" to set a pure default value for all telescopes. - 'id': argument is a specific telescope ID `['id', 89, 5.0]`) These are evaluated in-order, so you can first set a default value, and then set values for specific telescopes or types to override them. Examples -------- .. code-block: python tel_param = [ ('type', '*', 5.0), # default for all ('type', 'LST_*', 5.2), ('type', 'MST_MST_NectarCam', 4.0), ('type', 'MST_MST_FlashCam', 4.5), ('id', 34, 4.0), # override telescope 34 specifically ] .. code-block: python tel_param = 4.0 # sets this value for all telescopes """ klass = TelescopePatternList def __init__(self, dtype=float, default_value=None, **kwargs): if not isinstance(dtype, type): raise ValueError("dtype should be a type") if isinstance(default_value, dtype): default_value = [("type", "*", default_value)] super().__init__(default_value=default_value, **kwargs) self._dtype = dtype def validate(self, obj, value): # Support a single value for all (convert into a default value) if isinstance(value, self._dtype): value = [("type", "*", value)] # Check each value of list normalized_value = TelescopePatternList(None) if isinstance(value, self._dtype): value = [("type", "*", value)] if isinstance(value, (UserList, list)): for pattern in value: # now check for the standard 3-tuple of (command, argument, value) if len(pattern) != 3: raise TraitError( "pattern should be a tuple of (command, argument, value)" ) command, arg, val = pattern if not isinstance(val, self._dtype): raise TraitError(f"Value should be a {self._dtype}") if not isinstance(command, str): raise TraitError("command must be a string") if command not in ["type", "id"]: raise TraitError("command must be one of: '*', 'type', 'id'") if command == "type": if not isinstance(arg, str): raise TraitError("'type' argument should be a string") if command == "id": try: arg = int(arg) except ValueError: raise TraitError( f"Argument of 'id' should be an int (got '{arg}')" ) val = self._dtype(val) normalized_value.append((command, arg, val)) normalized_value._lookup = TelescopeParameterLookup(normalized_value) if ( isinstance(value, TelescopePatternList) and value._subarray is not None ): normalized_value.attach_subarray(value._subarray) else: raise TraitError(f"Value should be a {self._dtype}") return normalized_value def set(self, obj, value): # Retain existing subarray description # when setting new value for TelescopeParameter try: old_value = obj._trait_values[self.name] except KeyError: old_value = self.default_value super().set(obj, value) if getattr(old_value, "_subarray", None) is not None: obj._trait_values[self.name].attach_subarray(old_value._subarray) class FloatTelescopeParameter(TelescopeParameter): """ a `TelescopeParameter` with float type (see docs for `TelescopeParameter`)""" def __init__(self, **kwargs): super().__init__(dtype=float, **kwargs) class IntTelescopeParameter(TelescopeParameter): """ a `TelescopeParameter` with int type (see docs for `TelescopeParameter`)""" def __init__(self, **kwargs): super().__init__(dtype=int, **kwargs) class BoolTelescopeParameter(TelescopeParameter): """ a `TelescopeParameter` with int type (see docs for `TelescopeParameter`)""" def __init__(self, **kwargs): super().__init__(dtype=bool, **kwargs)