Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Like __hash__, allow setting MyClass.__init__ to None to prevent it being called by type.__call__ #78495

Closed
mr-nfamous mannequin opened this issue Aug 2, 2018 · 3 comments
Assignees
Labels
3.8 only security fixes interpreter-core (Objects, Python, Grammar, and Parser dirs) type-feature A feature request or enhancement

Comments

@mr-nfamous
Copy link
Mannequin

mr-nfamous mannequin commented Aug 2, 2018

BPO 34314
Nosy @rhettinger, @serhiy-storchaka, @mr-nfamous

Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.

Show more details

GitHub fields:

assignee = 'https://github.com/rhettinger'
closed_at = <Date 2018-08-03.06:53:02.097>
created_at = <Date 2018-08-02.03:02:06.189>
labels = ['interpreter-core', 'type-feature', '3.8']
title = 'Like __hash__, allow setting MyClass.__init__ to None to prevent it being called by type.__call__'
updated_at = <Date 2018-08-03.06:53:02.096>
user = 'https://github.com/mr-nfamous'

bugs.python.org fields:

activity = <Date 2018-08-03.06:53:02.096>
actor = 'rhettinger'
assignee = 'rhettinger'
closed = True
closed_date = <Date 2018-08-03.06:53:02.097>
closer = 'rhettinger'
components = ['Interpreter Core']
creation = <Date 2018-08-02.03:02:06.189>
creator = 'bup'
dependencies = []
files = []
hgrepos = []
issue_num = 34314
keywords = []
message_count = 3.0
messages = ['322907', '322908', '322909']
nosy_count = 3.0
nosy_names = ['rhettinger', 'serhiy.storchaka', 'bup']
pr_nums = []
priority = 'normal'
resolution = 'rejected'
stage = 'resolved'
status = 'closed'
superseder = None
type = 'enhancement'
url = 'https://bugs.python.org/issue34314'
versions = ['Python 3.8']

@mr-nfamous
Copy link
Mannequin Author

mr-nfamous mannequin commented Aug 2, 2018

Right now, you really gotta jump through hoops in some cases if you only want to use __new__ and don't care about __init__ (is there ever a time where you'd use both?). The problem originates in type.__call__. I'm attaching a full Python implementation of type_call/object_init at the end, but here's a tl;dr representation of the issue i type_call (where the whole thing can be basically summarized as this):

def type_call(type, *args, **kws):
   r = type.__new__(type, *args, **kws)
   if not issubclass(r, type):
       type(r).__init__(r, *args, **kws)
   return r

So if type(r).__init__ is object.__init__ or some other method with an incompatible signature to the metaclass's, errors are raised, which leads to having to implement really annoying workarounds. The annoyingness is compounded further by the fact that all object_init does is make sure it didn't receive any arguments.

All of that can be avoided by setting __init__ in the class to None, which would have the same effect as setting tp_init to NULL on an extension type. Perhaps with a caveat that the only current reachable __init__ is object.__init__?

I don't really find myself in situations involving the need for __new__ too often, but wouldn't this have a profoundly positive impact on pickle as well? One thing I can think of is there can be a universal pickle implementation for extension types with Py_TPFLAGS_BASETYPE set and a nonzero dict offset since if it detected a NULL init all it has to do is memcpy the base struct and pickle the instance dict.

Anyway, the C code is pretty easy to understand but here's a Python implementation almost exactly equivalent that shows a case where a metaclass can be used as a callable that accepts an arbitrary number of arguments instead of a forced three and returns a class:

PyType_Type = type
Py_TYPE = type
PyType_IsSubtype = issubclass
PyTuple_Check = tuple.__instancecheck__
PyDict_Check = dict.__instancecheck__
PyDict_GET_SIZE = dict.__len__
PyTUple_GET_SIZE = tuple.__len__

NULL = 0
ALLOW_NULL_INIT = False

def excess_args(args, kws):
    return args or kws

class Object:

    def __init__(self, *args, **kws):
        # object_init literally does nothing but check for zero len args/kws
        # (bit of a tangent but why is excess_args not a macro...?)
        if ALLOW_NULL_INIT:
            return
        tp = Py_TYPE(self)
        print('Object.__init__, type is:', tp.__name__)
        if excess_args(args, kws):
            if tp.__init__ is object.__init__:
                raise TypeError("object.__init__() takes no arguments")
            raise TypeError(f'{tp.__name__}.__init__() takes no arguments')

