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__