Is there a danger restricting a method to only be used on instances?

Viewed 103

I want to restrict methods to be used on instance objects, not on class. Does this break aspects of the data model, i.e. calls to super()? Normally, the following works:

class Foo:
    def bar(self, x=None):
         print(x)

Foo().bar(42)
Foo.bar(Foo(), 42)

I want to prevent the second case, because for some reason people make the mistake of calling Foo.bar(42), which causes unanticipated errors that change as bar gets refactored (here it prints None). Following the tutorial on data descriptors I use the descriptor-decorator:

from types import MethodType

class OnlyMethodNotUnboundFunction:
    def __init__(self, func):
        self.func = func
    def __get__(self, obj, objtype=None):
        if obj is None:
            # Changed from `return self`
            raise UnboundLocalError("This method can only be used by an instantiated object, not a Class")
        return MethodType(self.func, obj)

class Foo():
    @OnlyMethodNotUnboundFunction
    def bar(self, x=None):
        print(x)

Now, Foo().bar(42) works as anticipated, but Foo.bar(42) raises the UnboundLocalError, as desired. However, if I subclass Foo and call super, the behavior of the descriptor is not inherited in either call to super():

class Baz1(Foo):
    def bar(self, x=None):
        super().bar(x)
        print(x + 1)

class Baz2(Foo):
    def bar(self, x=None):
        super(Baz2, self).bar(x)
        print(x + 1)

Both Baz1.bar(Baz1(), 42) and Baz2.bar(Baz2(), 42) print 42 and 43. I had thought that the call to super() in Baz1 would return the class Foo, not an instance. The MRO would be the same in either case, but Baz1.bar(Baz1(), 42) should cause a call to Foo.bar(42) and raise the UnboundLocalError.

What's going on here? Does super() detect the presence of a passed self argument and automatically return an instance? Moreover, is there a way to inherit descriptors from parent class so that Baz2.bar(Baz2(), 42) also raises an error?

2 Answers

Your use case is "weird". If someone calls Foo.bar(Foo(), 42), fine, whatever, they get what they deserve, mostly they get hard-to-read code that is not idiomatic Python.

It does make sense to insist that 1st or 2nd parameter passed in shall be of a certain type. Use instanceof( ... ), and raise a fatal error if caller messed up. Feel free to enforce other DbC preconditions upon entry, such as requiring that some integer arg shall be positive. If you often see bad args passed in then make the checks more stringent, use more verbose names, or improve the docs.

It also makes sense to help folks call you correctly, enforced by the interpreter and also hinted at via IDEs. So consider declaring the signature like this

def bar(self, *, x=None):

instead of

def bar(self, x=None):

That way you have an optional argument with a default, but caller is forced to name the kwarg on every call, reducing the chances for confusion. (This works better if argument has a descriptive one- or two-word name rather than just a single letter.)

First, the arguments in @J_H's answer are perfect - you really should not worry about this -

If someone calls Foo.bar(Foo(), 42), fine, whatever, they get what they deserve, mostly they get hard-to-read code that is not idiomatic Python.

Second - instead of UnboundLocalError you really should raise AttributeError or TypeError: the former, although has "Unbound" in the name has to do with variable names. You can easily create a custom exception, inheriting from AttributeError that will make complete sense in your context:

class UnboundMethodError(AttributeError):
    pass

(that is all that is needed for a custom exeception that will be semantically ok)

And finally, past the point that "you should not", let's see "how to do it".

What's going on here? Does super() detect the presence of a passed self argument and automatically return an instance?

As @juanpa.arrivilaga expressed: a super() call will return a super object, which is a special type of proxy which will retrieve attributes as if they were from an instance. But that is irrelevant: if you write a method that takes self as a parameter: an instance already exists at that point. In the idiom Foo.bar , bar is being retrieved from the class, but when the call is made to Foo.bar(Foo(), ...), the method bar already gets the new Foo() instance as its self parameter.

The only way to propagate the behavior you create for your descriptor for overriden methods is using a mechanism to wrap these methods in your special descriptor as well.

This can be done at class creation time by using a special __init_subclass__ method that will check if there are any overriden descriptors of the wanted type, and recreate them:

I modified your descriptor so that it does the self insertion manually, instead of creating a new MethodType on each invocation: it makes getattr on the class work correctly, which is needed in this approach. (it is just that the method retrieved will error if it was initially retrieved from the class, not an instance)

from types import MethodType

class OnlyMethodNotUnboundFunction:
    def __init__(self, func, instance=None):
        self.func = func
        self.instance = instance
        
    def __get__(self, obj, objtype=None):
        return type(self)(func=self.func, instance=obj)
    
    def __call__(self, *args, **kw):
        if self.instance is None:
            raise AttributeError("This method can only be used by an instantiated object, not a Class")
        return self.func(self.instance, *args, **kw)

class Base:
    def __init_subclass__(cls, *args, **kwargs):
        super().__init_subclass__(*args, **kwargs)
        for name, attr in list(cls.__dict__.items()):
            if not callable(attr):
                continue
            # temporarily remove method from the subclass so we can
            # inspect it at the superclass. delattr only removes the method
            # from the most derived subclass - in this case,  "cls"
            # With multiple inheritance, there is no other way
            # of retrieving the correct attribute without
            # reimplementing the C3 method resolution order
            # used by super(). (and super().attr does not work
            # for classes, just instances)
            delattr(cls, name)
            
            if isinstance(getattr(cls, name, None), OnlyMethodNotUnboundFunction):
                attr = OnlyMethodNotUnboundFunction(attr)
            # and restore it:
            setattr(cls, name, attr)
        
class Foo(Base):
    @OnlyMethodNotUnboundFunction
    def bar(self, x=None):
        print(x)
        
        
class Baz1(Foo):
    def bar(self, x=None):
        super().bar(x)
        print(x + 1)

And trying to use Baz1.bar, we get:


In [116]: Baz1().bar(10)
10
11

In [117]: Baz1.bar(Baz1(), 10)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
(...)
     14     return self.func(self.instance, *args, **kw)

AttributeError: This method can only be used by an instantiated object, not a Class

Related