Reputation: 33
I'm to do a metaclass that should add a prefix 'custom_' to all the properties and methods of my class CustomClass except for magic methods. Here is the code:
class CustomClass(metaclass=CustomMeta):
x = 50
def __init__(self, val=99):
self.val = val
def line(self):
return 100
def __str__(self):
return "Custom_by_metaclass"
I wrote a following metaclass that works fine for this case, but I faced a problem that I can't change instance's argument self.val to self.custom_val so that my assertions fails:
inst = CustomClass()
assert inst.custom_val == 99 #Fails
Anyway, others assertions go fine:
assert CustomClass.custom_x == 50
inst = CustomClass()
assert inst.custom_x == 50
assert inst.custom_line() == 100
assert str(inst) == "Custom_by_metaclass"
Here is my metaclass:
class CustomMeta(type):
def __new__(cls, name_class, base, attrs):
custom_attrs = {}
for key, value in attrs.items():
if not key.startswith('__') and not key.endswith('__'):
custom_attrs['custom_' + key] = attrs[key]
else:
custom_attrs[key] = attrs[key]
return type.__new__(cls, name_class, base, custom_attrs)
What should I do to manage my problem?
PS. One of the solutions came is write setattr to my CustomClass
def __setattr__(cls, name, value):
if not (name.startswith('__') and name.endswith('__')):
name = f'custom_{name}'
super().__setattr__(name, value)
It works and I can add dynamically new properties for my object and get custom_ tag. But is there a way to resolve it through Metaclass? The task is bound to create and metaclass, so I doubt my solution is OK according to the requirements.
Upvotes: 1
Views: 155
Reputation: 110591
What a metaclass can easily modify are class attributes and methods - if you customize its __new__
method: It can't "see" what attributes will be created by __init__
or other methods.
In Python 3.13 onwards - there is the new __static_attributes__
tuple, which automatically gives you the names of any instance attribute created in any of the methods, including __init__
- this is kind of "deep magic" which was not possible before without introspecting the AST of all methods and would be generally out of reach for "simple" metaclasses.
Anyway, you could use that to learn about the instance attributes, if you were using Python 3.13 alone, but would still need a decorator-behavior around the class __init__
to actually change the attributes to the new names, after the return of __init__
.
That can be done by customizing the metaclass' __call__
method instead of __new__
. Even without introspecting __static_attributes__
we can pick whatever is in the instance dictionary, after __init__
had run, and create new entries with the custom_
prefix.
This approach, however, can't modify any attribute access in any of the other methods in a class - so if the line
method in your example would have to read or write custom_val
it would still access val
- and you'd have 2 separate values in the instance.
There are two ways to fix that: have the metaclass have a custom __getattribute__
, __setattr__
and __delattr__
methods which could make both "custom_" prefixed and non-prefixed attributes work on the same entries in the class' __dict__
- or, more complicated: modify the code objects of created methods so that they refer to the new names instead.
Since there certainly would be inconsistencies if the new attributes where just set after the instance's __init__
and the attributes as used inside the methods, treating them as aliases in __getattritbute__
(and family) would be preferable.
So - we do leave all attributes and method names as are - but show to the "outside world" attributes with the prefix. Note that this won't avoid name clashes of the attributes in an inheritance hierarchy. That is, if you are using multiple inheritance and having another class inherit from CustomClass
and another class that sets .val
just aliasing them won't work. To avoid name clashes with multiple (or even single inheritance, but with similar named attributes used on more than one level on the chain), Python has an "auto prefix" mechanism, which only requires your attributes and names to start with __
(double underscore. But just starting, not ending with double underscores). In that case, NONE of this is needed, just:
class CustomClass(metaclass=CustomMeta):
x = 50
def __init__(self, val=99):
self.__val = val
def __line(self):
print(self.__val)
return 100
def __str__(self):
return "Custom_by_metaclass"
This will make __val
be internally renamed to _CustomClass__val
and __line
to _CustomClass__line
, in a way that if OtherClass
also defines any of these, there is no name clash. Usually it is the reason for one wanting to do what you are asking, and the language does that when compiling the class body itself (and the methods inside it) - it is not feasible via metaclass (so, this prefix can't be changed).
For the __getattribute__
, __setattr__
, etc..approach, that can work like this:
import sys
def _check_if_inner_caller(self):
# Check if the code trying to access one of the attributes
# is itself a method of our customized class, or is outside code
# note: we verify so by checking the local "self" variable: it
# if any method won't be using the conventional "self", this has to be changed.
depth = 2
caller_frame = sys._getframe(depth)
# checks the "self" local variable in the grandeparent calling code.
# (parent callser should be the custom getattr and setattr)
if caller_frame.f_locals.get("self", None) is not self:
return False
def custom_getattr(self, name):
cls = type(self)
prefix = type(cls).prefix_registry[cls]
if not name.startswith(prefix) and not (name.startswith("__") and name.endswith("__")): # otherwise, nothing to be done -
if not _check_if_inner_caller(self): # attribute is being accessed from a method in the class: add the prefix!
name = prefix + name
return object.__getattribute__(self, name)
def custom_setattr(self, name, value):
cls = type(self)
prefix = type(cls).prefix_registry[cls]
if not name.startswith(prefix) and not (name.startswith("__") and name.endswith("__")): # otherwise, nothing to be done -
if not _check_if_inner_caller(self): # attribute is being accessed from a method in the class: add the prefix!
name = prefix + name
# do not use "super" to avoid entering twice in this custom setattr
return object.__setattr__(self, name, value)
# if needed, implement "__delattr__" in the same way.
class CustomMeta(type):
prefix_registry = {}
def __new__(mcls, name, bases, namespace, prefix="custom_"):
# rename methods, so they are not aliased further, and always show up with the prefix.
for name in list(namespace):
if not (name.startswith("__") and name.endswith("__")) and not name.startswith(prefix):
namespace[prefix + name] = namespace[name]
del namespace[name]
# if needed, set __setattr__ and companions:
namespace["__setattr__"] = custom_setattr
namespace["__getattribute__"] = custom_getattr
# create class with the new namespace: methods are already renamed:
new_cls = super().__new__(mcls, name, bases, namespace)
mcls.prefix_registry[new_cls] = prefix
return new_cls
class CustomClass(metaclass=CustomMeta):
x = 50
def __init__(self, val=99):
self.val = val
def line(self):
print(self.val)
return 100
def __str__(self):
return "Custom_by_metaclass"
And pasting this on a REPL and checking what we get:
>>> dir(CustomClass)
['__class__', ..., '__weakref__', 'custom_line', 'custom_x']
>>> inst = CustomClass()
>>>
>>> inst.custom_val
99
>>>
>>> inst.line()
99
100
>>> inst.__dict__
{'custom_val': 99}
>>>
Upvotes: 1
Reputation: 11237
you need to handle attr created at init level
class CustomMeta(type):
def __new__(cls, name_class, base, attrs):
custom_attrs = {}
for key, value in attrs.items():
if not key.startswith('__') and not key.endswith('__'):
custom_attrs['custom_' + key] = attrs[key]
else:
custom_attrs[key] = attrs[key]
# Modify __init__ to set custom attribute names
if '__init__' in attrs:
original_init = attrs['__init__']
def custom_init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
for key, value in vars(self).items():
if not key.startswith('custom_') and not key.startswith('__'):
setattr(self, f'custom_{key}', value)
delattr(self, key)
custom_init.__name__ = '__init__'
custom_attrs['__init__'] = custom_init
return type.__new__(cls, name_class, base, custom_attrs)
class CustomClass(metaclass=CustomMeta):
x = 50
def __init__(self, val=99):
self.val = val
def line(self):
return 100
def __str__(self):
return "Custom_by_metaclass"
assert CustomClass.custom_x == 50
inst = CustomClass()
assert inst.custom_x == 50
assert inst.custom_line() == 100
assert str(inst) == "Custom_by_metaclass"
inst = CustomClass()
assert inst.custom_val == 99
Upvotes: 1