4

This may appear as a very basic question, but I couldn't find anything helpful on SO or elsewhere...

If you take built-in classes, such as int or list, there is no way to create additional class attributes for them (which is obviously a desirable behavior) :

>>> int.x = 0
Traceback (most recent call last):
  File "<pyshell#16>", line 1, in <module>
    int.x = 0
TypeError: can't set attributes of built-in/extension type 'int'

but if you create your own custom class, this restriction is not actived by default, so anybody may create additional class attributes in it

class foo(object):
    a = 1
    b = 2

>>> foo.c = 3
>>> print(foo.a, foo.b, foo.c)
1 2 3

I know that the __slots__ class attribute is one solution (among others) to forbid creation of unwanted instance attributes, but what is the process to forbid unwanted class attributes, as done in the built-in classes ?

sciroccorics
  • 2,357
  • 1
  • 8
  • 21
  • You'd could use a *metaclass* with `__slots__` – juanpa.arrivillaga Dec 16 '19 at 17:47
  • @juanpa: I've tried such an approach, but I couldn't see any difference with creating `__slots__` directly in the initial class. Which means that I could still create new class attributes. Could you post some code snippet to illustrate your idea ? – sciroccorics Dec 16 '19 at 18:32
  • 1
    Here's one way, using metaclasses: https://stackoverflow.com/questions/59376404/prevent-creating-new-attributes-for-class-or-module/59376655#59376655 – Patrick Haugh Dec 19 '19 at 15:30
  • @Patrick Haugh: Hey, this looks like a very clear step in the right direction ! Do you know if built-in classes use that exact process, or are there some alternatives ? I guess that the implementation is different after doing the following test: when performing simple derivation from a built-in class, this feature is lost for the derived class, but when derivating the `A` class from your example, the derivated class is still frozen... – sciroccorics Dec 19 '19 at 15:54
  • 3
    No, the built-in classes are implemented in C (for CPython) and setting attributes for them is controlled by this mechanism: https://stackoverflow.com/questions/50118488/how-are-built-in-types-protected-from-overwriting-assigning-to-their-methods – Patrick Haugh Dec 19 '19 at 16:05
  • @Patrick Haugh: Wooow : fast, direct and clear ! So this explains that the "frozen" behavior is not transmitted to derived classes. Python guru you are indeed ! – sciroccorics Dec 19 '19 at 16:15
  • @Patrick Haugh: It would be nice if you could write an answer that summarizes your comments, so as I can validate it and offer you the bounty... – sciroccorics Dec 23 '19 at 16:15

3 Answers3

2

I think you should play with metaclasses. It can define the behavior of your class instead of its instances.

The comment from Patrick Haugh refers to another SO answer with the following code snippet:

class FrozenMeta(type):
    def __new__(cls, name, bases, dct):
        inst = super().__new__(cls, name, bases, {"_FrozenMeta__frozen": False, **dct})
        inst.__frozen = True
        return inst
    def __setattr__(self, key, value):
        if self.__frozen and not hasattr(self, key):
            raise TypeError("I am frozen")
        super().__setattr__(key, value)

class A(metaclass=FrozenMeta):
    a = 1
    b = 2

A.a = 2
A.c = 1 # TypeError: I am frozen
AlexisBRENON
  • 2,921
  • 2
  • 18
  • 30
  • I also guess that the solution has something to do with metaclass, but couldn't find any snippet that shows how to get this behavior – sciroccorics Dec 19 '19 at 15:35
  • Thanks for pasting the link provided by Patrick. As I said in my comment above, this implementation seems to differ from the one used in built-in classes, because the frozen behavior is inherited in one case, but not inherited in the other case. – sciroccorics Dec 19 '19 at 16:01
  • @sciroccorics What do you mean by "inherited"? – AlexisBRENON Dec 19 '19 at 16:03
  • When defining `class B(A): pass` and trying `B.c = 5 --> I'm frozen`. But when defining `class C(int): pass` and trying `C.c = 5 --> OK`. In other words, the "frozen" behavior of `A` is transmitted to `B` but the "frozen" behavior of `int` is not transmitted to `C`. – sciroccorics Dec 19 '19 at 16:07
