Undo/Redo

Undo is a pretty common application functionality. I present a Python implementation where a History object is used to perform any action that may be undone. This object can go back and forth in the actions history. The idea is that, for every action, you provide one way to perform the action and one way to revert the action.

Implementation

The History class definition follows. An instance maintains a list of actions, each action being composed of a way to perform something and a way to revert it:

[download history.py]

class Action(object):
    """Describes an action, and a way to revert that action"""
 
    def __init__(self, do, undo):
        """Both do and undo are in the form (function, [arg1, arg2, ...])."""
        self._do = do
        self._undo = undo
 
    def do(self):
        fun, args = self._do
        return fun(*args)
 
    def undo(self):
        fun, args = self._undo
        return fun(*args)
 
 
class History(object):
    "Maintains a list of actions that can be undone and redone."
 
    def __init__(self):
        self._actions = []
        self._last = -1
 
    def _push(self, action):
        if self._last < len(self._actions) - 1:
            # erase previously undone actions
            del self._actions[self._last + 1:]
        self._actions.append(action)
        self._last = self._last + 1
 
    def undo(self):
        if self._last < 0:
            return None
        else:
            action = self._actions[self._last]
            self._last = self._last - 1
            return action.undo()
 
    def redo(self):
        if self._last == len(self._actions) - 1:
            return None
        else:
            self._last = self._last + 1
            action = self._actions[self._last]
            return action.do()
 
    def add(self, do, undo):
        """Does an action and adds it to history.
 
        Both do and undo are in the form (function, [arg1, arg2, ...]).
 
        """
        action = Action(do, undo)
        self._push(action)
        return action.do()

Usage

Download the history module and place it in your working directory to try out the examples.

First, a History instance must be created.

from history import History
 
h = History()

You can then add an action to the history…

h.add(perform, revert)

… and revert or redo that action later:

h.undo()
h.redo()

The add() method accepts as arguments two functions and their corresponding list of arguments:

def foo(x, y):
    ....
 
def unfoo(z):
    ....
 
perform = (foo, [arg1, arg2])
revert = (unfoo, [arg3])
 
h.add(perform, revert)

I take as an example the modification of a dictionary d:

import history
 
d = {"x": 0, "y": 0}
h = history.History()
 
def modify(d, key, value):
    d[key] = value
 
def reset(d, saved):
    d.clear()
    d.update(saved)
 
saved = d.copy()
perform = (modify, [d, "x", 9999])
revert = (reset, [d, saved])
 
print "Original:  ", d
 
h.add(perform, revert)
print "Modified:  ", d
 
h.undo()
print "After undo:", d
 
h.redo()
print "After redo:", d
Original:   {'y': 0, 'x': 0}
Modified:   {'y': 0, 'x': 9999}
After undo: {'y': 0, 'x': 0}
After redo: {'y': 0, 'x': 9999}

Advanced Example

In the code below, undo/redo is provided to a Calculator class through a Python decorator.

from history import History
 
class Calculator(object):
    def __init__(self):
	self.value = 0.
        self._history = History()
        self.undo = self._history.undo
        self.redo = self._history.redo
 
    def _operation(method):
        def decorated(self, n):
            saved = self.value
            perform = (method, (self, n))
            revert = (self._set_value, (saved,))
            self._history.add(perform, revert)
        return decorated
 
    def _set_value(self, n):
        self.value = n
 
    @_operation
    def add(self, n):
	self.value += n
 
    @_operation
    def sub(self, n):
	self.value -= n
 
    @_operation
    def mult(self, n):
        self.value *= n
 
    @_operation
    def div(self, n):
	self.value /= n
 
 
calculator = Calculator()
print "Initial value:", calculator.value
calculator.add(2)
print "+ 2 =", calculator.value
calculator.sub(3)
print "- 3 =", calculator.value
calculator.mult(5)
print "* 5 =", calculator.value
calculator.div(-2)
print "/ -2 =", calculator.value
 
print
 
for _ in range(4):
    calculator.undo()
    print "undo:", calculator.value
 
print
 
for _ in range(4):
    calculator.redo()
    print "redo:", calculator.value

Output:

Initial value: 0.0
+ 2 = 2.0
- 3 = -1.0
* 5 = -5.0
/ -2 = 2.5

undo: -5.0
undo: -1.0
undo: 2.0
undo: 0.0

redo: 2.0
redo: -1.0
redo: -5.0
redo: 2.5

Active Object (Actor) in Python

A simple implementation of the active object design pattern in Python.

[download actor.py]

import Queue
import threading
 
def command(method):
    """Decorator to enqueue method calls in Actor instances."""
    def enqueue_call(self, *args, **kwargs):
        args = list(args)
        args.insert(0, self)
        self._commands.put((method, args, kwargs))
    return enqueue_call
 
 
class Actor(threading.Thread):
    """A simple implementation of the active object design pattern."""
 
    def __init__(self):
        threading.Thread.__init__(self)
        self._commands = Queue.Queue()
        self._must_stop = False
 
    @command
    def stop(self):
        self._must_stop = True
 
    def run(self):
        while not self._must_stop:
            cmd, args, kwargs = self._commands.get()
            cmd(*args, **kwargs)
 
 
