############################################################
#
# Author(s): Georg Schnabel
# Email: g.schnabel@iaea.org
# Creation date: 2023/12/27
# Last modified: 2024/10/14
# License: MIT
# Copyright (c) 2023-2024 International Atomic Energy Agency (IAEA)
#
############################################################
from collections.abc import (
Mapping,
MutableMapping,
Sequence,
MutableSequence,
)
from ..interpreter.helpers import (
list_setdefault,
list_set,
)
def recursive_equality_check(obj1, obj2, ids):
"""Compare recursively two nested data structures.
This function performs a comparison of two nested
data structures that may contain basic datatypes
(:class:`int`, :class:`float`, :class:`str`),
objects that are subclasses of :class:`collections.abc.Sequence`
(e.g. :class:`list` and :class:`tuple`)
and :class:`collections.abc.Mapping` (e.g. :class:`dict`).
"""
if id(obj1) == id(obj2):
return True
elif isinstance(obj1, Sequence) and isinstance(obj2, Sequence):
if len(obj1) != len(obj2):
return False
if isinstance(obj1, str):
return obj1 == obj2
if id(obj1) in ids:
raise IndexError("there is a cycle in the nested data structure")
ids.add(id(obj1))
for x, y in zip(obj1, obj2):
if not recursive_equality_check(x, y, ids):
return False
elif isinstance(obj1, Mapping) and isinstance(obj2, Mapping):
if len(obj1) != len(obj2):
return False
if len(set(obj1).intersection(obj2)) != len(obj1):
return False
if id(obj1) in ids:
raise IndexError("there is a cycle in the nested data structure")
ids.add(id(obj1))
for k in obj1:
if not recursive_equality_check(obj1[k], obj2[k], ids):
return False
else:
return obj1 == obj2
return True
[docs]
class EndfPath(Sequence):
"""Class to store a reference to a location in a nested dictionary.
An instance of this class maintains a reference that points to a specific
location in a nested dictionary.
It is assumed that all keys in the dictionaries are either of type
:class:`str` or :class:`int` and that keys of type :class:`str` do not contain slashes.
Under these assumptions, objects in a nested dictionary can be
referenced by concatenating the keys referring to different levels in
the nested hierarchy in a single string, with the individual keys
separated by a slash. For example, the element
``b`` in the nested dictionary ``{'a': {1: {'b': 5}`` can be
referenced by the string ``a/1/b``. Sometimes, it is a good
mental model to think of dictionaries with integer keys as
a kind of array, and it should therefore also be allowed to
rewrite paths of the form ``a/1/b`` as ``a[1]/b``.
Once this class is instantiated with a specific reference,
the methods :func:`get` and :func:`set` can be used to
retrieve and define, respectively, the object at the associated
location of a given nested dictionary. The function
:func:`exists` allows to test whether an object is present
at the referenced location and finally the function
:func:`remove` allows to remove a key rom the nested dictionary.
This class is derived from :class:`collections.abc.Sequence` (like :class:`tuple`)
with each element in the Sequence being a key associated with
a specific level in the hierarchy of a nested dictionary.
As for the :class:`tuple` datatype, references can be concatenated
using the ``+`` operator and two references can be compared
using the ``==`` operator. Also iterating over the individual
keys within a reference works, which are returned as
:class:`EndfPath` instances.
"""
def __init__(self, pathspec="", array_type="dict", leading="dict"):
"""The EndfPath constructor accepts the following parameters.
Parameters
----------
pathspec : Union[int, str, tuple]
This argument can be provided in several forms.
It can be a single key represented by a variable of
type ``int`` or ``str``.
It can be a composite key given in a string with
individual keys separated by a slash, e.g. ``a/b/1``
or equivalently ``a/b[1]``. This argument can
also be a tuple containing the indvidual keys,
e.g. ``('a', 'b', 1)``.
array_type : str
Must be either ``"dict"`` or ``"list"`` and determines
if arrays should be assumed to be represented as
:class:`dict` (default) or :class:`list`.
leading : str
Must be either ``"dict"`` or ``"list"``.
Arrays with leading consecutive integer path will be
represented according to this argument choice,
e.g. for ``(1, 2, a, 3)`` and ``leading="dict"``,
the first two levels will be of type :class:`dict`.
Examples
--------
>>> p1 = EndfPath('a/1/2/c')
>>> p2 = EndfPath('a[1]/2/c')
>>> p3 = EndfPath(('a', '1', '2', 'c'))
>>> p4 = EndfPath(('a', 1, '2', 'c'))
>>> p5 = EndfPath('a[1,2]/c')
>>> p6 = EndfPath('a[1]']) + EndfPath('2/c')
>>> assert p1 == p2 and p2 == p3 and p3 == p4
>>> assert p4 == p5 and p5 == p6
"""
if isinstance(pathspec, EndfPath):
self._path_elements = pathspec._path_elements
self.array_type = pathspec.array_type
self.leading = pathspec.leading
return
if isinstance(pathspec, int):
pathspec = str(pathspec)
if isinstance(pathspec, str):
pathspec = pathspec.split("/")
if not isinstance(pathspec, Sequence):
raise TypeError("expected pathspec to be sequence or string")
self._path_elements = self._standardize_path_tuple(pathspec)
self.array_type = array_type
self.leading = leading
def _standardize_path_tuple(self, path_tuple):
p = path_tuple
p = (str(x) for x in p)
p = (x.strip() for x in p)
p = (x for x in p if x != "")
res = tuple()
for t in p:
res = res + self._expand_array_notation(t)
res = tuple(int(x) if x.isdigit() else x for x in res)
self._validate_path(res)
return res
def _expand_array_notation(self, extvarname):
t = extvarname
if "[" not in t:
return (extvarname,)
if not t.endswith("]"):
raise ValueError(f"invalid path element `{t}`")
idx = t.index("[")
varname = t[:idx]
indices = t[idx + 1 : -1].split(",")
indices = tuple(s.strip() for s in indices)
return (varname,) + indices
def _validate_path(self, path_elements):
for el in path_elements:
if not isinstance(el, int):
if (
not el.replace("_", "").isalnum() or el[0].isdigit()
) and not el == "*":
raise ValueError(f"invalid path element `{el}`")
def __eq__(self, other):
if isinstance(other, EndfPath):
p1 = self._path_elements
p2 = other._path_elements
return all(x == y for x, y in zip(p1, p2))
return False
def __str__(self):
return "/".join([str(x) for x in self._path_elements])
def __repr__(self):
return f"EndfPath('{str(self)}')"
def __getitem__(self, key):
return EndfPath(self._path_elements[key])
def __len__(self, *args, **kwargs):
return len(self._path_elements)
def __add__(self, other):
if not isinstance(other, EndfPath):
other = EndfPath(other)
new_endfpath = EndfPath(
self._path_elements + other._path_elements, self.array_type, self.leading
)
return new_endfpath
def __radd__(self, other):
other = EndfPath(other)
return other.__add__(self)
def __int__(self):
if len(self._path_elements) != 1:
raise TypeError("Can only convert an EndfPath of length one to `int`")
return int(self._path_elements[0])
[docs]
def get(self, dict_like):
"""Retrieve object from nested dictionary.
Parameters
----------
dict_like : dict
The (nested) dictionary from which an object
should be retrieved.
Returns
-------
object
The object at the location referred to by this :class:`EndfPath` instance.
Example
-------
>>> testdict = {'a': 1: {'b': 5}}
>>> p = EndfPath('a/1/b')
>>> assert p.get(testdict) == 5
"""
cur = dict_like
for el in self._path_elements:
cur = cur[el]
return cur
[docs]
def set(self, dict_like, value):
"""Insert an object into a nested dictionary at the :class:`EndfPath` location.
Parameters
----------
dict_like : dict
The (nested) dictionary to be extended/altered.
value : Object
The object that should be inserted at the location
referenced by this :class:`EndfPath` instance.
Note
----
Intermediate dictionaries will be created if missing.
If an intermediate key exists that does not refer to
a :class:`dict` (or other datatype derived from :class:`collections.abc.MutableMapping`),
this method will fail.
Example
-------
>>> testdict = {}
>>> p = EndfPath('a/b/c')
>>> p.set(testdict, 12)
>>> print(testdict)
"""
if isinstance(value, EndfDict):
value = value.unwrap()
cur = dict_like
in_leading = True
pathels = self._path_elements
num_pathels = len(pathels)
dict_mode = self.array_type == "dict"
for i, el in enumerate(pathels):
is_el_int = isinstance(el, int)
in_leading &= is_el_int
if dict_mode or (in_leading and self.leading == "dict"):
if i + 1 < num_pathels:
cur = cur.setdefault(el, {})
else:
cur[el] = value
else:
if i + 1 < num_pathels:
is_next_el_int = isinstance(pathels[i + 1], int)
new_cont = [] if is_next_el_int and not dict_mode else {}
if is_el_int:
cur = list_setdefault(cur, el, new_cont)
else:
cur = cur.setdefault(el, new_cont)
else:
if is_el_int:
list_set(cur, el, value)
else:
cur[el] = value
[docs]
def exists(self, dict_like):
"""Test whether a key exists at the :class:`EndfPath` location.
Parameters
----------
dict_like : dict
The (nested) dictionary for which the existence
of a key at the :class:`EndfPath` location should be checked.
Returns
-------
bool
``True`` if key exists at :class:`EndfPath` location,
otherwise ``False``.
Example
-------
>>> testdict = {'a': {1: 10}}
>>> p = EndfPath('a/1')
>>> p.exists(testdict)
"""
try:
self.get(dict_like)
except (KeyError, IndexError):
return False
except TypeError:
return False
return True
[docs]
def remove(self, dict_like):
"""Remove key from nested dictionary at :class:`EndfPath` location.
Parameters
----------
dict_like : dict
The (nested) dictionary from which the key
at the :class:`EndfPath` location should be removed.
Example
-------
>>> testdict = {'a': {1: 10}}
>>> p = EndfPath('a/1')
>>> p.remove(testdict)
"""
cur = dict_like
for el in self._path_elements[:-1]:
cur = cur[el]
del cur[self._path_elements[-1]]
[docs]
class EndfVariable:
"""Class to keep a reference to a location in a (nested) dictionary.
An instance of this class is connected to a specific key
in a (nested) dictionary and its associated object.
The instance attribute :attr:`value` allows to set or retrieve
the object linked to the specific key in the dictionary.
The purpose of this class is to provide a mechanism to
pass around objects that can be treated like variables
but are always in sync with the data in a given dictionary.
"""
def __init__(self, endf_path, endf_dict, value=None):
"""Create and associate :class:`EndfVariable` with location in dictionary.
Parameters
----------
endf_path : EndfPath
An :class:`EndfPath` instance (or an object that is accepted
by the :class:`EndfPath` constructor) establishing the link
to a specific location in a nested :class:`dict`-like object.
endf_dict : dict
A (nested) :class:`dict`-like object that contains a key
referenced by the ``endf_path`` argument.
"""
if isinstance(endf_dict, EndfDict):
endf_dict = endf_dict.unwrap()
if not isinstance(endf_path, EndfPath):
endf_path = EndfPath(endf_path)
if not endf_path.exists(endf_dict):
if value is None:
raise KeyError(f"variable `{endf_path}` does not exist")
endf_path.set(endf_dict, value)
self._endf_dict = endf_dict
self._path = endf_path
self._varname = endf_path[-1]
self._parent = endf_path[:-1].get(endf_dict)
def __repr__(self):
return (
f"EndfVariable({self._path!r}, "
+ f"{type(self._endf_dict)} at {hex(id(self._endf_dict))}, "
+ f"value={self.value})"
)
def __call__(self):
return self.value
@property
def name(self):
"""Name of the associated key in the nested dictionary.
Example
-------
>>> v = EndfVariable('a/b', {'a': {'b': 5}})
>>> assert v.name == 'b'
"""
return self._varname
@property
def value(self):
"""Value of the associated key in the nested dictionary.
The value of this propert can also be modified, which
accordingly modifies the value stored under the associated
key in the nested :class:`dict`-like object.
Example
-------
>>> d = {'a': {'b': 5}}
>>> v = EndfVariable('a/b', d)
>>> assert v.value == d['a']['b']
>>> v.value = 10
>>> assert d['a']['b'] == 10
"""
return self._varname.get(self._parent)
@value.setter
def value(self, value):
self._varname.set(self._parent, value)
@property
def path(self):
"""Path of associated key in nested dictionary.
Example
-------
>>> d = {'a': {'b': 5}}
>>> v = EndfVariable('a/b', d)
>>> assert v.path == EndfPath('a/b')
"""
return self._path
@property
def endf_dict(self):
return self._endf_dict
@property
def parent_dict(self):
return self._parent
[docs]
class EndfObject:
"""Class for enhanced access to nested data structures.
This class facilitates the interaction with
nested data structures composed of :class:`dict`- and
:class:`list`-like objects as well as primitive data types
(:class:`str`, :class:`int`, and :class:`float`).
More precisely, the retrieval of objects from the nested
structure and the insertion and modification
of objects can be performed by making use of
references as enabled by the
:class:`EndfPath` class. Therefore access to
elements is possible with syntax, such as
``d['a/b/1/c']`` and ``d['a/b[1]/c']``.
Apart from the enhanced capabilities to refer to objects
in a nested data structure, this class behaves
almost in the same way as a normal Python :class:`dict`
or :class:`list` (depending on constructor argument ``obj``).
The exception is that whenever an object
is retrieved that is :class:`dict`- or :class:`list`-like,
it will be converted on-the-fly to an :class:`EndfDict`
or :class:`EndfList`, respectively,
and returned in that form to the user. Also,
:class:`EndfDict` and :class:`EndfList` instances to be associated
with a key are converted to the underlying base object
before being stored (see :func:`unwrap` method).
"""
def __init__(self, obj, array_type, leading):
"""The constructor takes two arguments.
Parameters
----------
obj : Union[None, dict, list]
The :class:`dict`- or :class:`list`-like object for
which enhanced access is desired.
array_type : str
Either ``"dict"`` or ``"list"``. With the former choice,
if an array needs to be created, it will be represented
as :class:`dict`. With the latter choice, it will be
represented by a :class:`list`.
Example
-------
>>> testdict = {'a': 1, 'b': {1: 'u', 2: 'v'}}
>>> viewdict = EndfDict(testdict)
>>> viewdict['b/1'] = 'w'
>>> assert testdict['b'][1] == 'w'
>>> assert viewdict['b/1'] == testdict['b'][1]
>>> viewdict[2, 3] = 7
>>> assert viewdict['2/3'] == 7
"""
if isinstance(obj, (MutableMapping, MutableSequence)):
if isinstance(obj, EndfObject):
obj = obj.unwrap()
self._store = obj
else:
raise TypeError("expected `obj` to be an instance of MutableMapping")
self._root = self
self._path = EndfPath("")
self._array_type = array_type
self._leading = leading
def __repr__(self):
return f"{self._store!r}"
def __str__(self):
return str(self._store)
def __eq__(self, other):
obj1 = self._store
obj2 = other
ret = recursive_equality_check(obj1, obj2, set())
return ret
def __getitem__(self, key):
if isinstance(key, (str, int)):
endf_path = EndfPath(key, self._array_type, self._leading)
elif isinstance(key, Sequence):
endf_path = EndfPath("", self._array_type, self._leading)
for p in key:
endf_path += p
else:
raise ValueError("unsupported key data type")
ret = endf_path.get(self._store)
if isinstance(ret, MutableMapping) and not isinstance(ret, EndfDict):
ret = EndfDict(ret, self._array_type)
ret._root = self._root
ret._path = endf_path
elif isinstance(ret, MutableSequence) and not isinstance(ret, EndfList):
ret = EndfList(ret, self._array_type)
ret._root = self._root
ret._path = endf_path
return ret
def __setitem__(self, key, value):
if isinstance(value, EndfObject):
value = value.unwrap()
if isinstance(key, (str, int)):
endf_path = EndfPath(key, self._array_type, self._leading)
elif isinstance(key, Sequence):
endf_path = EndfPath("", self._array_type, self._leading)
for p in key:
endf_path += p
else:
raise ValueError("unsupported key data type")
endf_path.set(self._store, value)
def __delitem__(self, key):
if not isinstance(key, EndfPath):
endf_path = EndfPath(key, self._array_type, self._leading)
endf_path.remove(self._store)
def __iter__(self):
return iter(self._store)
def __len__(self):
return len(self._store)
[docs]
def exists(self, path):
"""Check whether object exists under path.
Parameters
----------
path : EndfPath
An :class:`EndfPath` or object that is accepted
by its constructor.
"""
path = EndfPath(path, self._array_type, self._leading)
return path.exists(self._store)
def _recursive_unwrap(self, element):
if isinstance(element, MutableMapping):
if isinstance(element, EndfDict):
element = element.unwrap()
for curkey in element:
element[curkey] = self._recursive_unwrap(element[curkey])
if isinstance(element, MutableSequence):
if isinstance(element, EndfList):
element = element.unwrap()
for curidx in range(len(element)):
element[curidx] = self._recursive_unwrap(element[curidx])
return element
[docs]
def unwrap(self, recursive=False):
"""Returns the underlying base object.
The :class:`EndfObject` class can be regarded as an interface wrapping
around an underlying :class:`dict`- or :class:`list`-like object.
This function permits the retrieval of the underlying object.
Parameters
----------
recursive : bool
If ``True``, all :class:`EndfObject` objects present in the
underlying object are recursively converted to their base type,
i.e. either :class:`dict` or :class:`list`.
Returns
-------
Union[dict, list]
The underlying base object.
Note
----
The use of ``recursive=True`` is only necessary if the
user has stored an :class:`EndfObject` object anywhere in the
underlying base object. In contrast, if an attempt
is made to store an :class:`EndfObject` object in another
:class:`EndfObject` object, the former object is (non-recursively)
unwrapped (with :func:`unwrap`) before being stored. Therefore,
as long as the underlying base object is only
accessed via an :class:`EndfDict` instance, there should never be
an :class:`EndfObject` object in the underlying base object.
Example
-------
>>> testdict = {'a': 1, 'b': 2}
>>> viewdict = EndfDict(testdict)
>>> assert id(testdict) != id(viewdict)
>>> retdict = viewdict.unwrap()
>>> assert id(testdict) == id(retdict)
"""
if recursive:
self._store = self._recursive_unwrap(self._store)
return self._store
@property
def root(self):
"""Get the :class:`EndfDict` instance associated with the root dictionary.
Any :class:`dict`-like object retrieved from an :class:`EndfDict`
(termed the root ``EndfDict``) is automatically enwrapped in
an :class:`EndfDict` instances before being returned to the user.
The ``root`` attribute of the returned :class:`EndfDict` instances
holds a reference to the root :class:`EndfDict` object.
Example
-------
>>> testdict = {'a': {'b': {'c': 'd'}}}
>>> viewdict = EndfDict(testdict)
>>> assert id(viewdict) == id(viewdict.root)
>>> viewdict2 = testdict['a/b']
>>> assert id(viewdict2.root) == id(viewdict)
"""
return self._root
@property
def path(self):
"""Get the :class:`EndfPath` associated with this :class:`EndfDict`.
If this :class:`EndfDict` was retrieved from another :class:`EndfDict`,
the ``path`` attribute contains the associated location in
the nested dictionary.
Example
-------
>>> testdict = {'a': {'b': 'c'}}
>>> viewdict = EndfDict(testdict)
>>> viewdict2 = viewdict['a/b']
>>> assert viewdict2.path == EndfPath('a/b')
"""
return self._path
[docs]
class EndfDict(EndfObject, MutableMapping):
"""EndfDict class extends :class:`dict` with :class:`EndfPath` functionality.
The :class:`EndfDict` class behaves like a :class:`list`
but offers in addition enhanced indexing capabilities.
This class is derived from the :class:`EndfObject` class
and inherits its methods.
"""
def __init__(self, obj=None, array_type="dict"):
"""Constructor for the :class:`EndfDict` class.
Parameters
----------
obj : Optional[dict]
If ``obj`` is provided, the instantiated
:class:`EndfDict` class provides a view object.
Otherwise, the created instance will be initialized
with an empty dictionary.
array_type: str
Can be ``"dict"`` or ``"list"`` and indicates
the datatype to use for representing arrays.
"""
if obj is None:
obj = dict()
elif not isinstance(obj, MutableMapping):
raise TypeError("Expected a dict-like object")
EndfObject.__init__(self, obj, array_type, "dict")
def keys(self):
return self._store.keys()
def values(self):
return self._store.values()
def items(self):
return self._store.items()
[docs]
class EndfList(EndfObject, MutableSequence):
"""EndfList class extends :class:`list` with :class:`EndfPath` functionality.
The :class:`EndfDict` class behaves like a :class:`list`
but offers enhanced indexing capabilities.
This class is derived from the :class:`EndfObject` class
and inherits its methods.
"""
def __init__(self, obj, array_type="list"):
"""Constructor for the :class:`EndfList` class.
Parameters
----------
obj : Optional[list]
If ``obj`` is provided, the instantiated
:class:`EndfDict` class provides a view object.
Otherwise, the created instance will be initialized
with an empty list.
array_type: str
Can be ``"dict"`` or ``"list"`` and indicates
the datatype to use for representing arrays.
"""
if obj is None:
obj = list()
elif not isinstance(obj, MutableSequence):
raise TypeError("Expected a list-like object")
EndfObject.__init__(self, obj, array_type, "list")
def insert(self, idx, value):
if isinstance(value, EndfObject):
value = value.unwrap()
self._store.insert(idx, value)