Skip to content

A way to hook into frozen class setattr, delattr functions? #378

@joeblackwaslike

Description

@joeblackwaslike

Hello,

I'm using attrs with subclasses of persistent.Persistent, which allows for persisting python objects in ZODB (Zope DB http://www.zodb.org). This is a pretty popular library, which inherently would benefit from being able to persist somewhat immutable objects, think LineItems in an Order for a Shop.

Quick test, failing

import attr
from persistent import Persistent

@attr.s(frozen=True)
class TestAttrs(Persistent):
    val: str = attr.ib(default='TestDC')

import ZODB
from ZODB.MappingStorage import MappingStorage

db = ZODB.DB(MappingStorage())
conn = db.open()
root = conn.root()

root['test'] = TestAttrs('test value')

import transaction

transaction.commit()

Which currently results in the following:

/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/attr/_make.py in _frozen_setattrs(self, name, value)
    386     Attached to frozen classes as __setattr__.
    387     """
--> 388     raise FrozenInstanceError()
    389
    390

FrozenInstanceError:

After some research, I was able to determine that for full functionality, ZODB persistent objects need the ability to write to attributes prefixed with _p_ representing persistent state in the db, and _v_ for volatile attributes that won't be persisted, used for instance with a caching decorator, etc.

With that information, I wrote a decorator that monkeypatches attr._make._frozen_setattrs and attr._make._frozen_delattrs to allow for modifying attributes beginning with _p_ and _v_.

import sys
import attr._make
from attr.exceptions import FrozenInstanceError

_WHITELISTED_PREFIXES = ('_p_', '_v_')

def frozen_wrapper(func, override):
    def wrapper(self, *args):
        name = args[0]
        if name.startswith(_WHITELISTED_PREFIXES):
            sup = super(type(self), self)
            return getattr(sup, override)(*args)
        else:
            return func(self, *args)
    return wrapper

sys.modules['attr._make']._frozen_setattrs = frozen_wrapper(
    sys.modules['attr._make']._frozen_setattrs, '__setattr__')
sys.modules['attr._make']._frozen_delattrs = frozen_wrapper(
    sys.modules['attr._make']._frozen_delattrs, '__delattr__')

The following test now works

import attr
from persistent import Persistent

@attr.s(frozen=True)
class TestAttrs(Persistent):
    val: str = attr.ib(default='TestDC')

import ZODB
from ZODB.MappingStorage import MappingStorage

db = ZODB.DB(MappingStorage())
conn = db.open()
root = conn.root()

root['test'] = TestAttrs('test value')

import transaction

transaction.commit()

So obviously this is pretty hacky and I would love if there was an officially supported, generic way of hooking into the frozen class's __setattr__ and __delattr__ methods.

Although I think a module level tuple of whitelisted/blacklisted prefixes would work well in this case, I was thinking something similar to this will likely be requested in the future again, to work with another package, so providing a more flexible hook might be best.

Perhaps the ability to provide a decorator, or replacement functions would provide as much functionality as anyone would need down the line. I can volunteer my time to help implement this, but would like to open this issue to see what other people think about all of this. Such an interface could be generic and allow for added hooks, configuration in the future.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions