Source code for ctao_datamodel._versioning

from abc import ABC, abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, ClassVar

from ._core import ModelBase


@dataclass(frozen=True, order=True)
class SemVer:
    """
    Conversion and sorting of Semantic Versions.

    This is a very simple implementation to avoid having to rely on an external
    package like pydantic-semver.
    """

    major: int
    minor: int
    patch: int

    @classmethod
    def parse(cls, s: str):
        return cls(*(int(x) for x in s.split(".")))

    def __str__(self):
        return f"{self.major}.{self.minor}.{self.patch}"


@dataclass(frozen=True)
class MigrationInfo:
    """Stores info about a migration between two versions."""

    from_version: str
    to_version: str
    func: Callable

    def apply(self, migration):
        """Apply the migration."""
        self.func(migration)


[docs] class Migration(ABC): """Abstract class for migrations."""
[docs] @abstractmethod def add(self, path: str): """Add a new field or set of fields to the model.""" pass
[docs] @abstractmethod def rename(self, old: str, new: str): """Rename a field from old to new.""" pass
[docs] @abstractmethod def drop(self, path: str): """Drop field.""" pass
[docs] @abstractmethod def set_default(self, path: str, value): """Change a field's default.""" pass
[docs] @abstractmethod def update_metadata(self, path: str, metadata_field: str, old: Any, new: Any): """Change a field's metadata (e.g. fits_keyword) value.""" pass
[docs] @abstractmethod def apply_transform(self, path: str, func: Callable, description: str): """ Apply a more complex user-defined transformation. This can be used for migrations that require more than just a rename/drop of a field, e.g. merging multiple fields into one, or changing a field format. """
class DictMigration(Migration): """Implementation of Migration that modifies dicts.""" def __init__(self, data: dict): self.data = data def _parent(self, path) -> tuple[dict, str]: *parents, key = path.split(".") obj = self.data for p in parents: obj = obj[p] return obj, key def add(self, path: str): pass # no op needed for migration def rename(self, old, new): try: old_parent, old_key = self._parent(old) new_parent, new_key = self._parent(new) except KeyError: return if old_key in old_parent: new_parent[new_key] = old_parent.pop(old_key) def drop(self, path): try: parent, key = self._parent(path) except KeyError: return if key in parent: parent.pop(key) def set_default(self, path, value): try: parent, key = self._parent(path) except KeyError: return parent.setdefault(key, value) def update_metadata(self, path: str, metadata_field: str, old: Any, new: Any): pass # for now, the dict is assumed to be already translated from FITS, so no op is needed. def apply_transform(self, path: str, func: Callable, description: str): try: parent, key = self._parent(path) except KeyError: return func(obj=parent, key=key) class HistoryMigration(Migration): """Implementation of Migration that records migration history.""" def __init__(self): self.operations = [] def add(self, path: str): self.operations.append(f"Added '{path}'") def rename(self, old, new): self.operations.append(f"Renamed '{old}' to '{new}'") def drop(self, path): self.operations.append(f"Dropped '{path}'") def set_default(self, path, value): self.operations.append(f"Changed default value of '{path}' = '{value!r}'") def update_metadata(self, path: str, metadata_field: str, old: Any, new: Any): self.operations.append( f"Updated '{metadata_field}' metadata for '{path}' from '{old}' to '{new}'" ) def apply_transform(self, path: str, func: Callable, description: str): self.operations.append( f"Transformed '{path}' using {func.__name__} ({description})" ) class FITSHeaderMigration(Migration): """Migrations that are applied before FITS header conversion.""" def __init__(self, fits_header): self.header = fits_header def add(self, path: str): pass def rename(self, old, new): pass def drop(self, path): pass def set_default(self, path, value): pass def update_metadata(self, path: str, metadata_field: str, old: Any, new: Any): if metadata_field == "fits_keyword": if old in self.header: self.header.rename_keyword(old, new) def apply_transform(self, path: str, func: Callable, description: str): pass
[docs] def migration(from_version: str, to_version: str): """Migration decorator.""" def decorator(func): func.__migration__ = MigrationInfo( from_version=from_version, to_version=to_version, func=func, ) return staticmethod(func) return decorator
[docs] class VersionedModel(ModelBase): """Base class for ModelBases that are versioned.""" _migrations: ClassVar[list[MigrationInfo]] #: internal storage of migration info @classmethod def __pydantic_init_subclass__(cls, **kwargs): super().__pydantic_init_subclass__(**kwargs) cls._migrations = [ member.__func__.__migration__ for _, member in cls.__dict__.items() if isinstance(member, staticmethod) and hasattr(member.__func__, "__migration__") ]
[docs] @classmethod def model_validate_versioned(cls, data: dict): """ Like model_validate, but applies version migrations. Migrations are applied to the dict-like object to validate if needed, before passing to pydantic for final validation. """ migration = DictMigration(dict(data)) for info in cls._migrations: info.apply(migration) return cls.model_validate(migration.data)
[docs] @classmethod def migration_history(cls): """Return the version migration history for this model.""" history = [] for info in cls._migrations: recorder = HistoryMigration() info.apply(recorder) history.append( { "from": info.from_version, "to": info.to_version, "operations": recorder.operations, } ) return history
[docs] @classmethod def print_history(cls, printer=print): """Print a human-readable version of the history.""" printer(cls.__name__) printer("=" * len(cls.__name__)) history = cls.migration_history() for revision in history: printer(f"* {revision['from']} -> {revision['to']}") for op in revision["operations"]: printer(f" - {op}")