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] @_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] @_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] @_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,
]