How to apply default value to Python dataclass field when None was passed?
Asked Answered
A

9

65

I need a class that will accept a number of parameters, I know that all parameters will be provided but some maybe passed as None in which case my class will have to provide default values.

I want to setup a simple dataclass with a some default values like so:

@dataclass
class Specs1:
    a: str
    b: str = 'Bravo'
    c: str = 'Charlie'

I would like to be able to get the default value for the second field but still set a value for the third one. I cannot do this with None because it is happily accepted as a value for my string:

r1 = Specs1('Apple', None, 'Cherry') # Specs1(a='Apple', b=None, c='Cherry')

I have come up with the following solution:

@dataclass
class Specs2:
    def_b: ClassVar = 'Bravo'
    def_c: ClassVar = 'Charlie'
    a: str
    b: str = def_b
    c: str = def_c
    
    def __post_init__(self):
        self.b = self.def_b if self.b is None else self.b
        self.c = self.def_c if self.c is None else self.c

Which seems to behave as intended:

r2 = Specs2('Apple', None, 'Cherry') # Specs2(a='Apple', b='Bravo', c='Cherry')

However, I feel it is quite ugly and that I am maybe missing something here. My actual class will have more fields so it will only get uglier.

The parameters passed to the class contain None and I do not have control over this aspect.

Acerbic answered 19/6, 2019 at 10:14 Comment(2)
using __post_init__ method is probably the only way to achieve thisQuimper
I wish field had a flag for trigger_if_none_passed.Flavescent
T
23

I know this is a little late, but inspired by MikeSchneeberger's answer I made a small adaptation to the __post_init__ function that allows you to keep the defaults in the standard format:

from dataclasses import dataclass, fields
def __post_init__(self):
    # Loop through the fields
    for field in fields(self):
        # If there is a default and the value of the field is none we can assign a value
        if not isinstance(field.default, dataclasses._MISSING_TYPE) and getattr(self, field.name) is None:
            setattr(self, field.name, field.default)

Adding this to your dataclass should then ensure that the default values are enforced without requiring a new default class.

Treehopper answered 12/11, 2021 at 14:37 Comment(0)
A
33

The simple solution is to just implement the default arguments in __post_init__() only!

@dataclass
class Specs2:
    a: str
    b: str
    c: str

    def __post_init__(self):
        if self.b is None:
            self.b = 'Bravo'
        if self.c is None:
            self.c = 'Charlie'

