Skip to content

versioned

Version

Bases: Tuple[int, ...]

Class representing a version as a tuple of integers.

Source code in fancy_dataclass/versioned.py
class Version(Tuple[int, ...]):
    """Class representing a version as a tuple of integers."""

    def __new__(cls, version: AnyVersion) -> 'Version':
        """Constructs a `Version` from a single integer, tuple of integers, or `.`-separated version string."""
        err = ValueError(f'invalid version {version!r}')
        if isinstance(version, Version):
            return version
        if isinstance(version, int):
            parts = [version]
        elif isinstance(version, str):
            parts = []
            for part in version.strip().split('.'):
                try:
                    parts.append(int(part))
                except ValueError as e:
                    raise err from e
        elif isinstance(version, Sequence):
            parts = version  # type: ignore[assignment]
            if (not version) or any(not isinstance(part, int) for part in parts):
                raise err
        else:
            raise err
        return super().__new__(cls, tuple(parts))

    def __str__(self) -> str:
        return '.'.join(map(str, self))

__new__(version)

Constructs a Version from a single integer, tuple of integers, or .-separated version string.

Source code in fancy_dataclass/versioned.py
def __new__(cls, version: AnyVersion) -> 'Version':
    """Constructs a `Version` from a single integer, tuple of integers, or `.`-separated version string."""
    err = ValueError(f'invalid version {version!r}')
    if isinstance(version, Version):
        return version
    if isinstance(version, int):
        parts = [version]
    elif isinstance(version, str):
        parts = []
        for part in version.strip().split('.'):
            try:
                parts.append(int(part))
            except ValueError as e:
                raise err from e
    elif isinstance(version, Sequence):
        parts = version  # type: ignore[assignment]
        if (not version) or any(not isinstance(part, int) for part in parts):
            raise err
    else:
        raise err
    return super().__new__(cls, tuple(parts))

VersionedDataclass dataclass

Bases: DictDataclass

Mixin class that ensures a version integer is associated with a given dataclass type.

This enables reliable migration between different versions of the "same" class.

This class also inherits from [DictDataclass](fancy_dataclass.dict.DictDataclass], providing support for dict conversion, where by default the version field will be included in the dict representation.

