├── README.md ├── setup.py └── undoable.py /README.md: -------------------------------------------------------------------------------- 1 | # Undoable 2 | 3 | A simple Python list (`observed_list`) and dict (`observed_dict`) with callbacks when they are changed and undo/redo using these callbacks. 4 | 5 | Also includes `observed_tree`, a labelled tree with ordered children implemented as Python list with parent pointers and a bit of consistency check. 6 | 7 | ## Implementation 8 | 9 | Implemented using the [command pattern](https://en.wikipedia.org/wiki/Command_pattern). See [my blog post discussing this](https://asrp.github.io/blog/undo-redo.html). 10 | 11 | ## Example 12 | 13 | ``` 14 | git clone https://github.com/asrp/undoable 15 | cd undoable 16 | ``` 17 | 18 | ### Callbacks 19 | 20 | ```python 21 | >>> from undoable import observed_dict, observed_list 22 | >>> def printargs(*args): 23 | ... print(args) 24 | ... 25 | >>> l = observed_list([1, 2, 3]) 26 | >>> l.callbacks.append(printargs) 27 | >>> l.append(4) 28 | ([1, 2, 3, 4], 'append', 4) 29 | >>> l.extend([5, 6, 7]) 30 | ([1, 2, 3, 4, 5, 6, 7], 'extend', [5, 6, 7]) 31 | >>> d = observed_dict({1: "one", 2: "two"}) 32 | >>> d.callbacks.append(printargs) 33 | >>> d[3] = "three" 34 | ({1: 'one', 2: 'two', 3: 'three'}, '__setitem__', 3, 'three') 35 | >>> d2 = observed_dict() 36 | >>> d2.undocallbacks.append(printargs) 37 | >>> d2[1] = "one" 38 | ({1: 'one'}, ('__delitem__', 1), ('__setitem__', 1, 'one')) 39 | ``` 40 | 41 | ### Undo/redo 42 | 43 | ```python 44 | >>> from undoable import UndoLog, observed_dict, observed_list 45 | >>> u = UndoLog() 46 | >>> d = observed_dict({1: "one", 2: "two"}) 47 | >>> l = observed_list([1, 2, 3]) 48 | >>> u.add(d) 49 | >>> u.add(l) 50 | >>> l.append(1) 51 | >>> d[3] = "Hello" 52 | >>> l 53 | [1, 2, 3, 1] 54 | >>> d 55 | {1: 'one', 2: 'two', 3: 'Hello'} 56 | >>> u.undo() 57 | >>> d 58 | {1: 'one', 2: 'two'} 59 | >>> u.undo() 60 | >>> l 61 | [1, 2, 3] 62 | >>> u.redo() 63 | >>> u.redo() 64 | >>> l 65 | [1, 2, 3, 1] 66 | >>> d 67 | {1: 'one', 2: 'two', 3: 'Hello'} 68 | >>> u.start_group("foo") 69 | True 70 | >>> d[53] = "user" 71 | >>> del d[1] 72 | >>> u.end_group("foo") 73 | >>> d 74 | {2: 'two', 3: 'Hello', 53: 'user'} 75 | >>> u.undo() 76 | >>> d 77 | {1: 'one', 2: 'two', 3: 'Hello'} 78 | ``` 79 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup(name='undoable', 4 | version='0.3', 5 | description='Simple Python list and dict with undo/redo', 6 | long_description='A simple Python list and dict with callbacks when they are changed and undo/redo using these callbacks', 7 | url='https://github.com/asrp/undoable', 8 | author='asrp', 9 | author_email='asrp@email.com', 10 | py_modules=['undoable'], 11 | keywords='callbacks undo observer') 12 | -------------------------------------------------------------------------------- /undoable.py: -------------------------------------------------------------------------------- 1 | def deepwrap(elem, callbacks=[], undocallbacks=[], wrapper=None, skiproot=False): 2 | """ Wrap nested list and dict. """ 3 | if wrapper: 4 | output = wrapper(elem) 5 | if output is not None: 6 | return output 7 | if type(elem) == list: 8 | inner = [deepwrap(subelem, callbacks, undocallbacks, wrapper) 9 | for subelem in elem] 10 | if skiproot: 11 | return inner 12 | return observed_list(inner, callbacks=callbacks, undocallbacks=undocallbacks) 13 | elif type(elem) == dict: 14 | inner = dict((key, deepwrap(value, callbacks, undocallbacks, wrapper)) 15 | for key, value in elem.items()) 16 | if skiproot: 17 | return inner 18 | return observed_dict(inner, callbacks=callbacks, undocallbacks=undocallbacks) 19 | else: 20 | return elem 21 | 22 | class UndoLog(object): 23 | def __init__(self): 24 | # self.root: root of the undo tree 25 | # self.undoroot: root of the current event being treated 26 | # self.index: marks the position between undo and redo. Always negative 27 | # (counting from the back). 28 | self.root = self.undoroot = observed_tree("undo root") 29 | self.watched = [] 30 | self.index = -1 31 | 32 | def add(self, elem): 33 | """ Add element to watch. """ 34 | self.watched.append(elem) 35 | elem.undocallbacks.append(self.log) 36 | 37 | def log(self, elem, undoitem, redoitem): 38 | if elem.skiplog > 0: 39 | return 40 | self.clear_redo() 41 | self.undoroot.append(observed_tree(name=(elem, undoitem, redoitem))) 42 | 43 | def clear_redo(self): 44 | if self.undoroot == self.root and self.index != -1: 45 | # Need to delete everything if we aren't the last index! 46 | del self.root[self.index+1:] 47 | self.index = -1 48 | 49 | def start_group(self, name, new_only=False): 50 | if new_only and self.undoroot.name == name: 51 | return False 52 | self.clear_redo() 53 | self.undoroot.append(observed_tree(name)) 54 | self.index = -1 55 | self.undoroot = self.undoroot[-1] 56 | return True 57 | 58 | def end_group(self, name, skip_unstarted=False, delete_if_empty=False): 59 | if name and self.undoroot.name != name: 60 | if skip_unstarted: return 61 | raise Exception("Ending group %s but the current group is %s!" %\ 62 | (name, self.undoroot.name)) 63 | if not self.undoroot.parent: 64 | raise Exception("Attempting to end root group!") 65 | self.undoroot = self.undoroot.parent 66 | if delete_if_empty and len(self.undoroot) == 0: 67 | self.undoroot.pop() 68 | self.index = -1 69 | 70 | def undo(self, node=None): 71 | if node is None: 72 | node = self.root[self.index] 73 | self.index -= 1 74 | if type(node.name) == str: 75 | for child in reversed(node): 76 | self.undo(child) 77 | else: 78 | self.unredo_event(node.name[0], node.name[1]) 79 | 80 | def redo(self, node=None): 81 | if node is None: 82 | node = self.root[self.index + 1] 83 | self.index += 1 84 | if type(node.name) == str: 85 | for child in node: 86 | self.redo(child) 87 | else: 88 | self.unredo_event(node.name[0], node.name[2]) 89 | 90 | def unredo_event(self, elem, item): 91 | elem.skiplog += 1 92 | getattr(elem, item[0])(*item[1:]) 93 | elem.skiplog -= 1 94 | 95 | def pprint(self, node=None): 96 | for line in self.pprint_string(node): 97 | print(line) 98 | 99 | def pprint_string(self, node=None, indent=0): 100 | if node is None: 101 | node = self.root 102 | if type(node.name) != str: 103 | yield "%s%s" % (indent*" ", node.name[2][0]) 104 | return 105 | name = node.name if node.name else "" 106 | yield "%s%s" % (indent*" ", name) 107 | for child in node: 108 | for line in self.pprint_string(child, indent + 2): 109 | yield line 110 | 111 | class observed_list(list): 112 | """ A list that calls all functions in self.undocallbacks 113 | with an (undo, redo) pair any time an operation is applied to the list. 114 | Every function in self.callbacks is called with *redo instead. 115 | 116 | Contains a self.replace function not in python's list for conenience. 117 | """ 118 | def __init__(self, *args, **kwargs): 119 | list.__init__(self, *args) 120 | self.callbacks = kwargs.get("callbacks", []) 121 | self.undocallbacks = kwargs.get("undocallbacks", []) 122 | self.skiplog = 0 123 | 124 | def callback(self, undo, redo): 125 | for callback in self.callbacks: 126 | callback(self, *redo) 127 | for callback in self.undocallbacks: 128 | callback(self, undo, redo) 129 | 130 | def __deepcopy__(self, memo): 131 | return observed_list(self) 132 | 133 | def __setitem__(self, key, value): 134 | try: 135 | oldvalue = self.__getitem__(key) 136 | except KeyError: 137 | list.__setitem__(self, key, value) 138 | self.callback(("__delitem__", key), ("__setitem__", key, value)) 139 | else: 140 | list.__setitem__(self, key, value) 141 | self.callback(("__setitem__", key, oldvalue), 142 | ("__setitem__", key, value)) 143 | 144 | def __delitem__(self, key): 145 | oldvalue = list.__getitem__(self, key) 146 | list.__delitem__(self, key) 147 | self.callback(("__setitem__", key, oldvalue), ("__delitem__", key)) 148 | 149 | def __setslice__(self, i, j, sequence): 150 | oldvalue = list.__getslice__(self, i, j) 151 | self.callback(("__setslice__", i, j, oldvalue), 152 | ("__setslice__", i, j, sequence)) 153 | list.__setslice__(self, i, j, sequence) 154 | 155 | def __delslice__(self, i, j): 156 | oldvalue = list.__getitem__(self, slice(i, j)) 157 | list.__delslice__(self, i, j) 158 | self.callback(("__setslice__", i, i, oldvalue), ("__delslice__", i, j)) 159 | 160 | def append(self, value): 161 | list.append(self, value) 162 | self.callback(("pop",), ("append", value)) 163 | 164 | def pop(self, index=-1): 165 | oldvalue = list.pop(self, index) 166 | self.callback(("append", oldvalue), ("pop", index)) 167 | return oldvalue 168 | 169 | def extend(self, newvalue): 170 | oldlen = len(self) 171 | list.extend(self, newvalue) 172 | self.callback(("__delslice__", oldlen, len(self)), 173 | ("extend", self[oldlen:])) 174 | 175 | def insert(self, i, element): 176 | list.insert(self, i, element) 177 | self.callback(("pop", i), ("insert", i, element)) 178 | 179 | def remove(self, element): 180 | if element in self: 181 | oldindex = self.index(element) 182 | list.remove(self, element) 183 | self.callback(("insert", oldindex, element), ("remove", element)) 184 | 185 | def reverse(self): 186 | list.reverse(self) 187 | self.callback(("reverse",), ("reverse",)) 188 | 189 | def sort(self, *args, **kwargs): 190 | oldlist = self[:] 191 | list.sort(self, *args, **kwargs) 192 | self.callback(("replace", oldlist), ("replace", self[:])) 193 | 194 | def replace(self, newlist): 195 | oldlist = self[:] 196 | self.skiplog += 1 197 | del self[:] 198 | try: 199 | self.extend(newlist) 200 | except: 201 | self.replace(oldlist) # Hopefully no infinite loops happens 202 | self.skiplog -= 1 203 | raise 204 | self.skiplog -= 1 205 | self.callback(("replace", oldlist), ("replace", newlist)) 206 | 207 | class observed_dict(dict): 208 | def __init__(self, *args, **kwargs): 209 | self.callbacks = kwargs.pop("callbacks", []) 210 | self.undocallbacks = kwargs.pop("undocallbacks", []) 211 | dict.__init__(self, *args, **kwargs) 212 | self.skiplog = 0 213 | 214 | def callback(self, undo, redo): 215 | for callback in self.callbacks: 216 | callback(self, *redo) 217 | for callback in self.undocallbacks: 218 | callback(self, undo, redo) 219 | 220 | def __deepcopy__(self, memo): 221 | return observed_dict(self) 222 | 223 | def __setitem__(self, key, value): 224 | try: 225 | oldvalue = self.__getitem__(key) 226 | except KeyError: 227 | dict.__setitem__(self, key, value) 228 | self.callback(("__delitem__", key), ("__setitem__", key, value)) 229 | else: 230 | dict.__setitem__(self, key, value) 231 | self.callback(("__setitem__", key, oldvalue), 232 | ("__setitem__", key, value)) 233 | 234 | def __delitem__(self, key): 235 | oldvalue = self[key] 236 | dict.__delitem__(self, key) 237 | self.callback(("__setitem__", key, oldvalue), ("__delitem__", key)) 238 | 239 | def clear(self): 240 | oldvalue = self.copy() 241 | dict.clear(self) 242 | self.callback(("update", oldvalue), ("clear",)) 243 | 244 | def update(self, update_dict): 245 | oldvalue = self.copy() 246 | dict.update(self, update_dict) 247 | self.callback(("replace", oldvalue), ("update", update_dict)) 248 | 249 | def setdefault(self, key, value=None): 250 | if key not in self: 251 | dict.setdefault(self, key, value) 252 | self.callback(("__delitem__", key), ("setdefault", key, value)) 253 | return value 254 | else: 255 | return self[key] 256 | 257 | def pop(self, key, default=None): 258 | if key in self: 259 | value = dict.pop(self, key, default) 260 | self.callback(("__setitem__", key, value), ("pop", key, default)) 261 | return value 262 | else: 263 | return default 264 | 265 | def popitem(self): 266 | key, value = dict.popitem(self) 267 | self.callback(("__setitem__", key, value), ("popitem",)) 268 | return key, value 269 | 270 | def replace(self, newdict): 271 | oldvalue = self.copy() 272 | self.skiplog += 1 273 | self.clear() 274 | try: 275 | self.update(newdict) 276 | except: 277 | self.replace(oldvalue) # Hopefully no infinite loops happens 278 | self.skiplog -= 1 279 | raise 280 | self.skiplog -= 1 281 | self.callback(("replace", newdict), ("replace", oldvalue)) 282 | 283 | class observed_tree(list): 284 | """ An ordered list of children. The only difference with list is a maintained parent pointer. All elements of this list are expected to be trees or have parent pointers and a reparent function. 285 | 286 | Expects to contain at most one copy of any elements. 287 | 288 | Can be used to model XML, JSON documents, DOM, etc.""" 289 | def __init__(self, name=None, value=[], parent=None, 290 | callbacks=None, undocallbacks=None): 291 | list.__init__(self, value) 292 | self.parent = parent 293 | self.name = name 294 | self.callbacks = callbacks if callbacks else [] 295 | self.undocallbacks = undocallbacks if undocallbacks else [] 296 | self.skiplog = 0 297 | 298 | def callback(self, undo, redo, origin=None): 299 | # TODO: Need to think of a better way to pass self along 300 | if origin is None: 301 | origin = self 302 | for callback in self.callbacks: 303 | callback(origin, *redo) 304 | for callback in self.undocallbacks: 305 | callback(origin, undo, redo) 306 | if self.parent: 307 | self.parent.callback(undo, redo, origin) 308 | 309 | def _reparent(self, newparent, remove=False): 310 | if remove and self.parent: 311 | self.parent.remove(self, reparent=False) 312 | self.parent = newparent 313 | 314 | def __setitem__(self, key, value): 315 | try: 316 | oldvalue = self.__getitem__(key) 317 | except KeyError: 318 | value._reparent(self, True) 319 | list.__setitem__(self, key, value) 320 | # What to do about undo on that reparent 321 | self.callback(("__delitem__", key), ("__setitem__", key, value)) 322 | else: 323 | oldvalue._reparent(None) 324 | value._reparent(self, True) 325 | list.__setitem__(self, key, value) 326 | self.callback(("__setitem__", key, oldvalue), 327 | ("__setitem__", key, value)) 328 | 329 | def __delitem__(self, key): 330 | oldvalue = list.__getitem__(self, key) 331 | list.__delitem__(self, key) 332 | oldvalue._reparent(None) 333 | # What to do about undo on that reparent? 334 | # __setitem__ takes care of this at the moment. 335 | self.callback(("__setitem__", key, oldvalue), ("__delitem__", key)) 336 | 337 | def __setslice__(self, i, j, sequence): 338 | oldvalue = list.__getslice__(self, i, j) 339 | self.callback(("__setslice__", i, j, oldvalue), 340 | ("__setslice__", i, j, sequence)) 341 | for child in oldvalue: 342 | child._reparent(None, True) 343 | for child in sequence: 344 | child._reparent(self, True) 345 | list.__setslice__(self, i, j, sequence) 346 | 347 | def __delslice__(self, i, j): 348 | oldvalue = list.__getitem__(self, slice(i, j)) 349 | for child in oldvalue: 350 | child._reparent(None, True) 351 | list.__delslice__(self, i, j) 352 | self.callback(("__setslice__", i, i, oldvalue), ("__delslice__", i, j)) 353 | 354 | def __eq__(self, other): 355 | return self is other 356 | 357 | def append(self, value): 358 | list.append(self, value) 359 | value._reparent(self, True) 360 | self.callback(("pop",), ("append", value)) 361 | 362 | def pop(self, index=-1): 363 | oldvalue = list.pop(self, index) 364 | oldvalue._reparent(None) 365 | self.callback(("append", oldvalue), ("pop", index)) 366 | return oldvalue 367 | 368 | def extend(self, newvalue): 369 | oldlen = len(self) 370 | list.extend(self, newvalue) 371 | for value in newvalue: 372 | value._reparent(self, True) 373 | self.callback(("__delslice__", oldlen, len(self)), 374 | ("extend", self[oldlen:])) 375 | 376 | def insert(self, i, element): 377 | element._reparent(self, True) 378 | list.insert(self, i, element) 379 | self.callback(("pop", i), ("insert", i, element)) 380 | 381 | def remove(self, element, reparent=True): 382 | if element in self: 383 | oldindex = self.index(element) 384 | list.remove(self, element) 385 | if reparent: 386 | element._reparent(None) 387 | self.callback(("insert", oldindex, element), ("remove", element)) 388 | 389 | def reverse(self): 390 | list.reverse(self) 391 | self.callback(("reverse",), ("reverse",)) 392 | 393 | def sort(self, *args, **kwargs): 394 | oldlist = self[:] 395 | list.sort(self, *args, **kwargs) 396 | self.callback(("replace", oldlist), ("replace", self[:])) 397 | 398 | def replace(self, newlist): 399 | oldlist = self[:] 400 | self.skiplog += 1 401 | del self[:] 402 | try: 403 | self.extend(newlist) 404 | except: 405 | self.replace(oldlist) # Hopefully no infinite loops happens 406 | self.skiplog -= 1 407 | raise 408 | self.skiplog -= 1 409 | self.callback(("replace", oldlist), ("replace", newlist)) 410 | 411 | # Helper functions. Maybe they should be elsewhere. 412 | def tops(self, condition): 413 | """ Return the top (incomparable maximal) anti-chain amongst descendants of widget that satisfy condition""" 414 | for child in self: 415 | if condition(child): 416 | yield child 417 | else: 418 | for gc in child.tops(condition): 419 | yield gc 420 | 421 | @property 422 | def descendants(self): 423 | for child in self: 424 | for gc in child.descendants: 425 | yield gc 426 | yield child 427 | 428 | # Add this to callbacks for debugging 429 | def printargs(*args): 430 | print(args) 431 | 432 | if __name__ == '__main__': 433 | # minimal demonstration 434 | u = UndoLog() 435 | d = observed_dict({1: "one", 2: "two"}) 436 | l = observed_list([1, 2, 3]) 437 | u.add(d) 438 | u.add(l) 439 | l.append(1) 440 | d[3] = "Hello" 441 | u.start_group("foo") 442 | d[53] = "user" 443 | del d[1] 444 | u.end_group("foo") 445 | u.start_group("foo") 446 | d["bar"] = "baz" 447 | u.end_group("foo") 448 | deep = {"abc": "def", "alist": [1, 2, 3]} 449 | obs_deep = deepwrap(deep, undocallbacks=[u.log]) 450 | u.watched.append(obs_deep) 451 | obs_deep["d"] = "e" 452 | obs_deep["alist"].append(4) 453 | # Now run multiple u.undo(), u.redo() 454 | --------------------------------------------------------------------------------