(Code is not tested. If I got some detail wrong, it wouldn't be the first time)

Arbutus answered 15/3, 2021 at 16:37 Comment(3)
Unfortunately this solution isn't possible when using strict type annotations. Type checkers like mypy will not allow initializing Specs2 from Optional[str] or None.Cheyenne
@JustinFuruness: My point was that this pattern isn't type-safe. Sprinkling the code with # type: ignore basically makes it even worse. If I'm using a type checker, I'm obviously using it to find bugs, i.e., my goal isn't just to find ways to locally turn of the type checker again (no added value then).Cheyenne
@bluenote10, upon further consideration, you are correct and imo this pattern is not advisable when type checking. With type checking the approach considered here may be a viable alternative: https://mcmap.net/q/299399/-how-to-apply-default-value-to-python-dataclass-field-when-none-was-passedFructose
T
23

I know this is a little late, but inspired by MikeSchneeberger's answer I made a small adaptation to the __post_init__ function that allows you to keep the defaults in the standard format:

from dataclasses import dataclass, fields
def __post_init__(self):
    # Loop through the fields
    for field in fields(self):
        # If there is a default and the value of the field is none we can assign a value
        if not isinstance(field.default, dataclasses._MISSING_TYPE) and getattr(self, field.name) is None:
            setattr(self, field.name, field.default)

Adding this to your dataclass should then ensure that the default values are enforced without requiring a new default class.

Treehopper answered 12/11, 2021 at 14:37 Comment(0)
Q
15

Here is another solution.

Define DefaultVal and NoneRefersDefault types:

from dataclasses import dataclass, fields

@dataclass
class DefaultVal:
    val: Any


@dataclass
class NoneRefersDefault:
    def __post_init__(self):
        for field in fields(self):

            # if a field of this data class defines a default value of type
            # `DefaultVal`, then use its value in case the field after 
            # initialization has either not changed or is None.
            if isinstance(field.default, DefaultVal):
                field_val = getattr(self, field.name)
                if isinstance(field_val, DefaultVal) or field_val is None:
                    setattr(self, field.name, field.default.val)

Usage:

@dataclass
class Specs3(NoneRefersDefault):
    a: str
    b: str = DefaultVal('Bravo')
    c: str = DefaultVal('Charlie')

r3 = Specs3('Apple', None, 'Cherry')  # Specs3(a='Apple', b='Bravo', c='Cherry')

EDIT #1: Rewritten NoneRefersDefault such that the following is possible as well:

@dataclass
r3 = Specs3('Apple', None)  # Specs3(a='Apple', b='Bravo', c='Charlie')

EDIT #2: Note that if no class inherits from Spec, it might be better to have no default values in the dataclass and a "constructor" function create_spec instead:

@dataclass
class Specs4:
    a: str
    b: str
    c: str

def create_spec(
        a: str,
        b: str = None,
        c: str = None,
):
    if b is None:
        b = 'Bravo'
    if c is None:
        c = 'Charlie'

    return Spec4(a=a, b=b, c=c)

also see dataclass-abc/example

Quimper answered 24/9, 2019 at 13:11 Comment(4)
Nice one! It took me a minute or to to wrap my head around it but I think I understand it now.Acerbic
Very interesting solution, I like it. Why not use a more specific DefaultStr class with val: str?Guido
I guess, you want to add DefaultStr for more accurate type hinting. But in this case, the type hint I'm mostly interested in are a: str, b: str and c: str defined in Spec3, on which the type hint of val inside DefaultVal has no influence.Quimper
- ValueError: mutable default <class '....DefaultVal'> for field total total: int = DefaultVal(default=0) or from dataclasses import field .... total: int = field(default=0)Nutlet
S
13
@dataclass
class Specs1:
    a: str
    b: str = field(default='Bravo')
    c: str = field(default='Charlie')
Subtype answered 18/11, 2022 at 20:17 Comment(3)
This doesn't work, it's another way to write b: str = 'Bravo' and c: str = 'Charlie' and it will accept None as a value without substituting it for the defaultAcerbic
Um... it worked perfectly for me... ``` from dataclasses import dataclass, field @dataclass class Specs1: a: str b: str = field(default='Bravo') c: str = field(default='Charlie') r1 = Specs1('Apple', None, 'Cherry') # Specs1(a='Apple', b=None, c='Cherry') print(r1) /usr/local/bin/python3 /Users/jasonvogel/Desktop/dataclass.py Specs1(a='Apple', b=None, c='Cherry') ```Subtype
You confirm that this does not work. print(r1) should output Specs1(a='Apple', b='Bravo', c='Cherry') and not Specs1(a='Apple', b=None, c='Cherry'). The None arguments should be substituted by the default value and not set to None.Ruella
K
8

In data classes you can access a default value of class attribute: Specs.b You can check for None and pass default value if needed

Code for this:

dataclasses.dataclass()
class Specs1:
    a: str
    b: str = 'Bravo'
    c: str = 'Charlie'
a = 'Apple'
b = None
c = 'Potato'
specs = Specs1(a=a, b=b or Specs1.b, c=c or Specs1.c)
>>> specs
Specs1(a='Apple', b='Bravo', c='Potato')
Koffman answered 5/3, 2021 at 14:25 Comment(1)
This appears to work except for attributes with dataclass.field(...) defaults.Zigrang
P
3

Use key based parameters. You can just do r2 = Specs1('Apple', c='Cherry'). You don't have to use None. Refer here.

Output:

Specs1(a='Apple', b='Bravo', c='Cherry')
Pelotas answered 19/6, 2019 at 10:25 Comment(1)
Understood, but in my case, I will receive a fixed collection of values with None where default is expectedAcerbic
A
2

Perhaps the most efficient and convenient approach that I can think of for this task, involves using metaclasses in Python to automatically generate a __post_init__() method for the class, which will set the default value specified for a field if a None value is passed in for that field to __init__().

Assume we have these contents in a module metaclasses.py:

import logging


LOG = logging.getLogger(__name__)
logging.basicConfig(level='DEBUG')


def apply_default_values(name, bases, dct):
    """
    Metaclass to generate a __post_init__() for the class, which sets the
    default values for any fields that are passed in a `None` value in the
    __init__() method.
    """

    # Get class annotations, which `dataclasses` uses to determine which
    # fields to add to the __init__() method.
    cls_annotations = dct['__annotations__']

    # This is a dict which will contain: {'b': 'Bravo', 'c': 'Charlie'}
    field_to_default_val = {field: dct[field] for field in cls_annotations
                            if field in dct}

    # Now we generate the lines of the __post_init()__ method
    body_lines = []
    for field, default_val in field_to_default_val.items():
        body_lines.append(f'if self.{field} is None:')
        body_lines.append(f'  self.{field} = {default_val!r}')

    # Then create the function, and add it to the class
    fn = _create_fn('__post_init__',
                    ('self', ),
                    body_lines)

    dct['__post_init__'] = fn

    # Return new class with the __post_init__() added
    cls = type(name, bases, dct)
    return cls


def _create_fn(name, args, body, *, globals=None):
    """
    Create a new function. Adapted from `dataclasses._create_fn`, so we
    can also log the function definition for debugging purposes.
    """
    args = ','.join(args)
    body = '\n'.join(f'  {b}' for b in body)

    # Compute the text of the entire function.
    txt = f'def {name}({args}):\n{body}'

    # Log the function declaration
    LOG.debug('Creating new function:\n%s', txt)

    ns = {}
    exec(txt, globals, ns)
    return ns[name]

Now in our main module, we can import and use the metaclass we just defined:

from dataclasses import dataclass

from metaclasses import apply_default_values


@dataclass
class Specs1(metaclass=apply_default_values):
    a: str
    b: str = 'Bravo'
    c: str = 'Charlie'


r1 = Specs1('Apple', None, 'Cherry')
print(r1)

Output:

DEBUG:metaclasses:Creating new function:
def __post_init__(self):
  if self.b is None:
    self.b = 'Bravo'
  if self.c is None:
    self.c = 'Charlie'
Specs1(a='Apple', b='Bravo', c='Cherry')

To confirm that this approach is actually as efficient as stated, I've set up a small test case to create a lot of Spec objects, in order to time it against the version in @Lars's answer, which essentially does the same thing.

from dataclasses import dataclass
from timeit import timeit

from metaclasses import apply_default_values


@dataclass
class Specs1(metaclass=apply_default_values):
    a: str
    b: str = 'Bravo'
    c: str = 'Charlie'


@dataclass
class Specs2:
    a: str
    b: str
    c: str

    def __post_init__(self):
        if self.b is None:
            self.b = 'Bravo'
        if self.c is None:
            self.c = 'Charlie'


n = 100_000

print('Manual:    ', timeit("Specs2('Apple', None, 'Cherry')",
                            globals=globals(), number=n))
print('Metaclass: ', timeit("Specs1('Apple', None, 'Cherry')",
                            globals=globals(), number=n))

Timing for n=100,000 runs, the results show it's close enough to not really matter:

Manual:     0.059566365
Metaclass:  0.053688744999999996
Adah answered 12/11, 2021 at 17:8 Comment(0)
Q
0

I understand that you just want positional arguments. This can be accomplished with in-line conditionals (for code readability).

class Specs():
    def __init__(self, a=None,b=None,c=None):
        self.a = a if a is not None else 'Apple'
        sefl.b = b if b is not None else 'Bravo'
        self.c = c if c is not None else 'Cherry'
example = Specs('Apple', None, 'Cherry')

This approach can be done without an init method, if you prefer it that way.

However, you may considered an __init__() method with named arguments.

class Specs():
    def __init__(self, a = 'Apple', b = 'Bravo', c = 'Cherry'):
        self.a = a
        self.b = b
        self.c = c
example = Specs('Apple', c = 'Cherry')
Quadrat answered 19/6, 2019 at 10:32 Comment(4)
Yes, your first solution is pretty much what I need. So I guess this is better accomplished outside of a dataclass, but for the sake of curiosity, how would you write this with a dataclass ?Acerbic
The in-line conditionals can be included in the first chunk of code you provided, am I wrong?Quadrat
I'm really not sure how in the context of a dataclass (which was the premise of my question that you left out :-) )Acerbic
Yes, I guess you're right, I didn't think it too much. The solution you provide in the question seems right.Quadrat
A
-3

Not too clear what you are trying to do with your Class. Should these defaults not rather be properties?

Maybe you need a definition used by your class that has default parameters such as:

def printMessage(name, msg = "My name is "):  
    print("Hello! ",msg + name)

printMessage("Jack")

Same thing applies to Classes.

Similar debate about "None" can be found here: Call function without optional arguments if they are None

Ancillary answered 19/6, 2019 at 10:26 Comment(1)
The class is defined with default string values. Those values will be used if the parameter is not passed in at all, however, when the client explicitly passes in a None value, the defaults don't get used. The tricky part about this question is that it is specifically about dataclasses not regular classes, see realpython.com/python-data-classes they have a much more succinct syntax, no need to define attributes as properties.Guido

© 2022 - 2024 — McMap. All rights reserved.