Source code in fancy_dataclass/versioned.py
@dataclass
class VersionedDataclass(DictDataclass):
    """Mixin class that ensures a `version` integer is associated with a given `dataclass` type.

    This enables reliable migration between different versions of the "same" class.

    This class also inherits from [`DictDataclass`](fancy_dataclass.dict.DictDataclass], providing support for dict conversion, where by default the `version` field will be included in the dict representation."""
    __settings_type__ = VersionedDataclassSettings
    __settings__ = VersionedDataclassSettings()

    version: ClassVar[AnyVersion]

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(allow_duplicates=True, **kwargs)
        version: Optional[AnyVersion] = getattr(cls, 'version', cls.__settings__.version)
        if version is None:
            raise TypeError(f'must supply a valid version for class {cls.__name__!r}')
        try:
            version_tup = Version(version)
        except ValueError as e:
            raise TypeError(f'invalid version {version!r} for class {cls.__name__!r}') from e
        if 'version' not in cls.__dataclass_fields__:
            # if subclass gets generated dynamically, it might not have a 'version' ClassVar field, so create it
            cls.__dataclass_fields__['version'] = VersionedDataclass.__dataclass_fields__['version']
        cls.version = version
        _VERSIONED_DATACLASS_REGISTRY.register_class(cls, version_tup)

    @classmethod
    def _field_settings(cls, fld: Field) -> FieldSettings:  # type: ignore[type-arg]
        """Gets the class-specific FieldSettings extracted from the metadata stored on a Field object."""
        settings = cast(DictDataclassFieldSettings, super()._field_settings(fld))
        if (fld.name == 'version') and (cls.__settings__.suppress_version is False):
            # do not suppress version field if class-level suppress_version=False
            settings.suppress = False
        return settings

    def __setattr__(self, name: str, value: Any) -> None:
        # make 'version' field read-only
        if name == 'version':
            raise AttributeError("cannot assign to field 'version'")
        super().__setattr__(name, value)

    @classmethod
    def get_class_with_version(cls, version: Optional[AnyVersion] = None) -> Type['VersionedDataclass']:
        """Gets the corresponding class to this class with the given version.

        Args:
            version: Version of the class to retrieve from the global registry (if `None`, use the latest)"""
        return _VERSIONED_DATACLASS_REGISTRY.get_class(cls.__name__, version=version)

    def _to_dict(self, full: bool) -> AnyDict:
        d = super()._to_dict(full)
        if 'version' in d:  # ensure the 'version' key comes first in the ordering
            d = {'version': d.pop('version'), **d}
        return d

    @classmethod
    def dataclass_args_from_dict(cls, d: AnyDict) -> AnyDict:
        """Given a dict of arguments, performs type conversion and/or validity checking, then returns a new dict that can be passed to the class's constructor."""
        if 'version' in d:
            d = {key: val for (key, val) in d.items() if (key != 'version')}
        return super().dataclass_args_from_dict(d)

    @classmethod
    def _get_type_from_dict(cls, d: AnyDict) -> Type[Self]:
        cls = super()._get_type_from_dict(d)
        if (version := d.get('version')) is not None:
            # use the dict-specified version
            return cls.get_class_with_version(version=version)  # type: ignore[return-value]
        return cls

    def migrate(self, version: Optional[AnyVersion] = None) -> 'VersionedDataclass':
        """Migrates the object to a class corresponding to a (possibly different) version of this class, if possible.

        Args:
            version: Version of the class to migrate to (if `None`, use the latest)"""
        cls = self.get_class_with_version(version=version)
        if cls is type(self):
            return self
        kwargs = {}
        for fld in get_dataclass_fields(cls):
            if hasattr(self, fld.name):
                val = getattr(self, fld.name)
                if isinstance(val, VersionedDataclass) and issubclass_safe(fld.type, VersionedDataclass):  # type: ignore[arg-type]
                    val = val.migrate(fld.type.version)  # type: ignore[union-attr]
                kwargs[fld.name] = val
            elif (fld.default == dataclasses.MISSING) and (fld.default_factory == dataclasses.MISSING):
                raise MissingRequiredFieldError(fld.name)
        return cls(**kwargs)

    @classmethod
    def from_dict(cls, d: AnyDict, *, migrate: bool = False, **kwargs: Any) -> Self:
        """Constructs an object from a dictionary of fields.

        This may also perform some basic type/validity checking.

        Args:
            d: Dict to convert into an object
            migrate: If `True`, migrate to the calling class's version
            kwargs: Keyword arguments

        Returns:
            Converted object of this class"""
        obj = super().from_dict(d, **kwargs)
        if migrate:
            return cast(Self, obj.migrate(cls.version))
        return obj

dataclass_args_from_dict(d) classmethod

Given a dict of arguments, performs type conversion and/or validity checking, then returns a new dict that can be passed to the class's constructor.

Source code in fancy_dataclass/versioned.py
@classmethod
def dataclass_args_from_dict(cls, d: AnyDict) -> AnyDict:
    """Given a dict of arguments, performs type conversion and/or validity checking, then returns a new dict that can be passed to the class's constructor."""
    if 'version' in d:
        d = {key: val for (key, val) in d.items() if (key != 'version')}
    return super().dataclass_args_from_dict(d)

from_dict(d, *, migrate=False, **kwargs) classmethod

Constructs an object from a dictionary of fields.

This may also perform some basic type/validity checking.

Parameters:

Name Type Description Default
d AnyDict

Dict to convert into an object

required
migrate bool

If True, migrate to the calling class's version

False
kwargs Any

Keyword arguments

{}

Returns:

Type Description
Self

Converted object of this class

Source code in fancy_dataclass/versioned.py
@classmethod
def from_dict(cls, d: AnyDict, *, migrate: bool = False, **kwargs: Any) -> Self:
    """Constructs an object from a dictionary of fields.

    This may also perform some basic type/validity checking.

    Args:
        d: Dict to convert into an object
        migrate: If `True`, migrate to the calling class's version
        kwargs: Keyword arguments

    Returns:
        Converted object of this class"""
    obj = super().from_dict(d, **kwargs)
    if migrate:
        return cast(Self, obj.migrate(cls.version))
    return obj