def fake_init(*args, **kws):
    pass

class Type(type):

    def __getattribute__(self, attr):
        value = type.__getattribute__(self, attr)
        if attr == '__init__':
            if ALLOW_NULL_INIT and value is None:
                return fake_init
            return super(type(self).__mro__[1], self).__init__
        return value
    
    def __call__(tp, *args, **kws):
        
        if getattr(tp, '__new__', 0) == NULL:
            raise TypeError(f"cannot create {tp.__name__} instances")
        
        obj = tp.__new__(tp, *args, **kws)
        if (tp is type and
            PyTuple_Check(args) and PyTuple_GET_SIZE(args)== 1 and
            (kws == NULL or
             (PyDict_Check(kws) and PyDict_GET_SIZE(kws)==0))):
            return obj
        
        if not PyType_IsSubtype(Py_TYPE(obj), tp):
            return obj
        
        tp = Py_TYPE(obj)
        
        # Here's where the problem is. What could possibly be negatively
        # affected by this?
        if getattr(tp, '__init__', 0):
            res = tp.__init__(obj, *args, **kws)
            
        return obj

def autoname(arg):
    return '_'.join(arg.split())

def autobase(opt):
    return () if not opt else (opt,)

class MetaBase(Object, type):
    pass

class Mixin1:
    pass

class Meta(MetaBase, metaclass=Type):

    __init__ = None
        
    def __new__(metacls, name, opt=None):
        return super().__new__(metacls, autoname(name), autobase(opt), {})

if __name__ == '__main__':
    try:
        foobar = Meta('foo bar', Mixin1)
    except TypeError as er:
        print(f'could not make foobar because: {er}')
    print('setting ALLOW_NULL_INIT to True;')
    ALLOW_NULL_INIT = True
    foobar = Meta('foo bar', Mixin1)
    print('foobar.__name__ is:', foobar.__name__)
    print('foobar__mro__ is:', foobar.__mro__)

@mr-nfamous mr-nfamous mannequin added 3.8 only security fixes interpreter-core (Objects, Python, Grammar, and Parser dirs) type-feature A feature request or enhancement labels Aug 2, 2018
@rhettinger
Copy link
Contributor

Right now, you really gotta jump through hoops
in some cases if you only want to use __new__
and don't care about __init__

I'm now sure I see the difficulty. It is easy to define a classes with __new__ and without __init__:

    >>> class A:
            def __new__(cls, *args):
                print('New called with', cls, 'and', args)
                return object.__new__(cls)

    >>> a = A(10, 20, 30)
    New called with <class '__main__.A'> and (10, 20, 30)
    >>> isinstance(a, A)
    True

Like __hash__, allow setting MyClass.__init__ to None

FWIW, the API for hashing being set-to-None wasn't done because we like it (there a numerous downsides). It was done because we needed hashing to be on by default and there needed to be a way to turn it off. The downsides are that it confuses users, it is hard to teach and remember, and it adds a branch to various APIs which would then need to test for None. Instead, we prefer the pattern of having object() provide a default dummy method that either does nothing or gives up (like object.__init__ or the various rich comparison methods for object). This simplifies downstream code than doesn't have to check for a rare special case.

@rhettinger rhettinger self-assigned this Aug 2, 2018
@serhiy-storchaka
Copy link
Member

Setting __hash__ to None doesn't do what you think. It doesn't prevent __hash__ from being called by hash(), instead it produces a TypeError.

>>> class A: __hash__ = None
... 
>>> hash(A())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'A'

Actually the effect of special casing __hash__ is not raising a TypeError, but raising a TypeError with better error message. A TypeError was already raised before if set __hash__ to a non-callable. In 2.7:

>>> class A: __hash__ = None
... 
>>> hash(A())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'NoneType' object is not callable

If you want to prevent an inherited __init__ from being called by types's __call__, you can define an __init__ with an empty body.

    def __init__(self, *args, **kwargs):
        pass

@ezio-melotti ezio-melotti transferred this issue from another repository Apr 10, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3.8 only security fixes interpreter-core (Objects, Python, Grammar, and Parser dirs) type-feature A feature request or enhancement
Projects
None yet
Development

No branches or pull requests

2 participants