if __name__ == '__main__':
 
    import time
    class MyActor(Actor):
        @command
        def say(self, sentence):
            time.sleep(0.1)
            print sentence
 
    a = MyActor()
    a.start()
    a.say("Hello.")
    a.say("Bye.")
    a.stop()
    print "Main thread finished."

peanut: a JPEG comment editor

It’s useful to store informations about photo files such as where a photo was shot and who appears on it. JPEG files have a comment field but it’s surprisingly difficult to find a Linux application to edit them. So I wrote a script, that is easy to integrate with image viewers that support custom actions such as Mirage and GQview.

Peanut in action

Here are the instructions to use peanut. First, you’ve got to install pygtk and pyexiv2, through your distribution package manager. Then, download the script and save it somewhere (personnally, I save scripts in /home/pierre/bin/). Make it executable, either through your file navigator or through the command-line (chmod +x peanut). Now, you should be able to run peanut from the command-line, typing peanut FILE, where FILE is a JPEG file.

And to make editing comments even easier, you can set up a custom action in a picture viewer. I describe this for the Mirage image viewer.

A Custom Action in Mirage
Choose Edit -> Custom Actions -> Configure, press the + button to add a new action. Enter a name such as Edit Comment, fill the Command field with the full path to the peanut script followed by %F (for example, /home/pierre/bin/peanut %F), and finally click on the shortcut box and type a key combination that will launch the comment editor (for instance c). Press OK and you’re good to go.

Now, you can browse pictures through Mirage, and type c when you want to edit a comment: the editor will pop up.

[download peanut]

imapfwd: forward mail to a main box

I have several email addresses but I want all messages to arrive in a single mailbox. imapfwd connects to an imap server and forward all unread mails to an other address through SMTP. Here are the recognized options:

$ ./imapfwd --help
Usage: imapfwd [options] USERNAME IMAPHOST SMTPHOST DESTINATION

Options:
  -h, --help       show this help message and exit
  --pass=PASSWORD  imap password
  -p PORT          imap port
  --ssl            use SSL for imap
  --prefix=PREFIX  append a string to subject. ex: [box1]

I use the script in a cronjob. To set up the job, type crontab -e and add a line like this (I’ve saved the script in /home/pierre/bin/):

# m    h  dom mon dow   command
 */10  *   *   *   *    /home/pierre/bin/imapfwd --pass or4nge mylogin imap.hmail.com smtp.isp.com mainbox@fmail.com

Here is the script:

[download imapfwd]

#!/usr/bin/env python
 
# Copyright 2009 (C) Pierre Duquesne <stackp@online.fr>
# Licensed under the BSD Revised License.
 
import imaplib
import smtplib
import sys
import optparse
import getpass
import email.parser
 
USAGE="imapfwd [options] USERNAME IMAPHOST SMTPHOST DESTINATION" 
 
def parse_args():
    "Parse command-line arguments."
    parser = optparse.OptionParser(usage=USAGE)
    parser.add_option('--pass', dest='password', default=None,
                      help="imap password")
    parser.add_option('-p', dest='port', type='int', default=None,
                      help="imap port")
    parser.add_option('--ssl', dest='ssl', default=False, 
                      action='store_true',
                      help="use SSL for imap")
    parser.add_option('--prefix', dest='prefix', default=None, 
                      action='store',
                      help="append a string to subject. ex: [box1]")
    options, remainder = parser.parse_args(sys.argv[1:])
    return options, remainder
 
options, args = parse_args()
try:
    username = args[0]
    imaphost = args[1]
    smtphost = args[2]
    destination = args[3]
except:
    print "Error: some arguments are missing. Try --help."
    print USAGE
    sys.exit(1)
 
# connect to imap
print 'Connecting to %s as user %s ...' % (imaphost, username)
if options.ssl: 
    IMAP = imaplib.IMAP4_SSL
else:
    IMAP = imaplib.IMAP4
try:
    if options.port:  
        imap_server = IMAP(imaphost, options.port)
    else:     
        imap_server = IMAP(imaphost)
    if not options.password: 
        options.password = getpass.getpass()
    imap_server.login(username, options.password)
except Exception,e:
        print 'Error:', e;  sys.exit(1)
 
# connect to smtp
try:
    smtp_server = smtplib.SMTP(smtphost)
except Exception, e:
    print 'Could not connect to', smtphost, e.__class__, e
    sys.exit(2)
 
# filter unseen messages
imap_server.select("INBOX")
resp, items = imap_server.search(None, "UNSEEN")
numbers = items[0].split()
 
# forward each message
sender = "%s@%s" % (username, imaphost)
for num in numbers:
    resp, data = imap_server.fetch(num, "(RFC822)")
    text = data[0][1]
    if options.prefix:
        parser = email.parser.HeaderParser()
        msg = parser.parsestr(text)
        msg['Subject'] = options.prefix + msg['Subject']
        text = msg.as_string()
    smtp_server.sendmail(sender, destination, text)
    # Flag message as Seen (may have already been done by the server anyway)
    imap_server.store(num, '+FLAGS', '\\Seen')
 
imap_server.close()
smtp_server.quit()

Scalpel sound editor

Edit: Scalpel now has its own website.