pythonclass-methodpeeweecpython

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


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?


Solution

  • 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.