How do I disallow a classmethod from being called on an instance?
Asked Answered
N

2

6

I have been looking a the source code for peewee, specifically Model and the update function: https://github.com/coleifer/peewee/blob/a33e8ccbd5b1e49f0a781d38d40eb5e8f344eee5/peewee.py#L4718

I don't like the fact that this method can be called from a row instance, when any update operation affects every row in the model if the statement is not correctly coupled with a where clause. Thus, I want to find some way to disallow calling this classmethod from the model instances.

Some googling leads me to believe that this may be quite difficult. delattr from __init__ did not seem to work. And running isclass(self) from the uppdate function always returns True since it appears that when we are inside the classmethod we actually are the class and not the instance.

Any suggestions?

Narda answered 19/2, 2017 at 2:18 Comment(1)
I don't think you can do it with classmethod. You would have to write your own descriptor similar to classmethod that checks whether its being bound on an instance and raises an exception.Sard
S
5

Using a metaclass

You can customize the class __getattribute__ as in Schwobaseggl's answer - but you could also use a custom metaclass.

When we mention "metaclass" in Python, one ordinarily thinks of overriding its __new__ method and doing complicated things at class creation time (in contrast with instance creation time). However, if you leave all special dunder (__these__ __methods__) aside, a metaclas is just a class's class - and all its methods will be visible from the class itself, but won't be visible from the class's instances. That means, they won't show up when one "dir"s an instance, but will show up when one "dir" the class - and won't be directly retrievable through the instance. (Although, of course, one can always do self.__class__.method)

Moreover, despite metaclasse's justified bad-fame of complexity, overriding __getattribute__ itself can have some pitfalls.

In this specific case, the classs you want to protect alreayd use a metaclass - but this particular use, unlike "ordinary" metaclass uses, can be freely composable just like an ordinary class hierarchy:

class ClsMethods(BaseModel):  
     # inherit from `type` if there is no metaclass already
     
     # now, just leave __new__, __init__, __prepare__ , alone
     # and write your class methods as ordinary methods:
     def update(cls, *args, **kw):
          ...
     
     def fetch_rows_from(self, ...):
          ...

class Model(with_metaclass(ClsMethods)):
      # This really socks. Do you really still need Py2 support? :-) 

      ...

(It should be obvious, but perceive you don't need to declare the methods in the metaclass as classmethods: all of them are classmethods for the metaclass instance, which is the class)

And a quick demo at the console:

In [37]: class M(type):
    ...:     def secret(cls): print("At class only")
    ...:     

In [38]: class A(metaclass=M):
    ...:     pass
    ...: 

In [39]: A.secret()
At class only

In [40]: A().secret()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-40-06355f714f97> in <module>()
----> 1 A().secret()

AttributeError: 'A' object has no attribute 'secret'

Creating a specialized decorator

Python's classmethod decorator, and even ordinary instance methods, actually make use of the descriptor protocol: the methods, being objects themselves, have an specialized __get__ method which is used when retrieving them from an instance or from a class and modify the callable accordingly.

So, all we have to do is to create an equivalent of classmethod which will disallow being called from an instance:


from functools import partial

class strict_classmethod:
    def __init__(self, func):
         self.func = func
    def __get__(self, instance, owner):
         if instance is not None:
              raise TypeError("This method cannot be called from instances")
         return partial(self.func, owner)

class A:
   @strict_classmethod
   def secret(cls, ...):
       ...

This is a simple implementation that will work, but the decorated methods will still show up in class' introspection and dir - however, it suffices to avoid calls by mistake.

Sanborne answered 19/2, 2017 at 12:56 Comment(5)
Now I am getting TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases.Narda
from this line return super(BaseModel, cls).__new__(cls, name, bases, attrs) in BaseModel.__new__Narda
I tried this and it works great for a single class. Once you start inheriting that class, it gets iffy. I couldn't get it to work. Calling a classmethod on a child class would always call the classmethod with the same name on the parent or it wouldn't show up at all. I didn't try completely re-implementing the derived classes' metaclasses, since it would've been detrimental.Xylotomy
@Tim: if you are going through inheritance and trying to override the methods, just like ordnary methods, this way calls for inheritance of any metaclasses containing "class methods only", as well, and making any subclass class an instance of the metaclass subclass as well - then super can even be called in the methods overrided in the derived metaclass, with 0 surprises.Sanborne
However, a decorator for strictclassmethod can also be simple, I think I can update the answer for including that.Sanborne
H
0

You can override __getattribute__ which is called for every attribute access and only for instances and inspect the stuff that is being returned for classmethodicity. Alternatively, you can just refuse a certain item:

import inspect

class A(object):  # aka Model
    @classmethod
    def f(cls, *args, **kwargs):
        print(args, kwargs)

class B(A):  # your Model subclass
    def __getattribute__(self, item):
        # if item == 'update':
        #     raise TypeError
        obj = super(B, self).__getattribute__(item)
        # classmethod check
        if inspect.ismethod(obj) and obj.__self__ is B:
            raise TypeError
        return obj

> a = A()
> b = B()

> A.f(5, p=7)
(5,) {'p': 7}

> B.f(5, p=7)
(5,) {'p': 7}

> a.f(5, p=7)
(5,) {'p': 7}

> b.f(5, p=7)
# TypeError

The classmethod check is taken from this answer by Martijn Pieters.

Handler answered 19/2, 2017 at 3:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.