get_class_with_version(version=None) classmethod

Gets the corresponding class to this class with the given version.

Parameters:

Name Type Description Default
version Optional[AnyVersion]

Version of the class to retrieve from the global registry (if None, use the latest)

None
Source code in fancy_dataclass/versioned.py
@classmethod
def get_class_with_version(cls, version: Optional[AnyVersion] = None) -> Type['VersionedDataclass']:
    """Gets the corresponding class to this class with the given version.

    Args:
        version: Version of the class to retrieve from the global registry (if `None`, use the latest)"""
    return _VERSIONED_DATACLASS_REGISTRY.get_class(cls.__name__, version=version)

migrate(version=None)

Migrates the object to a class corresponding to a (possibly different) version of this class, if possible.

Parameters:

Name Type Description Default
version Optional[AnyVersion]

Version of the class to migrate to (if None, use the latest)

None
Source code in fancy_dataclass/versioned.py
def migrate(self, version: Optional[AnyVersion] = None) -> 'VersionedDataclass':
    """Migrates the object to a class corresponding to a (possibly different) version of this class, if possible.

    Args:
        version: Version of the class to migrate to (if `None`, use the latest)"""
    cls = self.get_class_with_version(version=version)
    if cls is type(self):
        return self
    kwargs = {}
    for fld in get_dataclass_fields(cls):
        if hasattr(self, fld.name):
            val = getattr(self, fld.name)
            if isinstance(val, VersionedDataclass) and issubclass_safe(fld.type, VersionedDataclass):  # type: ignore[arg-type]
                val = val.migrate(fld.type.version)  # type: ignore[union-attr]
            kwargs[fld.name] = val
        elif (fld.default == dataclasses.MISSING) and (fld.default_factory == dataclasses.MISSING):
            raise MissingRequiredFieldError(fld.name)
    return cls(**kwargs)

VersionedDataclassSettings

Bases: DictDataclassSettings

Class-level settings for the VersionedDataclass mixin.

Subclasses of VersionedDataclass should set the version field to an integer, integer tuple, or .-separated string indicating the version.

Additionally they may set the following options as keyword arguments during inheritance:

  • suppress_version: suppress version field when converting to a dict
Source code in fancy_dataclass/versioned.py
@dataclass_kw_only()
class VersionedDataclassSettings(DictDataclassSettings):
    """Class-level settings for the [`VersionedDataclass`][fancy_dataclass.versioned.VersionedDataclass] mixin.

    Subclasses of `VersionedDataclass` should set the `version` field to an integer, integer tuple, or `.`-separated string indicating the version.

    Additionally they may set the following options as keyword arguments during inheritance:

    - `suppress_version`: suppress version field when converting to a dict"""
    version: Optional[AnyVersion] = None
    suppress_version: bool = False

version(version, suppress_version=False)

Decorator turning a regular dataclass into a VersionedDataclass.

Parameters:

Name Type Description Default
version AnyVersion

Version associated with the class (integer, integer tuple, or .-separated string)

required
suppress_version bool

Whether to suppress the version when converting to dict

False

Returns:

Type Description
Callable[[Type[T]], Type[T]]

Decorator to wrap a dataclass into a VersionedDataclass

Source code in fancy_dataclass/versioned.py
def version(version: AnyVersion, suppress_version: bool = False) -> Callable[[Type[T]], Type[T]]:
    """Decorator turning a regular dataclass into a [`VersionedDataclass`][fancy_dataclass.versioned.VersionedDataclass].

    Args:
        version: Version associated with the class (integer, integer tuple, or `.`-separated string)
        suppress_version: Whether to suppress the version when converting to dict

    Returns:
        Decorator to wrap a `dataclass` into a `VersionedDataclass`"""
    def _wrap_dataclass(tp: Type[T]) -> Type[T]:
        return VersionedDataclass.wrap_dataclass(tp, version=version, suppress_version=suppress_version)  # type: ignore[return-value]
    return _wrap_dataclass