简体   繁体   中英

Using Python Metaclasses to Limit the Number of Attributes

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.

Question

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?

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!

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

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.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM