Reputation:
I trying to define a Python metaclass to limit the number of attributes that a class may contain, starting at creation time. I am constrained to use Python 3.7 due to system limitations
I managed to do it without a metaclass:
class MyClass:
def __init__(self):
self.param1 = 7
self.param2 = 8
def __setattr__(self, key, value):
if key not in self.__dict__ or len(self.__dict__.keys()) < 2:
self.__dict__[key] = value
raise ValueError("Excess of parameters.")
test = MyClass()
test.param1 = 3
test.param2 = 5
print(test.param1) # Prints 3
print(test.param2) # Prints 5
test.param3 = 9 # RAISES AN ERROR, AS IT SHOULD!
The problem that I am having is that I want to do this through a metaclass. Sure, I bet that there can be better options, but I find that, in this case, the best option is to do it through a metaclass (?)
I have read several posts and this is the closest Q&A I have found to solving the problem.
Is there a way to limit the number of attributes of a class to a specific number, at creation time, using metaclasses?
Maybe use the dunder method __settattr__
to use the super class constructor?
Upvotes: 0
Views: 341
Reputation: 110766
Since your needs are actually "It is more a policy of my co-workers not being able to meddle with the number of attributes of memory sensitive classes, where adding more attributes at run time (or even develop time) may place unneeded stress on the system (kind of an API), so I want them to have the flexibility to, at the very least, choose the names of the variables but limit them to a certain number"
You actually need to use __slots__
. You can use the metaclass to inspect some parameter of the class at creation time - possibly the parameters of the __init__
method, and create the names from there.
The metaclass can also inject a __setattr__
method with whatever limitations you want, of course - but without the use of __slots__
, each instance will have a full __dict__
for the attributes, which will (1) use memory, and (2) could be used with a workaround to store extra attributes.
Since knowing the attribute names to set them as slots would be an extra task, and there should be some definition (for example, we could use the first two parameter names to __init__
), I think it is easier to inject generic slot names, and use __setattr__
to map arbitrarily named attributes to the slots in the order they are assigned. The mapping itself can be recorded on the class object by the setattr, and once the first instance is fully initiated, no other attributes can be added in other instances.
Still - I am providing an example for this for the fun of it - from the point of view of "real world" usage, your concerns are not useful at all: if attributes are needed for a certain business logic, they are needed - trying to artificially limit them won't help. (they could just add a dict as one of the attributes. If you bar that, then use another container. Continue to try limting the code and it could degrade to the point of using raw lists, dictionaries and stand alone functions instead of being object oriented, and so on.
def limit_setattr(self, name, value):
cls = type(self)
# Acessing the attribute through "cls.__dict__"
# prevent it from being fetched from a superclass.
attrs = cls.__dict__["attr_names"]
# Allow the use of properties, without then counting to the maximum attributes:
if hasattr(cls, name) and hasattr(getattr(cls, name), "__set__"): # nice place to use the walrus - := - op, but that is supported from Python 3.8 on only
return object.__setattr__(self, name, value)
try:
index = attrs.index(name)
except ValueError:
index = None
if index is None and len(attrs) < cls.maxattrs:
index = len(attrs)
attrs += (name,)
cls.attr_names = attrs
elif index is None:
raise AttributeError(f"Class {cls.__name__} can't hold attribute {name}: max attributes exceeded!")
mapped_name = f"attr_{index}"
object.__setattr__(self, mapped_name, value)
def limit_getattr(self, name):
cls = type(self)
attrs = cls.__dict__["attr_names"]
try:
index = attrs.index(name)
except ValueError:
raise AttributeError(f"{name} not an attribute of {cls.__name__} instances" )
mapped_name = f"attr_{index}"
return object.__getattribute__(self, mapped_name)
class LimitAttrs(type):
def __new__(mcls, name, bases, namespace, maxattrs=2):
for base in bases:
if "__dict__" in dir(base):
raise TypeError(f"The base class {base} of {name} does not have slots and won't work for a slotted child class.")
namespace["__setattr__"] = limit_setattr
namespace["__getattribute__"] = limit_getattr
namespace["maxattrs"] = maxattrs
namespace["attr_names"] = tuple()
namespace["__slots__"] = tuple(f"attr_{i}" for i in range(maxattrs))
return super().__new__(mcls, name, bases, namespace)
And here is the code above being used in an interactive session:
In [81]: class A(metaclass=LimitAttrs):
...: pass
...:
In [82]: a = A()
In [83]: a.aa = 1
In [84]: a.bb = 2
In [85]: a.cc = 3
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
[...]
AttributeError: Class A can't hold attribute cc: max attributes exceeded!
In [86]: b = A()
In [87]: b.aa
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Input In [87], in <cell line: 1>()
----> 1 b.aa
[...]
AttributeError: ...
In [88]: b.aa = 3
In [89]: b.cc = 4
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Input In [89], in <cell line: 1>()
[...]
AttributeError: Class A can't hold attribute cc: max attributes exceeded!
Upvotes: 1
Reputation: 1047
If you really want to do it using metaclasses, you can, but I would recommend against it, because a class decorator suffices, and you can chain class decorators in future, whereas you cannot do so with metaclasses. Using a class decorator also frees up the possibility of using a metaclass in future.
With that in mind, here are the solutions I've cooked up. All code is tested and confirmed to function as intended on Python 3.7.3.
# A solution using a class decorator (recommended solution)
def limited_attributes(number_attributes):
def __setattr__(self,key,value):
if key not in self.__dict__ and len(self.__dict__.keys())>=number_attributes:
raise ValueError('Too many attributes')
self.__dict__[key]=value
def decorator(cls):
cls.__setattr__=__setattr__
return cls
return decorator
@limited_attributes(2)
class Foo:
def __init__(self):
self.param1=4
self.param2=5
test = Foo()
test.param1=3 # no error
test.param2='bar' # no error
test.param3='baz' # raises ValueError
# A solution using a function as a metaclass
def limited_attributes(number_attributes):
def __setattr__(self,key,value):
if key not in self.__dict__ and len(self.__dict__.keys())>=number_attributes:
raise ValueError('Too many attributes')
self.__dict__[key]=value
def metaclass(name,bases,attrs):
cls=type(name,bases,attrs)
cls.__setattr__=__setattr__
return cls
return metaclass
class Foo(metaclass=limited_attributes(2)):
def __init__(self):
self.param1=4
self.param2=5
test = Foo()
test.param1=3 # no error
test.param2='bar' # no error
test.param3='baz' # raises ValueError
Upvotes: 0
Reputation: 455
Your existing code has some problems, indeed it should be written as:
class MyClass:
def __init__(self):
self.param1 = 7
self.param2 = 8
def __setattr__(self, key, value):
n_attr_max = 2
if key in self.__dict__ or len(self.__dict__.keys())+1 <= n_attr_max:
self.__dict__[key] = value
else:
raise ValueError("Excess of parameters.")
test = MyClass()
test.param1 = 3
test.param2 = 5
print(test.param1) # Prints 3
print(test.param2) # Prints 5
test.param3 = 9 # Raises exception here.
To override metaclass dunder methods you can pass new method through the dictionary. The following will do what you want:
class MyMetaClass(type):
def __new__(cls, name, bases, dic):
#
def settattr_modified(self, key, value):
n_attr_max = 2
if key in self.__dict__ or len(self.__dict__.keys())+1 <= n_attr_max:
self.__dict__[key] = value
else:
raise ValueError("Excess of parameters.")
#
dic["__setattr__"] = settattr_modified
return super().__new__(cls, name, bases, dic)
class MyClass(metaclass= MyMetaClass):
def __init__(self):
self.param1 = 7
self.param2 = 8
test = MyClass()
test.param1 = 3
test.param2 = 5
print(test.param1) # Prints 3
print(test.param2) # Prints 5
test.param3 = 9 # Raises exception here.
Upvotes: -1