Source code for scm.pisa.key

from __future__ import annotations

import difflib
import warnings
from abc import abstractmethod
from pathlib import Path
from typing import Any, Iterable, Literal, TypeVar

from .entry import Entry, _check_unique
from .enums import InputCategory, KeyType
from .utils import str_to_enum

BoolType = Literal[
    "Yes",
    "yes",
    "No",
    "no",
    "True",
    "true",
    "False",
    "false",
    "t",
    "f",
    "T",
    "F",
    True,
    False,
]

T = TypeVar("T")


[docs]class Key(Entry): """Base class for all key classes in the input system. Keys are single lines in the text input, with the name of the key, followed by a space, and followed by a string representing the value. """
[docs] def __post_init__(self): self._val = None if self.default is not None: self.val = self.default self._value_changed = False
@property def val(self): """Property representing the key value. Any proposed `value` will be sent to the abstract method :meth:`_convert_val`, which is overwritten in each subclass. Values that cannot be converted to the correct type will be rejected. :param value: Value to set the key with :type value: Any """ return self._val @val.setter def val(self, value: Any) -> None: self._value_changed = True self._val = self._convert_val(value)
[docs] @abstractmethod def _convert_val(self, value): pass
[docs] def set_to_default(self): """Reset the key to the default value, if present.""" self.val = self.default
[docs] @staticmethod def from_def_dict(name: str, def_dict: dict) -> Key: assert str_to_enum(def_dict["_category"], InputCategory) == InputCategory.KEY key_type_lookup = { KeyType.FLOAT: FloatKey, KeyType.BOOL: BoolKey, KeyType.INTEGER: IntKey, KeyType.FLOAT_LIST: FloatListKey, KeyType.INTEGER_LIST: IntListKey, KeyType.MULTIPLE_CHOICE: MultipleChoiceKey, KeyType.STRING: StringKey, KeyType.PATHSTRING: PathStringKey, } key_type = str_to_enum(def_dict["_type"], KeyType) if key_type is KeyType.STRING and "_ispath" in def_dict and def_dict["_ispath"]: key_type = KeyType.PATHSTRING assert key_type in key_type_lookup def_dict.pop("_type") def_dict.pop("_category") return key_type_lookup[key_type]( # type: ignore name=name, # strip leading underscore to match constructor signature **{k[1:]: v for k, v in def_dict.items() if k.startswith("_")}, )
[docs] def _get_single_input_string(self, indent_lvl: int = 0) -> str: INDENT = " " * 2 return f"{INDENT * indent_lvl}{self.name} {self._format_val()}\n"
[docs] def _format_val(self) -> str: return str(self.val)
[docs] @_check_unique def __setitem__(self, index: int, value: Any) -> None: key: Key = self.__getitem__(index) assert isinstance(key, self.__class__) key.val = value
[docs] def _update_existing_attr(self, attr: Any, __name: str, __value: Any) -> None: object.__setattr__(self, __name, __value)
[docs] def __str__(self) -> str: # meant for the generated subclasses, for example # class Temperature(IntKey): # pass # print(Temperature(name=Temperature, default=4)) # > <IntKey: 'Temperature 4'> return f"<{self.__class__.__mro__[1].__name__}: '{self.name} {self.val}'>"
[docs] def _check_for_boolean(self, val: T) -> T: if isinstance(val, bool): raise TypeError(f"Will not accept boolean value {val} for {self.__class__.__name__} {self}") return val
[docs]class FloatKey(Key): val: float
[docs] def _convert_val(self, val: float) -> float: """Will accept anything that can be converted to a python float, but raises errors on values of type :class:`bool`, since the type hinter considers them a valid subclass of :class:`float`, but passing booleans as floats is never intended :param val: value :type val: float :return: Converted value :rtype: float """ return float(self._check_for_boolean(val))
[docs] @_check_unique def __setitem__(self, index: int, value: float) -> None: return super().__setitem__(index, value)
[docs]class BoolKey(Key): val: bool
[docs] def _convert_val(self, val: BoolType) -> bool: """Will accept a set of strings and `True` or `False` as boolean values. Will not try to convert to :class:`bool`, since nearly every python object can be converted to a boolean and passing anything but :class:`BoolType` rarely on purpose. :param val: value :type val: BoolType :raises ValueError: When value is not of type :class:`BoolType` or a case variation of one of the strings :return: Converted value :rtype: bool """ str_val = str(val) # t and f are accepted because of fortran standard # yes and no because of legacy accepted_true = ["true", "yes", "t"] accepted_false = ["false", "no", "f"] if str_val.lower() in accepted_true: return True elif str_val.lower() in accepted_false: return False else: raise ValueError( f"{str_val} not in accepted options for True: {accepted_true} or " f"False: {accepted_false}" )
[docs] @_check_unique def __setitem__(self, index: int, value: BoolType) -> None: return super().__setitem__(index, value)
[docs]class IntKey(Key): val: int
[docs] def _convert_val(self, val: int) -> int: """Accepts anything that can be converted to an integer, except boolean values. :param val: value :type val: int :return: Converted value :rtype: int """ return int(self._check_for_boolean(val))
[docs] @_check_unique def __setitem__(self, index: int, value: int) -> None: return super().__setitem__(index, value)
[docs]class FloatListKey(Key): val: tuple[float, ...]
[docs] def _convert_val(self, val: Iterable[float]) -> tuple[float, ...]: """Accepts any iterable of values that are convertible to float, except boolean values. :param val: value :type val: Iterable[float] :return: converted value :rtype: tuple[float, ...] """ try: return tuple(float(self._check_for_boolean(f)) for f in val) except TypeError: raise TypeError(f"Could not assign {val} to {self}. Must be compatible with 'Iterable[float]'.")
[docs] def _format_val(self) -> str: return " ".join(str(v) for v in self.val) if self.val is not None else ""
[docs] @_check_unique def __setitem__(self, index: int, value: Iterable[float]) -> None: return super().__setitem__(index, value)
[docs]class IntListKey(Key): val: tuple[int, ...]
[docs] def _convert_val(self, val: Iterable[int]) -> tuple[int, ...]: """Accepts any iterable of values that are convertible to integer, except boolean values. :param val: value :type val: Iterable[int] :return: converted value :rtype: tuple[int, ...] """ try: return tuple(int(self._check_for_boolean(f)) for f in val) except TypeError: raise TypeError(f"Could not assign {val} to {self}. Must be compatible with 'Iterable[int]'.")
[docs] def _format_val(self) -> str: return " ".join(str(v) for v in self.val) if self.val is not None else ""
[docs] @_check_unique def __setitem__(self, index: int, value: Iterable[int]) -> None: return super().__setitem__(index, value)
[docs]class MultipleChoiceKey(Key): val: str
[docs] def __post_init__(self): super().__post_init__() assert self.choices is not None
[docs] def _convert_val(self, val: str) -> str: """Accepts any strings that is equal to (or a case variation of) the list of strings defined in :attr:`MultipleChoiceKey.choices`. :param val: value :type val: str :raises ValueError: When `val` not present in :attr:`MultipleChoiceKey.choices` :return: Converted value. :rtype: str """ assert isinstance(val, str) assert self.choices is not None total_choices = self.choices if self.hiddenchoices is None else self.choices + self.hiddenchoices lower_choices = [c.lower() for c in total_choices] try: idx = lower_choices.index(val.lower()) return total_choices[idx] except ValueError: choice_list = "\n".join(total_choices) raise ValueError( f"Can not set value to {val}, not present in choices: {choice_list}. \n" f"Did you possibly mean any of {difflib.get_close_matches(val, total_choices)}?" )
[docs] @_check_unique def __setitem__(self, index: int, value: str) -> None: return super().__setitem__(index, value)
[docs]class StringKey(Key): val: str
[docs] def _convert_val(self, val: str) -> str: """Accepts any string. Validates whether the argument passed is actually a string, since calling str(val) would succeed for every possible type of val. :param val: value :type val: str :return: Converted value :rtype: str """ message = ( f"{self.name} is a {'repeatable ' if not self.unique else ''}" f"StringKey and can only be assigned a 'str' value but found type '{type(val)}'." f"{f' Assign values one at a time, e.g. {self.name}[0] = value' if not self.unique else ''}" ) assert isinstance(val, str), message return str(val)
[docs] @_check_unique def __setitem__(self, index: int, value: str) -> None: return super().__setitem__(index, value)
[docs]class PathStringKey(Key): val: str | Path
[docs] def _convert_val(self, val: str | Path) -> str: """Accepts strings that represent paths. :param val: value :type val: str | Path :return: Converted value :rtype: str """ assert isinstance(val, (str, Path)) # don't make empty default values into path objects that refer to the local directory ('.') if val == "": return str(val) path = Path(val) # Incomplete feature, current implementation is not correct, this should be some opt in opt out feature # That's applied e.g. when converting to other objects or when you ask for it, not when assigning? # if val is not self.default: # if path.exists() and not path.is_absolute(): # absolute_path = str(path.absolute().resolve()) # warnings.warn( # f"Path stringkey {self} was set with value {val}, an existing relative path. Converting to " # f"absolute path {absolute_path}", # UserWarning, # ) # return str(absolute_path) return str(path)
[docs] @_check_unique def __setitem__(self, index: int, value: str | Path) -> None: return super().__setitem__(index, value)
# maybe too fragile to define typehints in two places?
[docs]def type_hint(k: Key) -> str: """Provides the proper type hint for key attributes for the autogenerated classes. Keys are type hinted as their actual type, plus the type hint of their value to allow for syntax like: .. code:: python3 some_block.some_key = 5 to pass the type hinter, instead of the more cumbersome .. code:: python3 some_block.some_key.val = 5 :param k: Key object :type k: Key :raises NotImplementedError: When providing a key object for which a type hint is not implemented yet. :return: String describing a type hint :rtype: str """ assert isinstance(k, Key) if isinstance(k, FloatKey): return f"float | {FloatKey.__name__}" elif isinstance(k, IntKey): return f"int | {IntKey.__name__}" elif isinstance(k, BoolKey): return f"BoolType | {BoolKey.__name__}" elif isinstance(k, FloatListKey): return f"Iterable[float] | {FloatListKey.__name__}" elif isinstance(k, IntListKey): return f"Iterable[int] | {IntListKey.__name__}" elif isinstance(k, MultipleChoiceKey): # The following does not let me auto-complete the choices in vscode: # return "Literal[" + ", ".join(f'"{c}"' for c in k.choices) + f"] | {MultipleChoiceKey.__name__}" # noqa: E501 # While this works just fine: return "Literal[" + ", ".join(f'"{c}"' for c in k.choices) + "]" elif isinstance(k, StringKey): return f"str | {StringKey.__name__}" elif isinstance(k, PathStringKey): return f"str | Path | {StringKey.__name__}" else: raise NotImplementedError(f"Type hint not implemented for Key class {k.__class__}")
ALL_KEY_CLASSES = [ Key, FloatKey, FloatListKey, IntKey, IntListKey, BoolKey, MultipleChoiceKey, StringKey, PathStringKey, ]