Source code for scm.pisa.entry

from __future__ import annotations

import difflib
import inspect
from abc import ABC, abstractmethod
from functools import wraps
from typing import Any, Callable, Final, TypeVar, Iterator


# should be used to decorate any methods that only make sense for non-unique entries
# Don't add type hints here!
[docs]def _check_unique(meth): """Decorator that is used to wrap any ``Entry`` method that is not meant for unique entries (like ``__getitem__``) :param meth: ``Entry`` method to decorate :type meth: Callable :raises ValueError: When wrapped method is called on unique instances of the ``Entry`` object :return: The wrapped method :rtype: Callable """ @wraps(meth) def wrapper(obj: Entry, *args: Any, **kwargs: Any) -> Callable: assert isinstance(obj, Entry) if obj.unique: raise ValueError(f"Calling method {meth.__name__} not allowed on unique entry {obj}") return meth(obj, *args, **kwargs) return wrapper
TEntry = TypeVar("TEntry", bound="Entry")
[docs]class Entry(ABC): """ Abstract base class serving as the base class for the Block and Key classes. Also handles index notation '[]' for repeated blocks and keys by automatically creating new instances of itself upon indexing. """ # Saves all the possible metadata for present in the input definitions. # Mimics dataclass behavior, but needs custom __init__ and __repr__ implementation.
[docs] def __init__( self, name: str | None = None, comment: str = "", hidden: bool = False, unique: bool = True, gui_name: str | None = None, default: Any = None, ispath: bool = False, unit: str = "", choices: list[str] | None = None, hiddenchoices: list[str] | None = None, header: bool = False, ordered: bool = False, isatomlist: bool = False, # only in reaxff.json ? rootdir: str | None = None, # key is not present in https://wiki.scm.com/index.php?title=Input_handling ? engines: str | None = None, extra_info: str | None = None, gui_type: str | None = None, shared_id: str | None = None, default_engine: str | None = None, range: str | None = None, _keyword_dict: dict[str, dict] | None = None, ) -> None: # This should always be the first statement in the the __init__ function. Take care when overwriting the # the __init__ in subclasses self._init_finished: bool = False # keep track whether this is an autogenerated class defined in scm.input_classes, or one of the base classes from scm.pisa.block import ALL_BLOCK_CLASSES from scm.pisa.key import ALL_KEY_CLASSES self._is_input_class = self.__class__ not in [Entry, *ALL_BLOCK_CLASSES, *ALL_KEY_CLASSES] # copying of kwargs if name is None: self.name = self.__class__.__name__ else: self.name = name self.comment: Final = comment self.hidden: Final = hidden self.unique: Final = unique self.gui_name: Final = gui_name self.default: Final = default self.ispath: Final = ispath self.unit: Final = unit self.choices: Final[list[str] | None] = choices self.hiddenchoices: Final[list[str] | None] = hiddenchoices self.allow_header: Final = header self.ordered: Final = ordered self.isatomlist: Final = isatomlist self.rootdir: Final = rootdir self.engines: Final = engines self.extra_info: Final = extra_info self.gui_type: Final = gui_type self.shared_id: Final = shared_id self.default_engine: Final = default_engine self.range: Final = range self._keyword_dict: Final = _keyword_dict self.__post_init__() self._entry_list: list[Entry] = [] self._value_changed: bool = False self._init_finished = True
[docs] @abstractmethod def __post_init__(self) -> None: """Called from the :meth:`__init__`. This is the method that should be overwritten in subclasses to implement extra behavior. Note that in the autogenerated subclasses, this method is overwritten to define all the instance attributes, so any behavior defined in this method will not be executed in the subclasses that users user to generate AMS input strings with. """ pass
[docs] def __setattr__(self, __name: str, __value: Any) -> None: if not __name.startswith("_") and self._init_finished and self._is_input_class and not hasattr(self, __name): close_matches = difflib.get_close_matches(__name, self.__dict__.keys(), cutoff=0.1) msg = f"Object of type {self.__class__} has no attribute called '{__name}'." if len(close_matches) == 1: msg += f" Did you possibly mean to access {close_matches[0]}?" elif len(close_matches) > 1: msg += f" Did you possibly mean to access any of [{', '.join(close_matches)}]?" raise AttributeError(msg) return super().__setattr__(__name, __value)
@property def value_changed(self) -> bool: """Property that determines if an entry will be processed by :meth:`get_input_string`. Will be set to true if the entry was touched in any way. For keys, this means trying to change their values (even if changed to the default value). For blocks, this means any of their keys were touched in any way. :return: Whether the value of the entry was changed :rtype: bool """ return (not self.unique and bool(self._entry_list)) or self._value_changed
[docs] def get_input_string(self, indent_lvl: int = 0) -> str: """Generates a valid AMS input string. Note that this method exists for every entry, but generally as user you want to call it on the highest level :class:`DriverBlock` instance, which will recursively include all its entries. :param indent_lvl: With how many tabs to indent the string. Useful for dealing with snippets, not with DriverBlock. Defaults to 0. :type indent_lvl: int, optional :return: Valid AMS input string. :rtype: str """ # do not override, override '_single_input_string' or '_repeated_input_string' # for non unique keys, users might still set a single value via # 'Key.val = 4', instead of 'Key[0] = 4', resulting in empty entry list if self.unique or not len(self._entry_list): return self._get_single_input_string(indent_lvl) else: return self._get_repeated_input_string(indent_lvl)
[docs] @abstractmethod def _get_single_input_string(self, indent_lvl: int = 0) -> str: """This method produces the input string for unique Entries. This is the method that needs to be overwritten to implement specific subclasses, not :meth:`get_input_string`. :param indent_lvl: Indentation level, defaults to 0 :type indent_lvl: int, optional :return: string representing the ``Entry`` in the input system. :rtype: str """ pass
[docs] @_check_unique def __getitem__(self: TEntry, index: int) -> TEntry: """Using the python index notation `[]` on a repeated entry (:attr:`self.unique` equal to `False`), will return a new instance of the same class. This allows you to create a list of entries. The indexing starts at zero and must be sequential, e.g: .. code:: ipython3 block.repeated_entry[0].Temperature = 10 block.repeated_entry[1].Temperature = 20 block.repeated_entry[2].Temperature = 30 :param self: Typed as a type variable to indicate that the return type is equal to the type of the object you are calling __getitem__ on :type self: TEntry :param index: Integer index (slicing is not supported). Must be called sequentially, starting at 0. :type index: int :raises IndexError: Raised when called with a non-sequential index, e.g. `[2]` when `[1]` was not accessed before. :return: Object of the same class as the object you are indexing. Returned object is stored internally as a list and will be present in the final input :rtype: TEntry """ # Automatically instantiate new instances of the class when user indexes # repeated entries. Only allow incremented indexing starting at zero: # Block.RepeatedKey[0] = 'foo' # Block.RepeatedKey[1] = 'foo2' try: return self._entry_list[index] except IndexError: if index == len(self): self._entry_list = [ *self._entry_list, self.__class__( name=self.name, unique=False, # don't want infinite recursion of repeated keys comment=self.comment, hidden=self.hidden, gui_name=self.gui_name, default=self.default, ispath=self.ispath, unit=self.unit, choices=self.choices, hiddenchoices=self.hiddenchoices, header=self.allow_header, ordered=self.ordered, isatomlist=self.isatomlist, rootdir=self.rootdir, engines=self.engines, extra_info=self.extra_info, gui_type=self.gui_type, shared_id=self.shared_id, default_engine=self.default_engine, range=self.range, ), ] return self.__getitem__(index) else: raise IndexError( f"Tried to access {self.name}[{index}], but {self.name}[{len(self)}] does not exist. " f"First create {self.name}[{len(self)}]" )
# For non uniques we need to setup __iter__ since by default python will try to access __getitem__, # which creates an inf iterator due to the self extending nature of __getitem__ above ...
[docs] @_check_unique def __iter__(self: TEntry) -> Iterator[TEntry]: return iter(self._entry_list)
[docs] @_check_unique def _get_repeated_input_string(self, indent_lvl: int) -> str: """For repeated entries, simply generate a multiline string by repeatedly calling :meth:`get_input_string` for every entry in ``self._entry_list`` :param indent_lvl: Indentation level :type indent_lvl: int :return: Multiline string representing all items in the repeated Entry. :rtype: str """ return "".join(e.get_input_string(indent_lvl=indent_lvl) for e in self._entry_list)
[docs] @abstractmethod @_check_unique def __setitem__(self, index: int, value: Any) -> None: pass
[docs] @_check_unique def __delitem__(self, __key: int) -> None: self._entry_list.__delitem__(__key)
[docs] @_check_unique def insert(self, index: int, value: Entry) -> None: return self._entry_list.insert(index, value)
[docs] @_check_unique def __len__(self) -> int: return len(self._entry_list)
[docs] def __str__(self) -> str: input_string = self.get_input_string() return input_string if len(input_string) else "<Empty Entry>"
# repr should allow entry == eval(repr(entry)), so can be used in the class # generation
[docs] def __repr__(self) -> str: """The repr functionality is the core of the source code generation, since it allows the ``Block`` and ``Key`` classes to generate a string that can be used to construct an instance of themselves. This method decreases verbosity in the repr string by omitting arguments equal to the default values, so the autogenerated code looks a bit cleaner. :return: String that can be used to construct an identical instance of the object. :rtype: str """ signature = inspect.signature(self.__class__.__init__) repr_string = f"{self.__class__.__name__}(" for key, value in self.__dict__.items(): # to decrease verbosity in generated classes if value is self._keyword_dict: continue # renamed header argument to 'allow_header' for clarity if key == "allow_header": key = "header" if key not in signature.parameters: continue p = signature.parameters[key] if value != p.default: repr_string += f"{key}={repr(value)}, " return repr_string[:-2] + ")"
# only really used for unit tests, should not rely on this to always return expected result.
[docs] def __eq__(self, __value: object) -> bool: return __value.__class__ == self.__class__ and self.__dict__ == __value.__dict__