Search code examples
pythonexceptionsubclassimmutability

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?


Solution

  • 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.