Skip to content

Commit b6a55b0

Browse files
Merge pull request #9431 from OpenMined/koen/syft-migration-package
feat: add syft-migration package foundation
2 parents fa01218 + 3e2ff0f commit b6a55b0

15 files changed

Lines changed: 661 additions & 1 deletion

File tree

.github/workflows/unit-tests.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,26 @@ jobs:
156156
157157
- name: Run enclave model API tests
158158
run: just test-unit-enclave-model-api
159+
160+
migration-tests:
161+
runs-on: ubuntu-latest
162+
steps:
163+
- uses: actions/checkout@v4
164+
165+
- name: Install uv
166+
uses: astral-sh/setup-uv@v5
167+
168+
- name: Set up Python
169+
uses: actions/setup-python@v5
170+
with:
171+
python-version: '3.11'
172+
173+
- name: Install the project
174+
run: uv sync --all-extras
175+
176+
- name: Install just
177+
run: |
178+
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
179+
180+
- name: Run migration tests
181+
run: just test-unit-migration

Justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ test-unit-job:
3030
#!/bin/bash
3131
uv run pytest -v ./packages/syft-job/tests
3232

33+
test-unit-migration:
34+
#!/bin/bash
35+
uv run pytest -n auto ./packages/syft-migration/tests
36+
3337

3438
test-unit-enclave:
3539
#!/bin/bash

