Problem 692903 · medium · Level 06 Heuristics & Optimization

Undo for Any Settings Object

py-metaprogramming · py-decorators · class decorators · __setattr__ · undo history

An editor has several settings objects, and every one of them should support undo. Rather than write the history code into each class, write a class decorator undoable that adds it to any class:

@undoable
class Settings:
    def __init__(self, theme, font_size):
        self.theme = theme
        self.font_size = font_size

After decoration:

  • Assignments and deletions made while the object is being constructed (inside __init__) are not recorded. Every later obj.name = value and del obj.name that succeeds is recorded; one that raises is not. An assignment made while a recorded change is running (a property setter storing its value in self._font_size, say) is part of that change and is not recorded separately.
  • obj.undo() reverts the most recent recorded change that has not been undone and returns the attribute's name. Reverting assigns the value the attribute had before (as it read then, so through a property's setter if there is one), or deletes the attribute if reading it had failed. Nothing an undo does is recorded. With nothing left to undo, undo() raises IndexError.
  • obj.changes() returns how many recorded changes can still be undone.
  • The class keeps working as before: its properties, its own __setattr__ if it has one, its other methods, and its name and docstring. undoable returns the same class object, modified.

The tests decorate classes inside setup helpers you can call with Run: settings_class() (a class-level default tab_width and a checked property font_size), session(ops) (applies ("set", name, value), ("del", name) and ("undo",) to Settings("dark", 12)), same_class(), separate_histories(), own_setattr() and long_session(n, seed). outcome(call) returns a result or the exception's name.

Examples

Input:  session([("set", "theme", "light"), ("set", "font_size", 0), ("set", "font_size", 14), ("undo",), ("undo",), ("undo",)])
Output: ([None, "ValueError", None, "font_size", "theme", "IndexError"], [("theme", "dark"), ("font_size", 12), ("tab_width", 4)], 0)

Input:  session([("set", "margin", 2), ("del", "theme"), ("undo",)])
Output: ([None, None, "theme"], [("margin", 2), ("theme", "dark"), ("font_size", 12), ("tab_width", 4)], 1)

Constraints

  • Decorated classes store their attributes in an instance __dict__ (no __slots__); you may add one attribute of your own for the history.

Goals

  • Write a class decorator that changes a class's behaviour and returns the class itself
  • Wrap a class's `__init__`, `__setattr__` and `__delattr__` while keeping what they did before
  • Record the old value of every change, so that each change can be reverted in reverse order
Starting Python…