4

Working in Python, how can a method owned by a metaclass (metamethod) be retrieved through a class that it instantiated? In the following scenario, it's easy – just use getattr or dot notation:

* all examples use the version safe with_metaclass

class A(type):
        class A(type):
    """a metaclass"""

    def method(cls):
        m = "this is a metamethod of '%s'"
        print(m % cls.__name__)

class B(with_metaclass(A, object)):
    """a class"""
    pass

B.method()
# prints: "this is a metamethod of 'B'"

However this is peculiar since 'method' can't be found anywhere in dir(B). As a result of this fact, overriding such a method dynamically becomes difficult, because the metaclass isn't in the lookup chain for super:

class A(type):
    """a metaclass"""

    def method(cls):
        m = "this is a metamethod of '%s'"
        print(m % cls.__name__)

class B(with_metaclass(A, object)):
    """a class"""

    @classmethod
    def method(cls):
        super(B, cls).method()

B.method()

# raises: "AttributeError: 'super' object has no attribute 'method'"

So, what's the easiest way to properly override a metamethod? I've come up my own answer to this question, but look forward to any suggestions or alternate answers. Thanks in advance for your responses.

rmorshea
  • 832
  • 1
  • 7
  • 25

4 Answers4

8

As you put it, and discovered in practice, A.method is not on B's lookup chain - The relation of classes and metaclasses is not one of inheritance - it is one of 'instances' as a class is an instance of the metaclass.

Python is a fine language in which it behaves in expected ways, with very few surprises - and it is the same in this circumstances: If we were dealing with 'ordinary' objects, your situation would be the same as having an instance B of the class A. And B.method would be present in B.__dict__ - a "super" call placed on a method defined for such an instance could never get to A.method - it would actually yield an error. As B is a class object, super inside a method in B's __dict__ is meaningful - but it will search on B's __mro__ chain of classes (in this case, (object,)) - and this is what you hit.

This situation should not happen often, and I don't even think it should happen at all; semantically it is very hard to exist any sense in a method that would be meaningful both as a metaclass method and as a method of the class itself. Moreover, if method is not redefined in B note it won't even be visible (nor callable) from B's instances.

Maybe your design should:

a. have a baseclass Base, using your metaclass A, that defines method instead of having it defined in A - and then define class B(Base): instead

b. Or have the metaclass A rather inject method in each class it creates, with code for that in it's __init__ or __new__ method - along:

def method(cls):
    m = "this is an injected method of '%s'"
    print(m % cls.__name__)
    
class A(type):
    def __init__(cls, name, bases, dct):
        setattr(cls, 'method', classmethod(method))

This would be my preferred approach - but it does not allow one to override this method in a class that uses this metaclass - without some extra logic in there, the approach above would rather override any such method explicit in the body. The simpler thing is to have a base class Base as above, or to inject a method with a different name, like base_method on the final class, and hardcode calls to it in any overriding method:

class B(metaclass=A):
   @classmethod
   def method(cls):
        cls.base_method()
        ...

