pythonkeyword-argumentpython-3.7python-dataclasses

How to make "keyword-only" fields with dataclasses?


Since 3.0 there is support to make an argument keyword only:

class S3Obj:
    def __init__(self, bucket, key, *, storage_class='Standard'):
        self.bucket = bucket
        self.key = key
        self.storage_class = storage_class

How to get that kind of signature using dataclasses? Something like this, but preferably without the SyntaxError:

@dataclass
class S3Obj:
    bucket: str
    key: str
    *
    storage_class: str = 'Standard'

Ideally declarative, but using the __post_init__ hook and/or a replacement class decorator is fine too - as long as the code is reusable.

Edit: maybe something like this syntax, using an ellipsis literal

@mydataclass
class S3Obj:
    bucket: str
    key: str
    ...
    storage_class: str = 'Standard'

Solution

  • In Python 3.10+, there's a dataclasses.KW_ONLY sentinel that works like this:

    @dataclasses.dataclass
    class Example:
        a: int
        b: int
        _: dataclasses.KW_ONLY
        c: int
        d: int
    

    Any fields after the KW_ONLY pseudo-field are keyword-only.

    There's also a kw_only parameter to the dataclasses.dataclass decorator, which makes all fields keyword-only:

    @dataclasses.dataclass(kw_only=True)
    class Example:
        a: int
        b: int
    

    It's also possible to pass kw_only=True to dataclasses.field to mark individual fields as keyword-only.

    If keyword-only fields come after non-keyword-only fields (possible with inheritance, or by individually marking fields keyword-only), keyword-only fields will be reordered after other fields, specifically for the purpose of __init__. Other dataclass functionality will keep the declared order. This reordering is confusing and should probably be avoided.


    Pre-Python 3.10 answer:

    You're not going to get much help from dataclasses when doing this. There's no way to say that a field should be initialized by keyword-only argument, and the __post_init__ hook doesn't know whether the original constructor arguments were passed by keyword. Also, there's no good way to introspect InitVars, let alone mark InitVars as keyword-only.

    At minimum, you'll have to replace the generated __init__. Probably the simplest way is to just define __init__ by hand. If you don't want to do that, probably the most robust way is to create field objects and mark them kwonly in the metadata, then inspect the metadata in your own decorator. This is even more complicated than it sounds:

    import dataclasses
    import functools
    import inspect
    
    # Helper to make calling field() less verbose
    def kwonly(default=dataclasses.MISSING, **kwargs):
        kwargs.setdefault('metadata', {})
        kwargs['metadata']['kwonly'] = True
        return dataclasses.field(default=default, **kwargs)
    
    def mydataclass(_cls, *, init=True, **kwargs):
        if _cls is None:
            return functools.partial(mydataclass, **kwargs)
    
        no_generated_init = (not init or '__init__' in _cls.__dict__)
        _cls = dataclasses.dataclass(_cls, **kwargs)
        if no_generated_init:
            # No generated __init__. The user will have to provide __init__,
            # and they probably already have. We assume their __init__ does
            # what they want.
            return _cls
    
        fields = dataclasses.fields(_cls)
        if any(field.metadata.get('kwonly') and not field.init for field in fields):
            raise TypeError('Non-init field marked kwonly')
    
        # From this point on, ignore non-init fields - but we don't know
        # about InitVars yet.
        init_fields = [field for field in fields if field.init]
        for i, field in enumerate(init_fields):
            if field.metadata.get('kwonly'):
                first_kwonly = field.name
                num_kwonly = len(init_fields) - i
                break
        else:
            # No kwonly fields. Why were we called? Assume there was a reason.
            return _cls
    
        if not all(field.metadata.get('kwonly') for field in init_fields[-num_kwonly:]):
            raise TypeError('non-kwonly init fields following kwonly fields')
    
        required_kwonly = [field.name for field in init_fields[-num_kwonly:]
                           if field.default is field.default_factory is dataclasses.MISSING]
    
        original_init = _cls.__init__
    
        # Time to handle InitVars. This is going to get ugly.
        # InitVars don't show up in fields(). They show up in __annotations__,
        # but the current dataclasses implementation doesn't understand string
        # annotations, and we want an implementation that's robust against
        # changes in string annotation handling.
        # We could inspect __post_init__, except there doesn't have to be a
        # __post_init__. (It'd be weird to use InitVars with no __post_init__,
        # but it's allowed.)
        # As far as I can tell, that leaves inspecting __init__ parameters as
        # the only option.
    
        init_params = tuple(inspect.signature(original_init).parameters)
        if init_params[-num_kwonly] != first_kwonly:
            # InitVars following kwonly fields. We could adopt a convention like
            # "InitVars after kwonly are kwonly" - in fact, we could have adopted
            # "all fields after kwonly are kwonly" too - but it seems too likely
            # to cause confusion with inheritance.
            raise TypeError('InitVars after kwonly fields.')
        # -1 to exclude self from this count.
        max_positional = len(init_params) - num_kwonly - 1
    
        @functools.wraps(original_init)
        def __init__(self, *args, **kwargs):
            if len(args) > max_positional:
                raise TypeError('Too many positional arguments')
            check_required_kwargs(kwargs, required_kwonly)
            return original_init(self, *args, **kwargs)
        _cls.__init__ = __init__
    
        return _cls
    
    def check_required_kwargs(kwargs, required):
        # Not strictly necessary, but if we don't do this, error messages for
        # required kwonly args will list them as positional instead of
        # keyword-only.
        missing = [name for name in required if name not in kwargs]
        if not missing:
            return
        # We don't bother to exactly match the built-in logic's exception
        raise TypeError(f"__init__ missing required keyword-only argument(s): {missing}")
    

    Usage example:

    @mydataclass
    class S3Obj:
        bucket: str
        key: str
        storage_class: str = kwonly('Standard')
    

    This is somewhat tested, but not as thoroughly as I would like.


    You can't get the syntax you propose with ..., because ... doesn't do anything a metaclass or decorator can see. You can get something pretty close with something that actually triggers name lookup or assignment, like kwonly_start = True, so a metaclass can see it happen. However, a robust implementation of this is complicated to write, because there are a lot of things that need dedicated handling. Inheritance, typing.ClassVar, dataclasses.InitVar, forward references in annotations, etc. will all cause problems if not handled carefully. Inheritance probably causes the most problems.

    A proof-of-concept that doesn't handle all the fiddly bits might look like this:

    # Does not handle inheritance, InitVar, ClassVar, or anything else
    # I'm forgetting.
    
    class POCMetaDict(dict):
        def __setitem__(self, key, item):
            # __setitem__ instead of __getitem__ because __getitem__ is
            # easier to trigger by accident.
            if key == 'kwonly_start':
                self['__non_kwonly'] = len(self['__annotations__'])
            super().__setitem__(key, item)
    
    class POCMeta(type):
        @classmethod
        def __prepare__(cls, name, bases, **kwargs):
            return POCMetaDict()
        def __new__(cls, name, bases, classdict, **kwargs):
            classdict.pop('kwonly_start')
            non_kwonly = classdict.pop('__non_kwonly')
    
            newcls = super().__new__(cls, name, bases, classdict, **kwargs)
            newcls = dataclass(newcls)
    
            if non_kwonly is None:
                return newcls
    
            original_init = newcls.__init__
    
            @functools.wraps(original_init)
            def __init__(self, *args, **kwargs):
                if len(args) > non_kwonly:
                    raise TypeError('Too many positional arguments')
                return original_init(self, *args, **kwargs)
    
            newcls.__init__ = __init__
            return newcls
    

    You'd use it like

    class S3Obj(metaclass=POCMeta):
        bucket: str
        key: str
    
        kwonly_start = True
    
        storage_class: str = 'Standard'
    

    This is untested.