diff --git a/packages/syft-datasets/tests/migrations/unit/test_objects_registered.py b/packages/syft-datasets/tests/migrations/unit/test_objects_registered.py index bf7a12eb019..608846f8196 100644 --- a/packages/syft-datasets/tests/migrations/unit/test_objects_registered.py +++ b/packages/syft-datasets/tests/migrations/unit/test_objects_registered.py @@ -1,10 +1,5 @@ """Every versioned syft-dataset object is known to the package registry.""" -import importlib -import pkgutil - -from syft_migration import MigratableObject - import syft_datasets from syft_datasets.migrations import dataset_registry from syft_datasets.models import ( @@ -13,6 +8,7 @@ PrivateDatasetConfig, PrivateDatasetConfigV1, ) +from syft_migration import unregistered_objects, versioned_objects def test_versioned_objects_registered_and_aliased(): @@ -31,28 +27,8 @@ def test_versioned_objects_registered_and_aliased(): assert schema.current_schema(canonical_name="PrivateDatasetConfig") -def _all_subclasses(cls: type) -> set[type]: - subclasses = set(cls.__subclasses__()) - for sub in cls.__subclasses__(): - subclasses |= _all_subclasses(sub) - return subclasses - - def test_all_migratable_objects_in_package_are_registered(): - # Import every syft_datasets module so all MigratableObject subclasses are defined. - for module_info in pkgutil.walk_packages( - syft_datasets.__path__, prefix="syft_datasets." - ): - importlib.import_module(module_info.name) - - package_objects = [ - cls - for cls in _all_subclasses(MigratableObject) - if cls.__module__.startswith("syft_datasets.") - ] - assert len(package_objects) >= 2 # the scan actually found the dataset objects - - for cls in package_objects: - canonical_name = cls.model_fields["canonical_name"].default - version = cls.model_fields["version"].default - assert dataset_registry.get_class(canonical_name, version) is cls + # The scan imports every syft_datasets module, so it sees objects that + # nothing else imports. + assert len(versioned_objects(syft_datasets)) >= 2 + assert unregistered_objects(dataset_registry, syft_datasets) == [] diff --git a/packages/syft-datasets/tests/migrations/unit/test_upgrade_paths.py b/packages/syft-datasets/tests/migrations/unit/test_upgrade_paths.py index 06baccba6d0..e4d48fc98d6 100644 --- a/packages/syft-datasets/tests/migrations/unit/test_upgrade_paths.py +++ b/packages/syft-datasets/tests/migrations/unit/test_upgrade_paths.py @@ -1,28 +1,14 @@ """Every registered object version can migrate up to latest and down to any lower.""" from syft_datasets.migrations import dataset_registry +from syft_migration import missing_downgrade_paths, missing_upgrade_paths def test_every_version_has_upgrade_path_to_latest(): assert dataset_registry.objects # sanity: the registry is populated - - for canonical_name, versions in dataset_registry.objects.items(): - for version in versions: - assert dataset_registry.has_upgradeable_path_to_latest( - canonical_name=canonical_name, from_version=version - ), f"No upgrade path for {canonical_name!r} v{version} to latest" + assert missing_upgrade_paths(dataset_registry) == [] def test_every_version_has_downgrade_path_to_all_lower_versions(): assert dataset_registry.objects # sanity: the registry is populated - - for canonical_name, versions in dataset_registry.objects.items(): - for higher in versions: - for lower in versions: - if lower >= higher: - continue - assert dataset_registry.has_migration_path( - canonical_name=canonical_name, - from_version=higher, - to_version=lower, - ), f"No downgrade path for {canonical_name!r} v{higher} to v{lower}" + assert missing_downgrade_paths(dataset_registry) == [] diff --git a/packages/syft-job/tests/migrations/unit/test_objects_registered.py b/packages/syft-job/tests/migrations/unit/test_objects_registered.py index 15fd436ea0a..e77f070356e 100644 --- a/packages/syft-job/tests/migrations/unit/test_objects_registered.py +++ b/packages/syft-job/tests/migrations/unit/test_objects_registered.py @@ -1,10 +1,5 @@ """Every versioned syft-job object is known to the package registry.""" -import importlib -import pkgutil - -from syft_migration import MigratableObject - import syft_job from syft_job.migrations import job_registry from syft_job.models import ( @@ -13,6 +8,7 @@ JobSubmissionMetadata, JobSubmissionMetadataV1, ) +from syft_migration import unregistered_objects, versioned_objects def test_versioned_objects_registered_and_aliased(): @@ -31,26 +27,8 @@ def test_versioned_objects_registered_and_aliased(): assert schema.current_schema(canonical_name="JobSubmissionMetadata") -def _all_subclasses(cls: type) -> set[type]: - subclasses = set(cls.__subclasses__()) - for sub in cls.__subclasses__(): - subclasses |= _all_subclasses(sub) - return subclasses - - def test_all_migratable_objects_in_package_are_registered(): - # Import every syft_job module so all MigratableObject subclasses are defined. - for module_info in pkgutil.walk_packages(syft_job.__path__, prefix="syft_job."): - importlib.import_module(module_info.name) - - package_objects = [ - cls - for cls in _all_subclasses(MigratableObject) - if cls.__module__.startswith("syft_job.") - ] - assert len(package_objects) >= 2 # the scan actually found the job objects - - for cls in package_objects: - canonical_name = cls.model_fields["canonical_name"].default - version = cls.model_fields["version"].default - assert job_registry.get_class(canonical_name, version) is cls + # The scan imports every syft_job module, so it sees objects that nothing + # else imports. + assert len(versioned_objects(syft_job)) >= 2 + assert unregistered_objects(job_registry, syft_job) == [] diff --git a/packages/syft-job/tests/migrations/unit/test_upgrade_paths.py b/packages/syft-job/tests/migrations/unit/test_upgrade_paths.py index b3d72aa57d0..2a0f411023e 100644 --- a/packages/syft-job/tests/migrations/unit/test_upgrade_paths.py +++ b/packages/syft-job/tests/migrations/unit/test_upgrade_paths.py @@ -1,28 +1,14 @@ """Every registered object version can migrate up to latest and down to any lower.""" from syft_job.migrations import job_registry +from syft_migration import missing_downgrade_paths, missing_upgrade_paths def test_every_version_has_upgrade_path_to_latest(): assert job_registry.objects # sanity: the registry is populated - - for canonical_name, versions in job_registry.objects.items(): - for version in versions: - assert job_registry.has_upgradeable_path_to_latest( - canonical_name=canonical_name, from_version=version - ), f"No upgrade path for {canonical_name!r} v{version} to latest" + assert missing_upgrade_paths(job_registry) == [] def test_every_version_has_downgrade_path_to_all_lower_versions(): assert job_registry.objects # sanity: the registry is populated - - for canonical_name, versions in job_registry.objects.items(): - for higher in versions: - for lower in versions: - if lower >= higher: - continue - assert job_registry.has_migration_path( - canonical_name=canonical_name, - from_version=higher, - to_version=lower, - ), f"No downgrade path for {canonical_name!r} v{higher} to v{lower}" + assert missing_downgrade_paths(job_registry) == [] diff --git a/packages/syft-migration/README.md b/packages/syft-migration/README.md index 07998827f9e..569bea45022 100644 --- a/packages/syft-migration/README.md +++ b/packages/syft-migration/README.md @@ -15,6 +15,14 @@ serialized objects to a version the other side understands. the current + historical protocol schemas. - `MigrationService` — upgrades/downgrades objects, including to the version a peer's package version supports. +- `coverage` — checks a package runs against its own registry: `unregistered_objects` + finds a versioned object filed into another package's registry, and + `missing_upgrade_paths` / `missing_downgrade_paths` find a version that cannot reach + latest or cannot reach a lower version. Each returns findings, so empty means covered. + +## Docs + +- [Object Versions](docs/object-versions.md) — how to add a version to a versioned object, and which migrations the new version needs. ## Dev diff --git a/packages/syft-migration/docs/object-versions.md b/packages/syft-migration/docs/object-versions.md new file mode 100644 index 00000000000..ee22e661813 --- /dev/null +++ b/packages/syft-migration/docs/object-versions.md @@ -0,0 +1,172 @@ +# Add a Version to a Versioned Object + +## Overview + +A versioned object is a pydantic model. A peer reads it from disk, or receives it over the network. Each version of the object is a separate class. + +`syft-migration` holds every version and every migration between the versions. Therefore the new release reads a file or a message that a different release wrote. + +Every path in this document is relative to the root of the repository. + +Three packages hold versioned objects. Each package has its own registry. + +| Package | Registry | Import from | +| -------------- | ------------------ | -------------------------- | +| `syft` | `client_registry` | `syft.migrations.registry` | +| `syft-job` | `job_registry` | `syft_job.migrations` | +| `syft-dataset` | `dataset_registry` | `syft_datasets.migrations` | + +An object version is an incrementing integer held as a string: `"1"`, `"2"`, `"3"`. It is not a semver. It has no relation to the package version. + +## When to add a new version + +A release freezes an object version forever. Each release artifact stores the JSON schema of the object versions of that release. `find_schema_drift()` compares every live class against every frozen schema. A change to a released class is a defect. Add a new version instead. + +These changes need a new version: + +- add a field, remove a field, or rename a field +- change the type of a field +- change the default value of a field +- change the `description` or the `title` of a field +- change the docstring of the class + +These changes do not need a new version: + +- add or change a method, a classmethod, or a property +- change a comment + +> [!WARNING] +> +> The docstring rule is easy to miss. Pydantic copies the docstring of the class +> into `model_json_schema()` as `description`. It copies the `description` and +> the `title` of each field there too. Therefore all of that prose is part of +> the frozen schema. `protocol-0.json` holds the frozen docstrings of +> `VersionInfoV1`, `ProposedFileChangesMessageV1`, and +> `FileChangeEventsMessageV1`. A correction to one of those docstrings fails +> `tests/migrations/unit/test_history_artifacts.py`. Put new prose in a comment, +> or in the new version. + +## Where the files go + +`syft-job` and `syft-dataset` keep one file for each version. The package `__init__.py` holds the current-version alias. + +``` +models/dataset/ +├── __init__.py # Dataset = DatasetV1 +├── v1.py # class DatasetV1 +└── v2.py # class DatasetV2, and the migrations between v1 and v2 +``` + +The `syft` package keeps `VersionInfo` in one file, `syft/sync/version/version_info.py`. That file holds every version, both migrations, and the alias. Either layout is correct. Keep both migrations next to the new class, because the two edges change together. + +## Steps + +1. **Add the class.** Subclass the previous version, then pin the new `version` as a field default. The new class inherits the registry of the previous version. Do not pass `registry=` again. +2. **Register a migration in both directions.** Use `@registry.migration(canonical_name, from_version, to_version)`. +3. **Set the current-version alias to the new class.** Callers then always hold the latest version. +4. **Add a test fixture for the new version.** `syft-job` and `syft-dataset` need `packages//tests/migrations/unit/fixtures//v.yaml`. +5. **Check the protocol version constant.** The section below gives the rule. +6. **Run the test suite of the package that holds the object.** Then run the other three suites. + +## Worked example + +`VersionInfo` is the only object in the repository with two versions. V2 adds a field. Therefore the upgrade migration sets a default value for the field, and the downgrade migration removes it. + +`VersionInfo` is also a special case. A peer reads `SYFT_version.json` to learn the protocol that this client speaks. Every supported client must parse every newer version of that file. A new version of `VersionInfo` can therefore add an optional field only. + +Other objects have no such limit. `DatasetV2` can add a required field, because the upgrade migration builds the object and gives the field a value. + +```python +class VersionInfoV2(VersionInfoV1): + """V2 adds the protocol schemas this client speaks (client, job, dataset).""" + + version: str = "2" + + protocol_schemas: dict[str, ProtocolSchema] = Field(default_factory=dict) + + +@client_registry.migration("VersionInfo", "1", "2") +def _version_info_v1_to_v2(obj: VersionInfoV1) -> VersionInfoV2: + # A v1 file says nothing about package protocols: empty schemas, meaning + # "unknown speaker" to consumers. + return VersionInfoV2.model_validate( + obj.model_dump(exclude={"canonical_name", "version"}) + ) + + +@client_registry.migration("VersionInfo", "2", "1") +def _version_info_v2_to_v1(obj: VersionInfoV2) -> VersionInfoV1: + return VersionInfoV1.model_validate( + obj.model_dump(exclude={"canonical_name", "version", "protocol_schemas"}) + ) + + +# Current-version alias: callers always work with the latest VersionInfo. +VersionInfo = VersionInfoV2 +``` + +Both migrations exclude `canonical_name` and `version` from the dump. Each class pins its own identity as a field default. If a migration passes the old pair, the new object gets the wrong version. The downgrade migration also excludes the field that V1 does not have. + +## Register both directions + +`migration_path()` is a breadth-first search over the registered edges. It never infers an inverse. If a downgrade edge is absent, this release cannot serve a peer that reads the lower version. + +A migration for every pair of versions is not necessary. A path through an intermediate version is enough. Version 3 migrates to version 1 through version 2, with no 3-to-1 edge. + +Order versions with the integer key of the registry. Do not use a string sort, because a string sort puts `"10"` before `"2"`. + +## The protocol version constant + +The protocol version names the layout on disk and on the network. A new object version changes `supported_versions` in the registry. Therefore the protocol version constant must be above the newest released protocol. + +- The current protocol is not yet released. The constant is already above the newest released protocol, so a new object version needs no bump. +- The current protocol N is released. The next object version needs protocol N+1, so bump the constant. + +| Package | Constant | File | +| -------------- | ------------------------------ | ----------------------------------------------------------------- | +| `syft` | `SYFT_CLIENT_PROTOCOL_VERSION` | `syft/migrations/registry.py` | +| `syft-job` | `JOB_PROTOCOL_VERSION` | `packages/syft-job/src/syft_job/migrations/registry.py` | +| `syft-dataset` | `DATASET_PROTOCOL_VERSION` | `packages/syft-datasets/src/syft_datasets/migrations/registry.py` | + +Two checks find a mistake: + +- `protocol_bump_missing()` — the protocol changed after the newest released protocol, but the constant is the same. +- `protocol_changed_without_bump()` — the protocol changed against the released artifact for the current constant. + +Each package runs both checks in its own export script, and the script then stops the release: + +- `scripts/export_release_artifact.py` for `syft` +- `packages/syft-job/scripts/export_release_artifact.py` for `syft-job` +- `packages/syft-datasets/scripts/export_release_artifact.py` for `syft-dataset` + +Both checks compare object versions only. A change to the layout that adds no object version is invisible to both checks. Therefore bump the constant by hand for a path change or a folder rename. + +Do not raise `min_supported_protocol_version`. It is the oldest protocol that the package still reads. A higher value removes support for every peer below it. + +## What the tests check + +Each package runs the same checks against its own registry. + +- `test_objects_registered.py` — every versioned object of the package is in the registry of that package. This check finds a class that is in the registry of a different package. +- `test_upgrade_paths.py` — every registered version reaches the latest version and every lower version. A migration edge that is absent fails here, and not in production. +- `test_history_artifacts.py` — no released object version drifted from its frozen schema. + +`syft-job` and `syft-dataset` also run `test_migrations.py`. It loads the fixture of every registered version and upgrades it to the latest version. It also downgrades the latest version to every registered version. A new version with no fixture fails this test. + +```bash +just test-unit-migration # syft-migration +just test-client-migrations # syft (tests/migrations) +just test-unit-job # syft-job +just test-unit-datasets # syft-dataset +``` + +## Checklist + +- [ ] The new class subclasses the previous version and pins the new `version`. +- [ ] A migration exists in both directions. +- [ ] The upgrade migration sets every new field, and the downgrade migration removes it. +- [ ] The current-version alias refers to the new class. +- [ ] A fixture exists for the new version, for `syft-job` and `syft-dataset`. +- [ ] The protocol version constant is above the newest released protocol. +- [ ] No released class changed. A class docstring and a field `description` are part of the schema. +- [ ] All four test suites pass. diff --git a/packages/syft-migration/src/syft_migration/__init__.py b/packages/syft-migration/src/syft_migration/__init__.py index 3d67d530b6f..7ef27add58f 100644 --- a/packages/syft-migration/src/syft_migration/__init__.py +++ b/packages/syft-migration/src/syft_migration/__init__.py @@ -1,4 +1,11 @@ from syft_migration.base import MigratableObject +from syft_migration.coverage import ( + import_all_modules, + missing_downgrade_paths, + missing_upgrade_paths, + unregistered_objects, + versioned_objects, +) from syft_migration.identity import MigrationError from syft_migration.registry import MigrationRegistry from syft_migration.schema import ( @@ -21,4 +28,9 @@ "ReleasedPackageProtocolInfo", "ReleasedProtocol", "__version__", + "import_all_modules", + "missing_downgrade_paths", + "missing_upgrade_paths", + "unregistered_objects", + "versioned_objects", ] diff --git a/packages/syft-migration/src/syft_migration/coverage.py b/packages/syft-migration/src/syft_migration/coverage.py new file mode 100644 index 00000000000..2794c0c6002 --- /dev/null +++ b/packages/syft-migration/src/syft_migration/coverage.py @@ -0,0 +1,113 @@ +"""Migration coverage checks a package runs against its own registry. + +Two questions, asked the same way in every package: is every versioned object the +package defines registered in that package's registry, and can every registered +version reach every other version it has to reach. + +Each check returns its findings instead of asserting, so a caller reports them +all at once. An empty list means the package is covered. +""" + +from __future__ import annotations + +import importlib +import pkgutil +from types import ModuleType + +from syft_migration.base import MigratableObject +from syft_migration.identity import _has_identity, _identity, _version_order +from syft_migration.registry import MigrationRegistry + + +def _all_subclasses(cls: type) -> set[type]: + subclasses = set(cls.__subclasses__()) + for sub in cls.__subclasses__(): + subclasses |= _all_subclasses(sub) + return subclasses + + +def import_all_modules(package: ModuleType) -> None: + """Import every submodule of ``package``. + + A versioned object registers when its class body runs, so a module nothing + imports holds objects the registry has never seen. + """ + for module_info in pkgutil.walk_packages( + package.__path__, prefix=f"{package.__name__}." + ): + importlib.import_module(module_info.name) + + +def versioned_objects(package: ModuleType) -> list[type[MigratableObject]]: + """Every concrete versioned object defined in ``package``, which it imports first. + + Abstract intermediates leave the identity fields required and are never + registered, so they are not versioned objects and do not appear here. + """ + import_all_modules(package) + + def defined_here(cls: type) -> bool: + # The package's own __init__ module is named without the trailing dot, + # so a class declared there needs the equality arm to be seen at all. + return cls.__module__ == package.__name__ or cls.__module__.startswith( + f"{package.__name__}." + ) + + return sorted( + ( + cls + for cls in _all_subclasses(MigratableObject) + if defined_here(cls) and _has_identity(cls) + ), + key=_identity, + ) + + +def unregistered_objects( + registry: MigrationRegistry, package: ModuleType +) -> list[type[MigratableObject]]: + """Versioned objects in ``package`` that ``registry`` does not hold as themselves. + + ``MigratableObject`` already refuses a versioned class with no registry at + all, so what is left to catch is a class registered into another package's + registry: ``registry=`` is explicit, and a subclass inherits whichever one + its parent named. + """ + missing = [] + for cls in versioned_objects(package): + canonical_name, version = _identity(cls) + if registry.objects.get(canonical_name, {}).get(version) is not cls: + missing.append(cls) + return missing + + +def missing_upgrade_paths(registry: MigrationRegistry) -> list[tuple[str, str]]: + """``(canonical_name, version)`` that cannot migrate up to the latest version.""" + return [ + (canonical_name, version) + for canonical_name, versions in registry.objects.items() + for version in versions + if not registry.has_upgradeable_path_to_latest( + canonical_name=canonical_name, from_version=version + ) + ] + + +def missing_downgrade_paths(registry: MigrationRegistry) -> list[tuple[str, str, str]]: + """``(canonical_name, from_version, to_version)`` with no downgrade between them. + + Versions order by the same integer key the registry uses, so version 10 sits + above version 2 here even though a string sort puts it below. + """ + missing = [] + for canonical_name, versions in registry.objects.items(): + ordered = sorted(versions, key=_version_order) + for position, higher in enumerate(ordered): + for lower in ordered[:position]: + if not registry.has_migration_path( + canonical_name=canonical_name, + from_version=higher, + to_version=lower, + ): + missing.append((canonical_name, higher, lower)) + return missing diff --git a/packages/syft-migration/tests/covpkg/__init__.py b/packages/syft-migration/tests/covpkg/__init__.py new file mode 100644 index 00000000000..38665d4bb93 --- /dev/null +++ b/packages/syft-migration/tests/covpkg/__init__.py @@ -0,0 +1,28 @@ +"""A throwaway package the coverage checks run against. + +Holds one object that is registered correctly, one declared in this ``__init__`` +module (which the module-prefix scan must still see), and one registered into a +second registry, standing in for an object filed under the wrong package. +""" + +from syft_migration import MigratableObject, MigrationRegistry + +covered_registry = MigrationRegistry( + protocol_name="covpkg-proto", + package_name="covpkg", + package_version="1.0.0", + protocol_version="1", +) + +# A second registry in the same process, standing in for another package's. +other_registry = MigrationRegistry( + protocol_name="other-proto", + package_name="other", + package_version="1.0.0", + protocol_version="1", +) + + +class DeclaredInInitV1(MigratableObject, registry=covered_registry): + canonical_name: str = "DeclaredInInit" + version: str = "1" diff --git a/packages/syft-migration/tests/covpkg/objects.py b/packages/syft-migration/tests/covpkg/objects.py new file mode 100644 index 00000000000..02d7abf3e46 --- /dev/null +++ b/packages/syft-migration/tests/covpkg/objects.py @@ -0,0 +1,41 @@ +from syft_migration import MigratableObject + +from . import covered_registry, other_registry + + +class ThingV1(MigratableObject, registry=covered_registry): + canonical_name: str = "Thing" + version: str = "1" + name: str = "" + + +class ThingV2(MigratableObject, registry=covered_registry): + canonical_name: str = "Thing" + version: str = "2" + name: str = "" + owner: str = "" + + +class AbstractThing(MigratableObject): + """Leaves the identity fields required, so it is not a versioned object.""" + + name: str = "" + + +class FiledElsewhereV1(MigratableObject, registry=other_registry): + canonical_name: str = "FiledElsewhere" + version: str = "1" + + +covered_registry.register_migration( + canonical_name="Thing", + from_version="1", + to_version="2", + fn=lambda obj: ThingV2(name=obj.name), +) +covered_registry.register_migration( + canonical_name="Thing", + from_version="2", + to_version="1", + fn=lambda obj: ThingV1(name=obj.name), +) diff --git a/packages/syft-migration/tests/test_coverage.py b/packages/syft-migration/tests/test_coverage.py new file mode 100644 index 00000000000..90e3332a52f --- /dev/null +++ b/packages/syft-migration/tests/test_coverage.py @@ -0,0 +1,155 @@ +"""The coverage checks find an unregistered object and a missing migration. + +``covpkg`` is a throwaway package next to these tests holding the shapes each +check has to catch. Every check returns findings, so an empty list is a pass. +""" + +import covpkg +from covpkg import covered_registry, other_registry +from covpkg.objects import AbstractThing, FiledElsewhereV1, ThingV1, ThingV2 + +from syft_migration import ( + MigratableObject, + MigrationRegistry, + missing_downgrade_paths, + missing_upgrade_paths, + unregistered_objects, + versioned_objects, +) + + +def _registry(protocol_version: str = "1") -> MigrationRegistry: + return MigrationRegistry( + protocol_name="p", + package_name="pkg", + package_version="1.0.0", + protocol_version=protocol_version, + ) + + +# -- which classes count as versioned objects -------------------------------- +def test_a_versioned_object_declared_in_the_package_init_is_scanned(): + # Its module is "covpkg", with no trailing dot, so a prefix-only scan misses it. + names = {cls.__name__ for cls in versioned_objects(covpkg)} + assert "DeclaredInInitV1" in names + + +def test_an_abstract_intermediate_is_not_a_versioned_object(): + # It leaves canonical_name/version required, so it is never registered and + # asking for its identity would raise. + assert AbstractThing not in versioned_objects(covpkg) + + +def test_the_scan_finds_the_objects_of_the_package_it_is_given(): + found = versioned_objects(covpkg) + assert {ThingV1, ThingV2, FiledElsewhereV1} <= set(found) + + +# -- every versioned object is registered ------------------------------------ +def test_an_object_registered_into_another_packages_registry_is_reported(): + # FiledElsewhereV1 lives in covpkg but named other_registry, which the base + # class permits; only this check catches it. + assert unregistered_objects(covered_registry, covpkg) == [FiledElsewhereV1] + + +def test_the_registry_that_does_hold_the_object_does_not_report_it(): + assert FiledElsewhereV1 not in unregistered_objects(other_registry, covpkg) + + +# -- every versioned object has migrations ----------------------------------- +def test_a_registry_with_every_migration_reports_no_missing_paths(): + # covered_registry holds Thing 1<->2 both ways and a single-version object. + assert missing_upgrade_paths(covered_registry) == [] + assert missing_downgrade_paths(covered_registry) == [] + + +def test_a_missing_upgrade_to_latest_is_reported(): + registry = _registry() + + # Each class body registers itself; no migration is registered between them. + class GapV1(MigratableObject, registry=registry): + canonical_name: str = "Gap" + version: str = "1" + + class GapV2(MigratableObject, registry=registry): + canonical_name: str = "Gap" + version: str = "2" + + assert missing_upgrade_paths(registry) == [("Gap", "1")] + + +def test_a_missing_downgrade_is_reported(): + registry = _registry() + + # Each class body registers itself. + class StepV1(MigratableObject, registry=registry): + canonical_name: str = "Step" + version: str = "1" + + class StepV2(MigratableObject, registry=registry): + canonical_name: str = "Step" + version: str = "2" + + registry.register_migration( + canonical_name="Step", from_version="1", to_version="2", fn=lambda obj: StepV2() + ) + + assert missing_upgrade_paths(registry) == [] + assert missing_downgrade_paths(registry) == [("Step", "2", "1")] + + +def test_a_downgrade_reached_through_an_intermediate_version_counts(): + registry = _registry() + + class ChainV1(MigratableObject, registry=registry): + canonical_name: str = "Chain" + version: str = "1" + + class ChainV2(MigratableObject, registry=registry): + canonical_name: str = "Chain" + version: str = "2" + + class ChainV3(MigratableObject, registry=registry): + canonical_name: str = "Chain" + version: str = "3" + + for lower, higher, made in ((1, 2, ChainV2), (2, 3, ChainV3)): + registry.register_migration( + canonical_name="Chain", + from_version=str(lower), + to_version=str(higher), + fn=lambda obj, made=made: made(), + ) + for higher, lower, made in ((3, 2, ChainV2), (2, 1, ChainV1)): + registry.register_migration( + canonical_name="Chain", + from_version=str(higher), + to_version=str(lower), + fn=lambda obj, made=made: made(), + ) + + # 3 -> 1 is never registered; it is reached through version 2. + assert missing_downgrade_paths(registry) == [] + + +def test_version_ten_is_ordered_above_version_two(): + """A string sort puts "10" below "2" and would check the wrong direction.""" + registry = _registry() + + # Each class body registers itself. + class BigV2(MigratableObject, registry=registry): + canonical_name: str = "Big" + version: str = "2" + + class BigV10(MigratableObject, registry=registry): + canonical_name: str = "Big" + version: str = "10" + + registry.register_migration( + canonical_name="Big", from_version="2", to_version="10", fn=lambda obj: BigV10() + ) + + # Only the upgrade exists, so the missing downgrade is 10 -> 2. Ordering the + # versions as strings would instead ask for 2 -> 10 and find it. + assert missing_upgrade_paths(registry) == [] + assert missing_downgrade_paths(registry) == [("Big", "10", "2")] diff --git a/tests/migrations/unit/test_objects_registered.py b/tests/migrations/unit/test_objects_registered.py new file mode 100644 index 00000000000..7114fb0d883 --- /dev/null +++ b/tests/migrations/unit/test_objects_registered.py @@ -0,0 +1,42 @@ +"""Every versioned syft object is known to the client registry.""" + +import syft +from syft.migrations.registry import client_registry +from syft.sync.events.file_change_event import ( + FileChangeEventsMessage, + FileChangeEventsMessageV1, +) +from syft.sync.messages.proposed_filechange import ( + ProposedFileChangesMessage, + ProposedFileChangesMessageV1, +) +from syft.sync.version.version_info import VersionInfo, VersionInfoV2 +from syft_migration import unregistered_objects, versioned_objects + +OBJECTS = {"VersionInfo", "FileChangeEventsMessage", "ProposedFileChangesMessage"} + + +def test_versioned_objects_registered_and_aliased(): + # Every object has at least one version registered in the client registry. + for canonical_name in OBJECTS: + assert client_registry.versions(canonical_name) + + # The current-version aliases resolve to the latest class of each object. + # VersionInfo is the one that has moved past V1. + assert VersionInfo is VersionInfoV2 + assert FileChangeEventsMessage is FileChangeEventsMessageV1 + assert ProposedFileChangesMessage is ProposedFileChangesMessageV1 + + # The protocol schema covers every object and resolves a current version. + schema = client_registry.compute_protocol_schema() + assert OBJECTS <= set(schema.supported_versions) + for canonical_name in OBJECTS: + assert schema.current_schema(canonical_name=canonical_name) + + +def test_all_migratable_objects_in_package_are_registered(): + # The scan imports every syft module, so it sees objects that nothing else + # imports. It also catches an object filed into syft-job's or + # syft-dataset's registry instead of this one. + assert len(versioned_objects(syft)) >= len(OBJECTS) + assert unregistered_objects(client_registry, syft) == [] diff --git a/tests/migrations/unit/test_upgrade_paths.py b/tests/migrations/unit/test_upgrade_paths.py new file mode 100644 index 00000000000..44d4d64f6c8 --- /dev/null +++ b/tests/migrations/unit/test_upgrade_paths.py @@ -0,0 +1,14 @@ +"""Every registered object version can migrate up to latest and down to any lower.""" + +from syft.migrations.registry import client_registry +from syft_migration import missing_downgrade_paths, missing_upgrade_paths + + +def test_every_version_has_upgrade_path_to_latest(): + assert client_registry.objects # sanity: the registry is populated + assert missing_upgrade_paths(client_registry) == [] + + +def test_every_version_has_downgrade_path_to_all_lower_versions(): + assert client_registry.objects # sanity: the registry is populated + assert missing_downgrade_paths(client_registry) == []