(Use an extra if on the metaclass's __init__ so that the default method is aliased to base_method )

What you literally asked for begins here

Now, if you really has a use case for methods in the metaclass to be called from the class, the "one and obvious" way is to simply hardcode the call, as it was done before the existence of super

You can do either:

class B(metaclass=A):
   @classmethod
   def method(cls):
        A.method(cls)
        ...

Which is less dynamic, but less magic and more readable - or:

class B(metaclass=A):
   @classmethod
   def method(cls):
        cls.__class__.method(cls)
        ...

Which is more dynamic (the __class__ attribute works for cls just like it would work in the case B was just some instance of A like my example in the second paragraph: B.__class__ is A)

In both cases, you can guard yourself against calling a non existing method in the metaclass with a simple if hasattr(cls.__class__, "method"): ...

jsbueno
  • 99,910
  • 10
  • 151
  • 209
  • This was more of an intellectual pursuit than a practical one. Essentially I have a use case where I call the method `setup_class` in the `__init__` of a metaclass. There's a base functionality for `setup_class`, so it wouldn't completely unintuitive to have that defined on the metaclass, though after running into this problem I realized it could just as easily be defined on a base class instead. I'll post my attempt at a solution shortly. Thanks for the clear response – rmorshea May 10 '16 at 05:56
  • 1
    If you have a setup_class that makes sense on the metaclass, and things that can complement that makes sense on the class, you could document a naming standard, or a decorator, to have methods called on the class itself by the execution of setup_class itself. (however, there is a trick - a classmethod called before the metaclass `__init__` returns, can't use `super` - see http://stackoverflow.com/questions/13126727/how-is-super-in-python-3-implemented/28605694#28605694 – jsbueno May 10 '16 at 14:45
1

Overriding method is not a problem; using super properly is. method is not inherited from any base class, and you can't guarantee that every class in the MRO uses the same metaclass to provide the method, so you run into the same issue as any method without a root class to handle it without calling super.

Just because a metaclass can inject a class method doesn't mean you can use that method for cooperative inheritance.

chepner
  • 497,756
  • 71
  • 530
  • 681
0

This is my first attempt at a solution:

def metamethod(cls, name):
    """Get a method owned by a metaclass

    The metamethod is retrieved from the first metaclass that is found
    to have instantiated the given class, or one of its parent classes
    provided the method doesn't exist in the instantiated lookup chain.

    Parameters
    ----------
    cls: class
        The class whose mro will be searched for the metamethod
    """
    for c in cls.mro()[1:]:
        if name not in c.__dict__ and hasattr(c, name):
            found = getattr(c, name)
            if isinstance(found, types.MethodType):
                new = classmethod(found.__func__)
                return new.__get__(None, cls)
    else:
        m = "No metaclass attribute '%s' found"
        raise AttributeError(m % name)

However, this comes with a catch – it doesn't work with six.with_metaclass. The workaround is to recreate it, and use it to make a new base in the mro of classes that inherits from it. Because this base isn't temporary, the implementation is actually quite simple:

def with_metaclass(meta, *bases):
    """Create a base class with a metaclass."""
    return meta("BaseWithMeta", bases, {})

Low and behold, this approach does solve the case I presented:

class A(type):
    """a metaclass"""

    def method(cls):
        m = "this is a metamethod of '%s'"
        print(m % cls.__name__)

class B(six.with_metaclass(A, object)):
    """a class"""

    @classmethod
    def method(cls):
        metamethod(cls, 'method')()

B.method()
# prints: "this is a metamethod of 'B'"

However, it will fail, and it fails in the same way, and for the same reasons that this does:

class A(type):
    """a metaclass"""

    def method(cls):
        m = "this is a metamethod of '%s'"
        print(m % cls.__name__)

class C(object):
    """a class"""

    @classmethod
    def method(cls):
        m = "this is a classmethod of '%s'"
        print(m % cls.__name__)

class B(six.with_metaclass(A, C)):
    """a class"""
    pass

B.method()
# prints: "this is a classmethod of 'B'"
rmorshea
  • 832
  • 1
  • 7
  • 25
  • Your reimplementation of `with_metaclass` does not transfer `method` from the metaclass to the base class it returns - it does essentially the same as pure Python (or original with_metaclass), in which `method` exists in `B.__class__`, but not in B. Enhance it to copy the desired meethods from the methaclass to the dynamic base class, before returning it (thus making `method` to exist in the normal mro lookup). But them, you can do this method injection on the metaclass itself (that is what metaclasses are for - injecting and changing stuff at class creation time, after all) – jsbueno May 10 '16 at 14:39
  • The method injection makes sense, but for some reason I'd never investigated the `__class__` attribute of a class. I was under the impression that you lost access to the metaclass. That being the case, you can just search `B.__class__` for `method` rather than iterating over the mro like I do now. I guess a better way to word my question would have been to ask how you could retrieve the metaclass of a class. – rmorshea May 10 '16 at 18:53
0

I think this most closely addresses the initial problem of retrieving the metamethod.

def metamethod(cls, name):
    """Get an unbound method owned by a metaclass

    The method is retrieved from the instantiating metaclass.

    Parameters
    ----------
    cls: class
        The class whose instantiating metaclass will be searched.
    """
    if hasattr(cls.__class__, name):
        return getattr(cls.__class__, name)

However, this doesn't really address the issue of properly overriding a method which is called before a class is fully instantiated as @jsbueno explained in response to my particular use case.

Community
  • 1
  • 1
rmorshea
  • 832
  • 1
  • 7
  • 25