1

@AlexisBRENON's answer works but if you want to emulate the behavior of a built-in class, where subclasses are allowed to override attributes, you can set the __frozen attribute to True only when the bases argument is empty:

class FrozenMeta(type):
    def __new__(cls, name, bases, dct):
        inst = super().__new__(cls, name, bases, {"_FrozenMeta__frozen": False, **dct})
        inst.__frozen = not bases
        return inst
    def __setattr__(self, key, value):
        if self.__frozen and not hasattr(self, key):
            raise TypeError("I am frozen")
        super().__setattr__(key, value)

class A(metaclass=FrozenMeta):
    a = 1
    b = 2

class B(A):
    pass

B.a = 2
B.c = 1 # this is OK
A.c = 1 # TypeError: I am frozen
blhsing
  • 91,368
  • 6
  • 71
  • 106
0

Whenever you see built-in/extension type you are dealing with an object that was not created in Python. The built-in types of CPython were created with C, for example, and so the extra behavior of assigning new attributes was simply not written in.

You see similar behavior with __slots__:

>>> class Huh:
...    __slots__ = ('a', 'b')

>>> class Hah(Huh):
...    pass

>>> Huh().c = 5   # traceback
>>> Hah().c = 5   # works

As far as making Python classes immutable, or at least unable to have new attributes defined, a metaclass is the route to go -- although anything written in pure Python will be modifiable, it's just a matter of how much effort it will take:

>>> class A(metaclass=FrozenMeta):
...     a = 1
...     b = 2

>>> type.__setattr__(A, 'c', 9)
>>> A.c
9

A more complete metaclass:

class Locked(type):
    "support various levels of immutability"
    #
    def __new__(metacls, cls_name, bases, clsdict, create=False, change=False, delete=False):
        cls = super().__new__(metacls, cls_name, bases, {
                "_Locked__create": True,
                "_Locked__change": True,
                "_Locked__delete": True,
                **clsdict,
                })
        cls.__create = create
        cls.__change = change
        cls.__delete = delete
        return cls
    #
    def __setattr__(cls, name, value):
        if hasattr(cls, name):
            if cls.__change:
                super().__setattr__(name, value)
            else:
                raise TypeError('%s: cannot modify %r' % (cls.__name__, name))
        elif cls.__create:
            super().__setattr__(name, value)
        else:
            raise TypeError('%s: cannot create %r' % (cls.__name__, name))
    #
    def __delattr__(cls, name):
        if not hasattr(cls, name):
            raise AttributeError('%s: %r does not exist' % (cls.__name__, name))
        if not cls.__delete or name in (
                '_Locked__create', '_Locked__change', '_Locked_delete',
                ):
            raise TypeError('%s: cannot delete %r' % (cls.__name__, name))
        super().__delattr__(name)

and in use:

>>> class Changable(metaclass=Locked, change=True):
...     a = 1
...     b = 2
... 
>>> Changable.a = 9
>>> Changable.c = 7
Traceback (most recent call last):
    ...
TypeError: Changable: cannot create 'c'
>>> del Changable.b
Traceback (most recent call last):
    ...
TypeError: Changable: cannot delete 'b'
Ethan Furman
  • 63,992
  • 20
  • 159
  • 237
  • This isn't quite accurate - you can't set new attributes on instances of a typical built-in class because the data structures and hooks for that just weren't written in, but you can't set new attributes on *the class objects themselves* because there's a specific check in `type.__setattr__` for built-in classes. – user2357112 Dec 24 '19 at 23:44
  • (Specifically, [this check](https://github.com/python/cpython/blob/v3.8.1/Objects/typeobject.c#L3280).) – user2357112 Dec 24 '19 at 23:47
  • @user2357112supportsMonica: If that check were removed, would we then be able to add attributes to built-in classes, or would Python crash? – Ethan Furman Dec 25 '19 at 00:20
  • There'd be problems if you tried to mess with "core" attributes like `__new__` or `__setattr__`, but I think adding new attributes would work fine. (I think you might have problems with subinterpreters or with calling `Py_Initialize` after `Py_Finalize` or a few other unusual cases.) – user2357112 Dec 25 '19 at 00:23