packages/syft-migration/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# syft-migration
2+
3+
Versioning and on-the-fly migration foundation for Syft objects.
4+
5+
Lets peers running different package versions exchange data by upgrading or downgrading
6+
serialized objects to a version the other side understands.
7+
8+
## Building blocks
9+
10+
- `MigratableObject` — base for any versioned object (`canonical_name` + `version`),
11+
auto-registers via `__init_subclass__`.
12+
- `PackageProtocolSchema` — the protocol surface of one release of one package
13+
(`protocol_name`, `package_name`, `package_version`, one version per object).
14+
- `MigrationRegistry` — per-package registry of all object versions, migration edges, and
15+
the current + historical protocol schemas.
16+
- `MigrationService` — upgrades/downgrades objects, including to the version a peer's
17+
package version supports.
18+
19+
## Dev
20+
21+
```bash
22+
uv pip install -e packages/syft-migration
23+
just test-unit-migration
24+
```
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[project]
2+
name = "syft-migration"
3+
version = "0.1.0"
4+
description = "Versioning and on-the-fly migration foundation for Syft objects"
5+
authors = [{ name = "OpenMined", email = "info@openmined.org" }]
6+
license = { text = "Apache-2.0" }
7+
requires-python = ">=3.10"
8+
9+
dependencies = [
10+
"pydantic>=2.11.7",
11+
]
12+
13+
[build-system]
14+
requires = ["hatchling"]
15+
build-backend = "hatchling.build"
16+
17+
[tool.hatch.build.targets.wheel]
18+
packages = ["src/syft_migration"]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from syft_migration.base import MigratableObject
2+
from syft_migration.registry import MigrationError, MigrationRegistry, default_registry
3+
from syft_migration.schema import PackageProtocolSchema
4+
from syft_migration.service import MigrationService
5+
6+
__version__ = "0.1.0"
7+
8+
__all__ = [
9+
"MigratableObject",
10+
"MigrationError",
11+
"MigrationRegistry",
12+
"MigrationService",
13+
"PackageProtocolSchema",
14+
"default_registry",
15+
"__version__",
16+
]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional
4+
5+
from pydantic import BaseModel
6+
7+
from syft_migration.registry import MigrationRegistry, default_registry
8+
9+
10+
class MigratableObject(BaseModel):
11+
"""Base class for any versioned object that can be migrated across versions.
12+
13+
Subclasses pin their identity by overriding the field defaults, e.g.::
14+
15+
class JobV2(MigratableObject):
16+
canonical_name: str = "job"
17+
version: str = "2"
18+
19+
``canonical_name`` is the stable logical name shared across all versions of the
20+
object; ``version`` is the schema version. Concrete subclasses auto-register into
21+
a :class:`MigrationRegistry` on definition. Pass ``registry=`` as a class keyword
22+
argument to register into a non-default registry (handy for test isolation).
23+
"""
24+
25+
canonical_name: str
26+
version: str
27+
28+
def __init_subclass__(
29+
cls, *, registry: Optional[MigrationRegistry] = None, **kwargs: object
30+
) -> None:
31+
# Capture the chosen registry here; defer registration to
32+
# __pydantic_init_subclass__ where model_fields is fully built.
33+
if registry is not None:
34+
cls.__migration_registry__ = registry
35+
super().__init_subclass__(**kwargs)
36+
37+
@classmethod
38+
def __pydantic_init_subclass__(cls, **kwargs: object) -> None:
39+
super().__pydantic_init_subclass__(**kwargs)
40+
registry: MigrationRegistry = getattr(
41+
cls, "__migration_registry__", default_registry
42+
)
43+
registry.register_object_version(cls)
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
from __future__ import annotations
2+
3+
from collections import deque
4+
from typing import TYPE_CHECKING, Callable, Optional
5+
6+
if TYPE_CHECKING:
7+
from syft_migration.base import MigratableObject
8+
from syft_migration.schema import PackageProtocolSchema
9+
10+
# A migration transforms one MigratableObject instance into another version.
11+
MigrationFn = Callable[["MigratableObject"], "MigratableObject"]
12+
13+
14+
class MigrationError(Exception):
15+
"""Raised when an object cannot be registered, located, or migrated."""
16+
17+
18+
def _has_identity(cls: type[MigratableObject]) -> bool:
19+
"""Whether ``cls`` pins both identity fields (i.e. is a concrete version).
20+
21+
The base class and abstract intermediates leave the fields required (no
22+
default), so they have no identity and are not registered.
23+
"""
24+
name_field = cls.model_fields.get("canonical_name")
25+
version_field = cls.model_fields.get("version")
26+
if name_field is None or version_field is None:
27+
return False
28+
return not (name_field.is_required() or version_field.is_required())
29+
30+
31+
def _identity(cls: type[MigratableObject]) -> tuple[str, str]:
32+
"""Return (canonical_name, version) for a concrete subclass.
33+
34+
Raises ``MigrationError`` if ``cls`` does not pin both fields (the base class
35+
and abstract intermediates leave them required, so they have no identity).
36+
"""
37+
if not _has_identity(cls):
38+
raise MigrationError(
39+
f"{cls.__name__} does not pin canonical_name/version and has no identity"
40+
)
41+
return (
42+
str(cls.model_fields["canonical_name"].default),
43+
str(cls.model_fields["version"].default),
44+
)
45+
46+
47+
class MigrationRegistry:
48+
"""All known object versions, migrations, and protocol schemas for ONE package."""
49+
50+
def __init__(self) -> None:
51+
# canonical_name -> {version: object_class}
52+
self.objects: dict[str, dict[str, type[MigratableObject]]] = {}
53+
# canonical_name -> {(from_version, to_version): migration_fn}
54+
self.migrations: dict[str, dict[tuple[str, str], MigrationFn]] = {}
55+
self.current_protocol_schema: Optional[PackageProtocolSchema] = None
56+
self.history_protocol_schemas: dict[str, PackageProtocolSchema] = {}
57+
58+
# -- objects -----------------------------------------------------------
59+
def register_object_version(self, cls: type[MigratableObject]) -> None:
60+
if not _has_identity(cls):
61+
return
62+
canonical_name, version = _identity(cls)
63+
existing = self.objects.get(canonical_name, {}).get(version)
64+
if existing is not None and existing is not cls:
65+
raise MigrationError(
66+
f"Object {(canonical_name, version)} already registered as "
67+
f"{existing.__name__}, cannot re-register as {cls.__name__}"
68+
)
69+
self.objects.setdefault(canonical_name, {})[version] = cls
70+
71+
def get_class(self, canonical_name: str, version: str) -> type[MigratableObject]:
72+
try:
73+
return self.objects[canonical_name][version]
74+
except KeyError:
75+
raise MigrationError(
76+
f"No object registered for {(canonical_name, version)}"
77+
)
78+
79+
def versions(self, canonical_name: str) -> list[str]:
80+
return list(self.objects.get(canonical_name, {}))
81+
82+
def latest_version(self, canonical_name: str) -> str:
83+
schema = self.current_protocol_schema
84+
if schema is not None and canonical_name in schema.object_versions:
85+
return schema.object_versions[canonical_name]
86+
versions = self.versions(canonical_name)
87+
if not versions:
88+
raise MigrationError(f"No versions registered for {canonical_name!r}")
89+
return max(versions)
90+
91+
# -- migrations --------------------------------------------------------
92+
def register_migration(
93+
self,
94+
canonical_name: str,
95+
from_version: str,
96+
to_version: str,
97+
fn: MigrationFn,
98+
) -> None:
99+
"""Register ``fn`` as the migration from ``from_version`` to ``to_version``."""
100+
edges = self.migrations.setdefault(canonical_name, {})
101+
edges[(from_version, to_version)] = fn
102+
103+
def migration(
104+
self, canonical_name: str, from_version: str, to_version: str
105+
) -> Callable[[MigrationFn], MigrationFn]:
106+
"""Decorator form of :meth:`register_migration` for named functions."""
107+
108+
def decorator(fn: MigrationFn) -> MigrationFn:
109+
self.register_migration(
110+
canonical_name=canonical_name,
111+
from_version=from_version,
112+
to_version=to_version,
113+
fn=fn,
114+
)
115+
return fn
116+
117+
return decorator
118+
119+
def migration_path(
120+
self, canonical_name: str, from_version: str, to_version: str
121+
) -> list[MigrationFn]:
122+
"""Return the migration functions to apply, in order, via BFS over edges."""
123+
if from_version == to_version:
124+
return []
125+
# all migrations for this class
126+
edges = self.migrations.get(canonical_name, {})
127+
# BFS queue of (current_version, path_to_current)
128+
queue: deque[tuple[str, list[MigrationFn]]] = deque([(from_version, [])])
129+
# seen versions
130+
seen = {from_version}
131+
while queue:
132+
current, path = queue.popleft()
133+
for (src, dst), func in edges.items():
134+
if src != current or dst in seen:
135+
continue
136+
next_path = [*path, func]
137+
if dst == to_version:
138+
return next_path
139+
seen.add(dst)
140+
queue.append((dst, next_path))
141+
raise MigrationError(
142+
f"No migration path for {canonical_name!r} from {from_version} to {to_version}"
143+
)
144+
145+
# -- protocol schemas --------------------------------------------------
146+
def register_protocol_schema(
147+
self, schema: PackageProtocolSchema, *, current: bool = True
148+
) -> None:
149+
"""Register a schema, keeping the object registry and schemas in sync.
150+
151+
Every object the schema pins must already be registered (objects auto-register
152+
when their class is defined); registering a schema that references an unknown
153+
object/version raises before the schema is stored.
154+
"""
155+
for canonical_name, version in schema.object_versions.items():
156+
self.get_class(canonical_name=canonical_name, version=version)
157+
self.history_protocol_schemas[schema.package_version] = schema
158+
if current:
159+
self.current_protocol_schema = schema
160+
161+
def schema_for_package_version(self, package_version: str) -> PackageProtocolSchema:
162+
if (
163+
self.current_protocol_schema is not None
164+
and self.current_protocol_schema.package_version == package_version
165+
):
166+
return self.current_protocol_schema
167+
try:
168+
return self.history_protocol_schemas[package_version]
169+
except KeyError:
170+
raise MigrationError(
171+
f"No protocol schema registered for package version {package_version!r}"
172+
)
173+
174+
175+
# Default per-import registry used by MigratableObject.__init_subclass__.
176+
default_registry = MigrationRegistry()
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
from typing import TYPE_CHECKING
6+
7+
from pydantic import BaseModel
8+
9+
from syft_migration.registry import MigrationError, _identity
10+
11+
if TYPE_CHECKING:
12+
from syft_migration.base import MigratableObject
13+
14+
PathLike = str | Path
15+
16+
17+
class PackageProtocolSchema(BaseModel):
18+
"""The protocol surface of one release of one package.
19+
20+
Pins exactly one ``version`` per object (``canonical_name``) that the package ships
21+
at ``package_version``. ``protocol_name`` is a hardcoded, language-agnostic
22+
identifier for the protocol and is intentionally distinct from ``package_name``.
23+
"""
24+
25+
protocol_name: str
26+
package_name: str
27+
package_version: str
28+
# canonical_name -> version
29+
object_versions: dict[str, str] = {}
30+
31+
@classmethod
32+
def from_objects(
33+
cls,
34+
protocol_name: str,
35+
package_name: str,
36+
package_version: str,
37+
classes: list[type[MigratableObject]],
38+
) -> PackageProtocolSchema:
39+
object_versions: dict[str, str] = {}
40+
for klass in classes:
41+
canonical_name, version = _identity(klass)
42+
if canonical_name in object_versions:
43+
raise MigrationError(
44+
f"Protocol schema may only pin one version per object, but "
45+
f"{canonical_name!r} was given twice"
46+
)
47+
object_versions[canonical_name] = version
48+
return cls(
49+
protocol_name=protocol_name,
50+
package_name=package_name,
51+
package_version=package_version,
52+
object_versions=object_versions,
53+
)
54+
55+
def save(self, path: PathLike) -> None:
56+
Path(path).write_text(self.model_dump_json(indent=2))
57+
58+
@classmethod
59+
def load(cls, path: PathLike) -> PackageProtocolSchema:
60+
return cls.model_validate(json.loads(Path(path).read_text()))

0 commit comments

Comments
 (0)