8

I'm trying to make an object act like a built-in list, except that its value be saved once modified.

The implementation I come up with is wrapping a list in a PersistentList class. For every access to method that may change the list, the wrapper delegates to the wrapped list, and save it to a key-value database after it's invoked.

Code:

class PersistentList(object):
    def __init__(self, key):
        self.key = key 
        self._list = db.get(key, []) 

    def __getattr__(self, name):
        attr = getattr(self._list, name)
        if attr:
            if attr in ('append', 'extend', 'insert', 'pop',
                'remove', 'reverse', 'sort'):
                attr = self._autosave(attr)
            return attr
        raise AttributeError

    def _autosave(self, func):
        @wraps(func)
        def _(*args, **kwargs):
            ret = func(*args, **kwargs)
            self._save()
            return ret 
        return _

    def _save(self):
        db.set(self.key, self._list)

There are several problems with this implementation:

  1. I have to decorate methods like append every time they are accessed, is there a better way to decorate multiple methods of some object?

  2. Operations like l += [1,2,3] don't work because I haven't implemented the iadd method.

What can I do to simplify this?

satoru
  • 31,822
  • 31
  • 91
  • 141

4 Answers4

6

I like @andrew cooke's answer but I see no reason why you can't derive directly from a list.

class PersistentList(list):
    def __init__(self, *args, **kwargs):
        for attr in ('append', 'extend', 'insert', 'pop', 'remove', 'reverse', 'sort'):
            setattr(self, attr, self._autosave(getattr(self, attr))
        list.__init__(self, *args, **kwargs)
    def _autosave(self, func):
        @wraps(func)
        def _func(*args, **kwargs):
            ret = func(*args, **kwargs)
            self._save()
            return ret 
        return _func
mattbornski
  • 11,895
  • 4
  • 31
  • 25
  • What does your `_save` look like? And how do you load the object back? My naive attempts to do so using `pickle` don't work. `pickle.dumps(self)` doesn't work while `pickle.dumps(list(self))` does. Or would you just convert to a list every time `_save` runs()? – kuzzooroo Jul 13 '14 at 22:56
  • Also, what made you confident that you didn't have to include `'__delitem__', '__delslice__', '__iadd__', '__imul__', '__reversed__', '__setitem__', '__setslice__'` in your list of mutators? – kuzzooroo Jul 14 '14 at 00:52
  • No idea what _save looks like. I believe I based this off an answer which has since been deleted. You should probably include any function which could modify the list data, including most of the ones you mentioned (although I don't believe __reversed__ is a mutator). – mattbornski Jul 14 '14 at 16:48
  • Interesting point about `__reversed__`. I got that list of methods by doing `set(dir(list)) - set(dir(tuple))`. I guess creating the iterator requires mutating the list somehow. OTOH `reversed` (no underscores) works with tuples. I created [a new question](http://stackoverflow.com/q/24750146/2829764) to find out why. – kuzzooroo Jul 15 '14 at 05:02
3

Here is a way to avoid having to decorate every list method. It makes PersistentList a context manager, so you can use the

with PersistentList('key', db) as persistent:
    do_stuff()

syntax. Admittedly, this does not cause the _save method to be called after each list operation, only when you exit the with-block. But I think it gives you enough control to save when you want to save, especially since the __exit__ method is guaranteed to be executed no matter how you leave the with-block, including if it happens because of an exception.

You might be an advantage that _save is not called after every list operation. Imagine appending to the list 10,000 times. So many individual calls to db.set (a database?) could be quite time-consuming. I would be better, at least from a performance point of view, to make all the appends and the save once.


class PersistentList(list):
    def __init__(self, key, db):
        self.key = key
        self.extend(db.get(key, []))
    def _save(self):
        # db.set(self.key, self)
        print('saving {x}'.format(x = self))
    def __enter__(self):
        return self
    def __exit__(self,ext_type,exc_value,traceback):
        self._save()

db = {}
p = PersistentList('key', db)

with p:
    p.append(1)
    p.append(2)

with p:
    p.pop()
    p += [1,2,3]

# saving [1, 2]
# saving [1, 1, 2, 3]
unutbu
  • 842,883
  • 184
  • 1,785
  • 1,677
  • If you wanted to, you could get even more fancy by mixing the two techniques so that you keep a "dirty" flag indicating that it needs to be saved. You could even make it so that `PersistenList.__del__` would complain or try to save (if the system is exiting it might fail) if it were dirty. – Chris Morgan Feb 26 '12 at 13:38
  • @ChrisMorgan: I like your idea, but I think it would be hard to implement correctly. For example, if a user were to `append` then `pop`, a naive implementation (by decorating each list method) would set the `dirty` flag erroneously. To do better, you'd need to save a copy of the list in `__enter__` and in each list method test if the list is dirty. All those comparisons might make the performance slow. Since in general you'd want to save, maybe it is better to be a little wasteful and just save every time. – unutbu Feb 26 '12 at 14:29
  • I'd only put it in as a basic indicator that things have been changed. Sure, the changes may have been undone, but as you say, the cost of preventing an unnecessary write would be much too high. – Chris Morgan Feb 26 '12 at 14:32
  • @ChrisMorgan: Ah, I see. In that case, I think we can assume if the user uses `with p`, that p is going to change. So we can take for granted the dirty flag would be set. We could save a copy of the original list in `__enter__` and do one comparison in `__exit__` to see if `_save` needs to be called. This would avoid having to decorate all the list methods too. – unutbu Feb 26 '12 at 14:59
0

Here's an answer that's a lot like @unutbu's, but more general. It gives you a function you can call to sync your object to disk, and it works with other pickle-able classes besides list.

with pickle_wrap(os.path.expanduser("~/Desktop/simple_list"), list) as (lst, lst_sync):
    lst.append("spam")
    lst_sync()
    lst.append("ham")
    print(str(lst))
    # lst is synced one last time by __exit__

Here's the code that makes that possible:

import contextlib, pickle, os, warnings

def touch_new(filepath):
    "Will fail if file already exists, or if relevant directories don't already exist"
    # http://stackoverflow.com/a/1348073/2829764
    os.close(os.open(filepath, os.O_WRONLY | os.O_CREAT | os.O_EXCL))

@contextlib.contextmanager
def pickle_wrap(filepath, make_new, check_type=True):
    "Context manager that loads a file using pickle and then dumps it back out in __exit__"
    try:
        with open(filepath, "rb") as ifile:
            result = pickle.load(ifile)
        if check_type:
            new_instance = make_new()
            if new_instance.__class__ != result.__class__:
                # We don't even allow one class to be a subclass of the other
                raise TypeError(("Class {} of loaded file does not match class {} of "
                    + "value returned by make_new()")
                    .format(result.__class__, new_instance.__class__))
    except IOError:
        touch_new(filepath)
        result = make_new()
    try:
        hash(result)
    except TypeError:
        pass
    else:
        warnings.warn("You probably don't want to use pickle_wrap on a hashable (and therefore likely immutable) type")

    def sync():
        print("pickle_wrap syncing")
        with open(filepath, "wb") as ofile:
            pickle.dump(result, ofile)

    yield result, sync
    sync()
kuzzooroo
  • 6,788
  • 11
  • 46
  • 84
0

I know it's not pretty or clever, but I would just write the individual methods out...

class PersistentList(object):
   ...

   def append(self, o):
      self._autosave()
      self._list.append(o)

   ...etc...
Mark Hildreth
  • 42,023
  • 11
  • 120
  • 109