Optional arguments and Descriptor instance

Viewed 417

I'm using descriptors (docs here), so each value gets automatically converted to float.

from dataclasses import dataclass

class ToFloat:

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        return value

    def __set__(self, obj, value):
        setattr(obj, self.private_name, float(value))

@dataclass
class Coordinates:
   lon: float = ToFloat() 
   lat: float = ToFloat()
   val: float = ToFloat() 

c = Coordinates(lon=1 , lat=2, val=3)
# 1.0 2.0 3.0

However, how can I make these fields optional? (with or without @dataclass).

This does not work:

c = Coordinates()
# TypeError: __init__() missing 3 required positional arguments: lat, long, val
3 Answers

Here's a simple and straightforward solution that seems to work. I'm using PyCharm and also it doesn't seem to show any type warnings etc. when I leave out the field names to the __init__ method.

from dataclasses import dataclass, field


class ToFloat:

    def __init__(self, default_value=None):
        self.default = default_value

    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        return value

    def __set__(self, obj, value):
        if isinstance(value, ToFloat):
            setattr(obj, self.private_name, self.default)
        else:
            setattr(obj, self.private_name, float(value))


@dataclass
class Coordinates:
    lon: float = field(default=ToFloat(0.0))
    lat: float = field(default=ToFloat(0.1))
    val: float = field(default=ToFloat(0.2))


c = Coordinates(lon=1, lat=2, val=3)
print(c)
# Coordinates(lon=1.0, lat=2.0, val=3.0)

c = Coordinates()
print(c)
# Coordinates(lon=0.0, lat=0.1, val=0.2)

And if you want to simplify the logic under __set__ (which right now does an isinstance check each time), you can simply have it like this:

    def __set__(self, obj, value):
        setattr(obj, self.private_name, float(value))

    def __float__(self):
        return self.default

Easiest would to remove the type hints:

@dataclass
class Coordinates:
   lon = ToFloat() 
   lat = ToFloat()
   val = ToFloat()

   def __init__(self, *args, **kwargs):
        self.lon = kwargs.pop("lon", None)
        self.lat = kwargs.pop("lat", None)
        self.val = kwargs.pop("val", None)
        super().__init__(*args, **kwargs)

That would require some adaptiations to the descriptor though which will need to handle None or some other "default" value.

class ToFloat:
    # ...
    def __set__(self, obj, value):
        value = value if value is None else float(value)
        setattr(obj, self.private_name, value)

This, of course, has no more dataclass functionality unless you have other fields around.

You might want to consider another package, pydantic, which brings your needs pretty much ootb:

from pydantic import BaseModel

class Coordinates(BaseModel):
    lon: float = None
    lat: float = None
    val: float = None
 
c = Coordinates()
c.lon
# None
c = Coordinates(lon=5)
c.lon
# 5.0
c
# Coordinates(lon=5.0, lat=None, val=None)
Coordinates(lon=5) == Coordinates(lon="5")
# True

As has been stated, dataclasses and descriptors don't really play well together. The "classic" way of doing what you're trying to do would be to write something like this:

class ToFloat:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        value = getattr(obj, self.private_name)
        return value

    def __set__(self, obj, value):
        setattr(obj, self.private_name, float(value))


class Coordinates:
    lon = ToFloat() 
    lat = ToFloat()
    val = ToFloat()
    
    def __init__(self, lon, lat, val):
        self.lon = lon
        self.lat = lat
        self.val = val
    
c = Coordinates(lon=1 , lat=2, val=3)
# 1.0 2.0 3.0

If you want to add type hints, MyPy will understand what you're doing just fine as long as you add a return type to the __get__ method of ToFloat:

class ToFloat:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None) -> float:
        value = getattr(obj, self.private_name)
        return value

    def __set__(self, obj, value):
        setattr(obj, self.private_name, float(value))


class Coordinates:
    lon = ToFloat() 
    lat = ToFloat()
    val = ToFloat()
    
    def __init__(self, lon, lat, val):
        self.lon = lon
        self.lat = lat
        self.val = val
    
c = Coordinates(lon=1 , lat=2, val=3)
reveal_type(c.lon)
# Revealed type is float 

The disadvantage of writing classes this way, however, is that you have to define a fairly boilerplate __init__ method for any class you define like this. If you want dataclass-y __repr__, __eq__ and __hash__ methods, you'll have to define those as well.

One solution is to use a third-party library such as attrs or pydantic. However, there's also nothing to stop you writing your own decorator to reduce boilerplate:

from typing import get_type_hints, Generic, TypeVar
from abc import ABCMeta, abstractmethod

T = TypeVar('T')

class AbstractValidator(Generic[T], metaclass=ABCMeta):
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None) -> T:
        value = getattr(obj, self.private_name)
        return value

    @staticmethod
    @abstractmethod
    def validate(input_val) -> T: ...

    def __set__(self, obj, value):
        setattr(obj, self.private_name, self.validate(value))


def validated_dataclass(cls):
    fields = [k for k, v in cls.__dict__.items() if isinstance(v, AbstractValidator)]
    
    init = f'def __init__(self, {", ".join(fields)}):\n'
    init += '\n'.join(f'   self.{field} = {field}' for field in fields)
    namespace = {}
    exec(init, globals(), namespace)
    cls.__init__ = namespace['__init__']
    return cls
    

class ToFloat(AbstractValidator[float]):
    @staticmethod
    def validate(input_val) -> float:
        return float(input_val)


@validated_dataclass
class Coordinates:
    lon = ToFloat() 
    lat = ToFloat()
    val = ToFloat()
    

c = Coordinates(lon=1 , lat=2, val=3) # works fine

The advantage of doing it this way is that you know exactly how your code works, whereas there is a lot of magic going on in libraries like pydantic, some of which you may not need, but may slow your code down unnecessarily. The disadvantage is that it takes a little bit of work to set up — but once you've set it up, it's extremely extensible. It would be very easy to create ToStr or RaisesIfLessThan10 validators inheriting from AbstractValidator, if you wanted to. You could also add options to auto-generate __repr__/__eq__/__hash__ methods, like with dataclasses, if you like.

Another disadvantage is that this kind of dynamic code generation is impossible for Mypy to work out (dataclasses are special-cased by type-checkers). As it is, MyPy will raise an error telling you that Coordinates.__init__ received unexpected arguments. You can solve this by rewriting rewriting Coordinates like so — it reintroduces a little boilerplate, but it's still less boilerplate than the "classic" solution, and mypy now understands the signature of the auto-generated __init__ method that you're replacing the stub with in the validated_dataclass decorator.

@validated_dataclass
class Coordinates:
    def __init__(self, lon, lat, val) -> None: ...
    
    lon = ToFloat() 
    lat = ToFloat()
    val = ToFloat()
Related