+++ title = "A Python Transaction Class" date = 2015-10-13T10:20:43+00:00 [taxonomies] tags = ["python", "programming"] +++ This is a repost of a blog post of 2008. Just for the reference :-) This class allows sub-classes to commit changes to an instance to a history, and rollback to previous states. The final class with an extension for `__setstate__` and `__getstate__` can be found here: [transaction.py](/files/transaction.py) and [transaction_test.py](/files/transaction_test.py). Now to the story, that led to it: I had the need for a transaction class in python and browsing the python cookbook led me to a [small Transaction class](http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/551788). ```python $ python Python 2.5.1 (r251:54863, Oct 30 2007, 13:45:26) [GCC 4.1.2 20070925 (Red Hat 4.1.2-33)] on linux2 Type "help", "copyright", "credits" or "license" for more information. >>> class Transaction(object): ... def __init__(self): ... self.log = [] ... def commit(self): ... self.log.append(self.__dict__.copy()) ... def rollback(self): ... try: ... self.__dict__.update(self.log.pop(-1)) ... except IndexError: ... pass ... ``` Ok, lets have some fun with it. ```python >>> class A(Transaction): ... pass ... >>> a = A() >>> a.test = True >>> a.commit() >>> a.test = False >>> a 'self.__dict__ = {'test': False, 'log': [{'test': True, 'log': [...]}]}' >>> a.rollback() >>> a 'self.__dict__ = {'test': True, 'log': []}' ``` Nice. Let's see if we can commit and rollback several times. ```python >>> a = A() >>> a.test = 1 >>> a.commit() >>> a.test = 2 >>> a.commit() >>> a.test = 3 >>> a 'self.__dict__ = {'test': 3, 'log': [{'test': 1, 'log': [...]}, {'test': 2, 'log': [...]}]}' >>> a.rollback() >>> a 'self.__dict__ = {'test': 2, 'log': [{'test': 1, 'log': [...]}]}' >>> a.rollback() >>> a 'self.__dict__ = {'test': 1, 'log': []}' ``` Ok.. works :) Let's try some lists. ```python >>> a = A() >>> a.test = [ 0, 1 ] >>> a.commit() >>> a.test.append(2) >>> a 'self.__dict__ = {'test': [0, 1, 2], 'log': [{'test': [0, 1, 2], 'log': [...]}]}' >>> a.rollback() >>> a 'self.__dict__ = {'test': [0, 1, 2], 'log': []}' ``` Doh! Ok, someone mentioned that already in the comments. copy.deepcopy() is the key. ```python >>> import copy >>> >>> class Transaction2(Transaction): ... def commit(self, **kwargs): ... self.log.append(copy.deepcopy(self.__dict__)) ... >>> class A(Transaction2): ... pass ... >>> a = A() >>> a.test = [ 0, 1 ] >>> a.commit() >>> a.test.append(2) >>> a 'self.__dict__ = {'test': [0, 1, 2], 'log': [{'test': [0, 1], 'log': []}]}' >>> a.rollback() >>> a 'self.__dict__ = {'test': [0, 1], 'log': []}' ``` Ah, works. Very good. Now another check: ```python >>> a = A() >>> a.test = 1 >>> a.commit() >>> a 'self.__dict__ = {'test': 1, 'log': [{'test': 1, 'log': []}]}' >>> a.other = 2 >>> a 'self.__dict__ = {'test': 1, 'other': 2, 'log': [{'test': 1, 'log': []}]}' >>> a.rollback() >>> a 'self.__dict__ = {'test': 1, 'other': 2, 'log': []}' >>> a.other 2 ``` Oh, a leftover... seems like `self.__dict__` has to be cleared, before the update. ```python >>> class Transaction3(Transaction2): ... def rollback(self, **kwargs): ... try: ... state = self.log.pop(-1) ... self.__dict__.clear() ... self.__dict__.update(state) ... except IndexError: ... pass ... >>> class A(Transaction3): ... pass ... >>> a = A() >>> a.test = 1 >>> a.commit() >>> a 'self.__dict__ = {'test': 1, 'log': [{'test': 1, 'log': []}]}' >>> a.other = 2 >>> a 'self.__dict__ = {'test': 1, 'other': 2, 'log': [{'test': 1, 'log': []}]}' >>> a.rollback() >>> a 'self.__dict__ = {'test': 1, 'log': []}' >>> a.other Traceback (most recent call last): File "", line 1, in AttributeError: 'A' object has no attribute 'other' >>> ``` Ah, works. Very good. Ok, more tests... ```python >>> b = a.ncls >>> a.ncls.test = True >>> a.commit() >>> a.ncls.test = False >>> a.rollback() >>> a 'self.__dict__ = {'ncls': 'self.__dict__ = {'test': True, 'log': []}', 'log': []}' >>> b 'self.__dict__ = {'test': False, 'log': []}' ``` Oh, what if we work with "b", which stills holds the old value? Maybe we should commit() all our attributes also, traversing through them all? Ok, here is such a beast: ```python >>> class TransactionNew1(object): ... def _docommit(self): ... if "log" not in self.__dict__: ... self.__dict__["log"] = list() ... ... self.__dict__["log"].append(copy.deepcopy(self.__dict__)) ... ... def _dorollback(self): ... if "log" not in self.__dict__: ... return ... try: ... state = self.__dict__["log"].pop(-1) ... self.__dict__.clear() ... self.__dict__.update(state) ... except IndexError: ... pass ... ... def commit(self, **kwargs): ... # commit ourselves, then our childs ... self._docommit() ... if kwargs.get("deep", True): ... for child in self.__dict__.values(): ... if isinstance(child, self.__class__): ... child.commit() ... ... def rollback(self, **kwargs): ... # rollback our childs, then ourselves ... if kwargs.get("deep", True): ... for child in self.__dict__.values(): ... if isinstance(child, self.__class__): ... child.rollback() ... self._dorollback() ... ... def __repr__(self): ... return "'self.__dict__ = %s'" % self.__dict__ ... >>> class A(TransactionNew1): ... pass ... >>> a = A() >>> a.ncls = A() >>> b = a.ncls >>> a.ncls.test = True >>> a.commit() >>> a.ncls.test = False >>> a.rollback() >>> a 'self.__dict__ = {'ncls': 'self.__dict__ = {'test': True}', 'log': []}' >>> b 'self.__dict__ = {'test': True, 'log': []}' ``` Ok... looks good, but we lost the reference. id(b) != id(a.ncls) ... ( update: this is fixed in the final version ) Working with it revealed also: ```python >>> a = A() >>> b = a >>> for i in xrange(3): ... b.n = A() ... b.t = "test" ... b = b.n ... a.commit() ... >>> a 'self.__dict__ = {'log': [{'n': 'self.__dict__ = {}', 'log': [], 't': 'test'}, {'n': 'self.__dict__ = {'log': [{'log': []}], 't': 'test', 'n': 'self.__dict__ = {}'}', 'log': [{'t': 'test', 'log': [], 'n': 'self.__dict__ = {}'}], 't': 'test'}, {'n': 'self.__dict__ = {'log': [{'log': []}, {'log': [{'log': []}], 't': 'test', 'n': 'self.__dict__ = {}'}], 't': 'test', 'n': 'self.__dict__ = {'log': [{'log': []}], 't': 'test', 'n': 'self.__dict__ = {}'}'}', 'log': [{'t': 'test', 'log': [], 'n': 'self.__dict__ = {}'}, {'t': 'test', 'log': [{'n': 'self.__dict__ = {}', 't': 'test', 'log': []}], 'n': 'self.__dict__ = {'t': 'test', 'log': [{'log': []}], 'n': 'self.__dict__ = {}'}'}], 't': 'test'}], 't': 'test', 'n': 'self.__dict__ = {'t': 'test', 'log': [{'log': []}, {'n': 'self.__dict__ = {}', 't': 'test', 'log': [{'log': []}]}, {'n': 'self.__dict__ = {'log': [{'log': []}], 't': 'test', 'n': 'self.__dict__ = {}'}', 't': 'test', 'log': [{'log': []}, {'log': [{'log': []}], 't': 'test', 'n': 'self.__dict__ = {}'}]}], 'n': 'self.__dict__ = {'t': 'test', 'log': [{'log': []}, {'n': 'self.__dict__ = {}', 't': 'test', 'log': [{'log': []}]}], 'n': 'self.__dict__ = {'log': [{'log': []}]}'}'}'}' >>> len(str(a)) 1192 ``` Hmm... seems strange.. Ah, self.log was also copied with copy.deepcopy(). So, we have multiple useless copies. Let's "pop()" the state from self.__dict__ before the deepcopy. ```python >>> class TransactionNew2(TransactionNew1): ... def _docommit(self): ... if "log" in self.__dict__: ... oldstate = self.__dict__.pop("log") ... else: ... oldstate = None ... state = copy.deepcopy(self.__dict__) ... if oldstate: ... state["log"] = oldstate ... self.__dict__["log"] = state ... def _dorollback(self): ... if "log" not in self.__dict__: ... return ... try: ... state = self.__dict__["log"] ... self.__dict__.clear() ... self.__dict__.update(state) ... except IndexError: ... pass ... >>> class A(TransactionNew2): ... pass ... >>> >>> a = A() >>> b = a >>> for i in xrange(3): ... b.n = A() ... b.t = "test" ... b = b.n ... a.commit() ... >>> a 'self.__dict__ = {'log': {'log': {'log': {'t': 'test', 'n': 'self.__dict__ = {}'}, 't': 'test', 'n': 'self.__dict__ = {'log': {}, 't': 'test', 'n': 'self.__dict__ = {}'}'}, 't': 'test', 'n': 'self.__dict__ = {'log': {'t': 'test', 'n': 'self.__dict__ = {}'}, 't': 'test', 'n': 'self.__dict__ = {'log': {}, 't': 'test', 'n': 'self.__dict__ = {}'}'}'}, 't': 'test', 'n': 'self.__dict__ = {'t': 'test', 'log': {'log': {'t': 'test', 'n': 'self.__dict__ = {}'}, 't': 'test', 'n': 'self.__dict__ = {'log': {}, 't': 'test', 'n': 'self.__dict__ = {}'}'}, 'n': 'self.__dict__ = {'t': 'test', 'log': {'t': 'test', 'n': 'self.__dict__ = {}'}, 'n': 'self.__dict__ = {'log': {}}'}'}'}' >>> len(str(a)) 671 ``` Ok, saved us a bit of state length. The final version has: ```python >>> a 'self.__dict__ = {'__l': {'__l': {'__l': {'t': 'test'}, 't': 'test'}, 't': 'test'}, 't': 'test', 'n': 'self.__dict__ = {'__l': {'__l': {'t': 'test'}, 't': 'test'}, 't': 'test', 'n': 'self.__dict__ = {'__l': {'t': 'test'}, 't': 'test', 'n': 'self.__dict__ = {'__l': {}}'}'}'}' >>> len(str(a)) 275 ``` Now another thing: ```python >>> a = A() >>> a.n = a >>> a.commit() File "/usr/lib64/python2.5/copy.py", line 162, in deepcopy y = copier(x, memo) RuntimeError: maximum recursion depth exceeded ``` ... Oh, oh! Recursion in commit()... Now we have to check, if we have been there. ```python >>> class TransactionNew3(TransactionNew2): ... def _checksetseen(self, seen): ... if id(self) in seen: ... import sys ... sys.stderr.write("Recursion detected... \n") ... return True ... seen.add(id(self)) ... return False ... ... def commit(self, **kwargs): # pylint: disable-msg=W0613 ... seen = kwargs.get("_commit_seen", set()) ... if self._checksetseen(seen): ... return ... # commit ourselves, then our childs ... self._docommit() ... if kwargs.get("deep", True): ... for child in self.__dict__.values(): ... if isinstance(child, self.__class__): ... child.commit(_commit_seen = seen) ... ... def rollback(self, **kwargs): ... seen = kwargs.get("_rollback_seen", set()) ... if self._checksetseen(seen): ... return ... # rollback our childs, then ourselves ... if kwargs.get("deep", True): ... for child in self.__dict__.values(): ... if isinstance(child, self.__class__): ... child.rollback(_rollback_seen = seen) ... self._dorollback() ... >>> >>> class A(TransactionNew3): ... pass ... >>> a = A() >>> a.n = a >>> a.commit() Recursion detected... >>> ``` The final class with an extension for `__setstate__` and `__getstate__` can be found here: [transaction.py](https://harald.hoyer.xyz/files/transaction.py) and [transaction_test.py](/files/transaction_test.py). Have fun with it :-)