Python: ContextManager for entity mutations?

41 Views Asked by At

I'd like to establish a code pattern that allows me to do something analogous to the following:

# Foo has getter-only properties
foo: Foo = FooRepo.get(id="foo_id")
assert foo.bar == "original_bar"

# MutableFoo is a window onto the Foo, and when it __exit__s, it persists to the repo
with foo.mutable() as mutable_foo:
    mutable_foo.bar = "new_bar"

# We've updated the Foo, as well as whatever persistent version the FooRepo owns
assert foo.bar == "new_bar"

I'm not wedded to the exact code pattern. What I like about it:

  • We can pass the Foo around to lots of areas of code, and as long as mutable() isn't called, we can treat it as immutable and ignore the idea of persistence.
  • We have the ability to work transactionality into that ContextManager in a variety of ways. Outside of the ContextManager we get to treat the object as a snapshot, which will be more common and is less hairy.
  • Callers can largely ignore persistence

Challenges I see:

  • Need an elegant way of preventing creating the mutable version outside of a with block.
  • Ditto with whatever interface into Foo enables MutableFoo to do its mutation. (Can you tell I'm used to Java? Lacking inner classes with access to private members has me scratching my head a bit)
  • Need an elegant way of doing error checking. Since the persistence happens in response to exiting the context, there's potential for exceptions and those will need to be caught and handled responsibly.

Have folks built frameworks of this kind in Python? What solutions do you like?

2

There are 2 best solutions below

0
blhsing On

You can create an "immutable" proxy object that disables setter and deleter methods by making them raise an exception. Implement a context manager that returns the original mutable object upon entrance to allow mutation within the context:

def protected(obj):
    class Protected(type(obj)):
        def __getattr__(self, name):
            return getattr(obj, name)

        def __setattr__(self, name, value):
            raise AttributeError("can't set attribute")

        def __delattr__(self, name):
            raise AttributeError("can't delete attribute")

        def __enter__(self):
            return obj

        def __exit__(self, exc_type, exc_val, exc_tb):
            pass

    return object.__new__(Protected)

so that the following code passes the assertion:

class Foo:
    def __init__(self, bar):
        self.bar = bar

foo = protected(Foo('original_bar'))

with foo as mutable_foo:
    mutable_foo.bar = 'new_bar'

assert foo.bar == 'new_bar'

while the following code would raise an AttributeError:

foo.bar = 'new_bar'
2
FerricDonkey On

I'd be tempted to avoid a wrapper class unless necessary. Seems like you could have it be a lot simpler with just a boolean

class Foo:
    def __init__(self, bar):
        self._bar = bar
        self._mutable = False

    @property
    def bar(self):
        return self._bar

    @bar.setter
    def bar(self, value):
        if not self._mutable:
            raise RuntimeError('No touchy')
        self._bar = value

    @contextlib.contextmanager
    def mutable(self):
        self._mutable = True
        try:
            yield
        finally:
            self._mutable = False

foo = Foo('sup')
foo.bar = 'bro' # RuntimeError

with foo.mutable():
    foo.bar = 'bro'

Since everything about this is in place modification of the Foo object, no special effort is necessary to get changes to be reflected in your FooRepo.