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()