pyiron_base.interfaces.lockable module

A small mixin to lock attribute and method access at runtime.

Sometimes we wish to restrict users of pyiron from changing certain things past certain stages of object lifetime, e.g. the input of jobs should only be changed before it is run, but still need to be able to change them internally. This can be implemented with Lockable and the decorator sentinel(). It should be thought of as a well defined escape hatch that is rarely necessary. Users should never be expected to unlock an object ever again after it has been locked by them or pyiron.

The context manager functionality is implemented in a separate class rather than directly on Lockable to conserve dunder name space real estate and let subclasses be context managers on their own.

Through out the code inside methods of Lockable will use object.__setattr__ and object.__getattribute__ to avoid any overloading attribute access that sibling classes may bring in.

class pyiron_base.interfaces.lockable.Lockable(*args, lock_method: str = 'error', **kwargs)

Bases: object

A small mixin to lock attribute and method access at runtime.

The mixin maintains an read_only and offers a context manager to temporarily unset it. It does not restrict access to any attributes or methods on its own. Instead sub classes are expected to mark methods they wish protected with sentinel(). Wrapped methods will then raise Locked if read_only is set.

If the subclass also implements HasGroups, locking it will iterate over all nodes and (recursively) groups and lock them if possible and vice-versa for unlocking.

Once an object has been locked it should generally not be expected to be (permanently) unlocked again, especially not explicitely by the user.

Subclasses need to initialize this class by calling the inherited __init__, if explicitely overriding it. When not explicitely overriding it (as in the examples below), take care that either the other super classes call super().__init__ or place this class before them in the inheritance order. Also be sure to initialize it before using methods and properties decorated with sentinel().

Subclasses may override _on_lock() and _on_unlock() if they wish to customize locking/unlocking behaviour, provided that they call super() in their overloads.

Let’s start with a simple example; a list that can be locked

>>> class LockList(Lockable, list):
...   __setitem__ = sentinel(list.__setitem__)
...   clear = sentinel(list.clear)
>>> l = LockList([1,2,3])
>>> l
[1, 2, 3]

Deriving adds the read only flag

>>> l.read_only
False

While it is not set, we may mutate the object

>>> l[2] = 4
>>> l[2]
4

Once it is locked, the wrapped methods will raise errors

>>> l.lock()
>>> l.read_only
True
>>> l[1]
2
>>> l[1] = 4
Traceback (most recent call last):
    ...
lockable.Locked: Object is currently locked!  Use unlocked() if you know what you are doing.

You can lock an object multiple times to no effect

>>> l.lock()

From now on every modification should be done with the unlocked() context manager. It returns the unlocked object itself.

>>> with l.unlocked():
...   l[1] = 4
>>> l[1]
4
>>> with l.unlocked() as lopen:
...   print(l is lopen)
...   l[1] = 4
True

sentinel() can be used for methods, item and attribute access.

>>> l.clear()
Traceback (most recent call last):
    ...
lockable.Locked: Object is currently locked!  Use unlocked() if you know what you are doing.
>>> with l.unlocked():
...   l.clear()
>>> l
[]

When used together with HasGroups, objects will be locked recursively.

>>> class LockGroupDict(Lockable, dict, HasGroups):
...   __setitem__ = sentinel(dict.__setitem__)
...
...   def _list_groups(self):
...     return [k for k, v in self.items() if isinstance(v, LockGroupDict)]
...
...   def _list_nodes(self):
...     return [k for k, v in self.items() if not isinstance(v, LockGroupDict)]
>>> d = LockGroupDict(a=dict(c=1, d=2), b=LockGroupDict(c=1, d=2))
>>> d.lock()

Since the first item is a plain dict, it can still be mutated.

>>> type(d['a'])
<class 'dict'>
>>> d['a']['c'] = 23
>>> d['a']['c']
23

Where as the second will be locked from now on

>>> type(d['b'])
<class 'lockable.LockGroupDict'>
>>> d['b']['c'] = 23
Traceback (most recent call last):
    ...
lockable.Locked: Object is currently locked!  Use unlocked() if you know what you are doing.
>>> d['b']['c']
1

but we can unlock it as usual

>>> with d.unlocked():
...   d['b']['d'] = 23
>>> d['b']['d']
23

To use this class with properties, simply decorate the setter

>>> class MyLock(Lockable):
...   def __init__(self, foo):
...     super().__init__()
...     self._foo = foo
...   @property
...   def foo(self):
...     return self._foo
...   @foo.setter
...   @sentinel
...   def foo(self, value):
...     self._foo = value
>>> ml = MyLock(42)
>>> ml.foo
42
>>> ml.foo = 23
>>> ml.lock()
>>> ml.foo = 42
Traceback (most recent call last):
    ...
lockable.Locked: Object is currently locked!  Use unlocked() if you know what you are doing.

It’s possible to change the errors raised into a warning and allow modification by passing lock_method to __init__() or method to lock().

>>> mw = LockList(lock_method="warning")
>>> mw.append(0)
>>> mw.lock()
>>> mw[0] = 1 # will print the warning
>>> mw[0]
1
>>> mw = LockList()
>>> mw.append(0)
>>> mw.lock(method='warning')
>>> mw[0] = 1 # will print the warning
>>> mw[0]
1
lock(method: Literal['error', 'warning'] | None = None)

Set read_only.

Objects may be safely locked multiple times without further effect.

Parameters:

method (str, either "error" or "warning") – if “error” raise an Locked exception if modification is attempted; if “warning” raise a LockedWarning warning; default is “error” or the value passed to the constructor.

Raises:

ValueError – if method is not an allowed value

property read_only: bool

False if the object can currently be written to

Setting this value will trigger _on_lock() and _on_unlock() if it changes.

Type:

bool

unlocked() _UnlockContext

Unlock the object temporarily.

Context manager returns this object again and relocks it after the with statement finished.

Note

lock() vs. unlocked()

There is a small asymmetry between these two methods. lock() can only be done once (meaningfully), while unlocked() is a context manager and can be called multiple times.

exception pyiron_base.interfaces.lockable.Locked

Bases: Exception

exception pyiron_base.interfaces.lockable.LockedWarning

Bases: UserWarning

pyiron_base.interfaces.lockable.sentinel(meth)

Wrap a method to fail if read_only is True on the owning object.

Use together with Lockable.

Parameters:

meth (method) – method to call if read_only is False.

Returns:

wrapped method