Keplerto
Keplerto

Reputation: 113

Subclass that throws custom error if modified

What's the best way in python to create a subclass of an existing class, in such a way that a custom error is raised whenever you attempt to modify the object?

The code below shows what I want.

class ImmutableModifyError(Exception):
    pass

class ImmutableList(list):
    def __init__(self, err = "", *argv):
        self.err = err
        super().__init__(*argv)
    
    def append(self, *argv):
        raise ImmutableModifyError(self.err)
    
    def extend(self, *argv):
        raise ImmutableModifyError(self.err)
        
    def clear(self, *argv):
        raise ImmutableModifyError(self.err)
    
    def insert(self, *argv):
        raise ImmutableModifyError(self.err)
    
    def pop(self, *argv):
        raise ImmutableModifyError(self.err)
        
    def remove(self, *argv):
        raise ImmutableModifyError(self.err)
        
    def sort(self, *argv):
        raise ImmutableModifyError(self.err)
        
    def reverse(self, *argv):
        raise ImmutableModifyError(self.err)

If I use other immutable types, an AttributeError is thrown instead of the custom error whose message is created along the object. As you can see, this code is too repetitive and would be error-prone if the class changed. I have not taken into account hidden methods and operators. Is there a better way to achieve this?

Upvotes: 1

Views: 38

Answers (1)

steve-ed
steve-ed

Reputation: 197

There are plenty of more scalable and less error-prone way to achieve this is to dynamically block all mutating methods.

Using Setattr to indentify all mutating methods dynamically and override them [Best Method to implement - Pycon 2017]

class ImmutableModifyError(Exception):
    pass

class ImmutableList(list):
    def __init__(self, *args, err="Immutable list cannot be modified"):
        self.err = err
        super().__init__(*args)

    def __raise_error(self, *args, **kwargs):
        raise ImmutableModifyError(self.err)

_mutating_methods = {
    "append", "extend", "clear", "insert", "pop",
    "remove", "sort", "reverse", "__setitem__", "__delitem__",
    "__iadd__", "__imul__"
}


for met in _mutating_methods:
   setattr(ImmutableList, method, ImmutableList.__raise_error) ## Don't use map here 

Use metaclasses

class ImmutableModifyError(Exception):
    pass

class ImmutableMeta(type):
    def __new__(cls, name, bases, namespace):
        def raise_error(self, *args, **kwargs):
            raise ImmutableModifyError(f"Cannot modify {self.__class__.__name__} object")

        
        mutating_methods = {
            attr for base in bases
            for attr in dir(base)
            if callable(getattr(base, attr, None)) and
               attr in {"__setitem__", "__delitem__", "__iadd__", "__imul__",
                        "append", "extend", "clear", "insert", "pop",
                        "remove", "sort", "reverse"}
        }

        for method in mutating_methods:
            namespace[method] = raise_error

        return super().__new__(cls, name, bases, namespace)

class ImmutableList(list, metaclass=ImmutableMeta):
    pass

One of benefits you get that if you want to use a dict instead of list just one line of code is needed

class ImmutableDict(dict, metaclass=ImmutableMeta):
    pass

Use getitem [most least popular not safe ]

  • This will make the list completely unusable since even reading values will raise an error.
  • Any iteration (for x in lst) will also fail.
  • This is extreme immutability, where not only modification but even access is prevented.
class ImmutableModifyError(Exception):
    pass

class ImmutableList(list):
    def __init__(self, *args, err="Immutable list cannot be modified"):
        self.err = err
        super().__init__(*args)
    
    def __getitem__(self, index):
        raise ImmutableModifyError(self.err)

One example why it is not good to implement is as it breaks Read-Only Operations and if you override getitem, you won't be able to retrieve values anymore:

lst = ImmutableList([1, 2, 3])
print(lst[0])  # Should return 1, but would raise an error instead.

Upvotes: 2

Related Questions