Repository: glotzerlab/signac
Pull request #654 (2021-12-11 19:20:08)

Migrate using project directories instead of project instances
<!-- Provide a general summary of your changes in the Title above -->

## Description
This PR changes the schema migration code to operate in terms of a project's root directory rather than a project class instance. Additionally, loading and checking the schema version of a project is done using a cascade of multiple schema loaders that support different schemas.

## Motivation and Context
<!-- Why is this change required? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here. -->
The current schema migration logic makes some easily invalidated assumptions about what operations are valid in the course of a migration. The two most problematic assumptions are that 1) `config.load_config` can load schemas of any schema version, and 2) a valid `Project` can be created from any schema version by simply skipping the schema compatibility check. The former is invalidated by, for instance, a change in the format of the config file like the one planned for schema version 2. The latter would be invalidated by any change to the `Project` constructor that assumes the existence of certain keys in the config. The migration to schema version 2 does not immediately break this logic since we are _removing_ project ids, not adding them (see #643 and #644), but there is no reason to assume that we will not make changes in the opposite direction in future schema versions. This PR protects us against such changes.

## Types of Changes
<!-- Please select all items that apply either now or after creating the pull request: -->
- [ ] Documentation update
- [ ] Bug fix
- [x] New feature
- [ ] Breaking change<sup>1</sup>

<sup>1</sup>The change breaks (or has the potential to break) existing functionality.

## Checklist:
<!-- Please select all items that apply either now or after creating the pull request. -->
<!-- If you are unsure about any of these items, do not hesitate to ask! -->
- [x] I am familiar with the [**Contributing Guidelines**](https://github.com/glotzerlab/signac/blob/master/CONTRIBUTING.md).
- [x] I agree with the terms of the [**Contributor Agreement**](https://github.com/glotzerlab/signac/blob/master/ContributorAgreement.md).
- [x] My name is on the [list of contributors](https://github.com/glotzerlab/signac/blob/master/contributors.yaml).
- [x] My code follows the [code style guideline](https://github.com/glotzerlab/signac/blob/master/CONTRIBUTING.md#code-style) of this project.
- [x] The changes introduced by this pull request are covered by existing or newly introduced tests.

If necessary:
- [x] I have updated the API documentation as part of the package doc-strings.
- [ ] I have created a separate pull request to update the [framework documentation](https://docs.signac.io/) on [signac-docs](https://github.com/glotzerlab/signac-docs) and linked it here.
- [x] I have updated the [changelog](https://github.com/glotzerlab/signac/blob/master/changelog.txt) and added all related issue and pull request numbers for future reference (if applicable). See example below.

<!-- Example for a changelog entry: `Fix issue with launching rockets to the moon (#101, #212).` -->


vyasr: @csadorf @jennyfothergill apologies in advance if the quality isn’t up to my usual standard. It’s been hard to dedicate a significant block of time, so I’ve been working on this in very brief fits and starts and I figured at this point it would be better to just get feedback on what I had rather than wait longer.
codecov[bot]: # [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/654?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) Report
> Merging [#654](https://codecov.io/gh/glotzerlab/signac/pull/654?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) (822aa46) into [master](https://codecov.io/gh/glotzerlab/signac/commit/0658c425ffdb1b5889e91ebb18c403c0e210b8a5?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) (0658c42) will **decrease** coverage by `0.19%`.
> The diff coverage is `62.66%`.

[![Impacted file tree graph](https://codecov.io/gh/glotzerlab/signac/pull/654/graphs/tree.svg?width=650&height=150&src=pr&token=FghbjTyzgM&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab)](https://codecov.io/gh/glotzerlab/signac/pull/654?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab)

```diff
@@            Coverage Diff             @@
##           master     #654      +/-   ##
==========================================
- Coverage   78.31%   78.12%   -0.20%     
==========================================
  Files          65       65              
  Lines        7079     7107      +28     
  Branches     1432     1434       +2     
==========================================
+ Hits         5544     5552       +8     
- Misses       1227     1246      +19     
- Partials      308      309       +1     
```


| [Impacted Files](https://codecov.io/gh/glotzerlab/signac/pull/654?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab) | Coverage Δ | |
|---|---|---|
| [signac/\_\_main\_\_.py](https://codecov.io/gh/glotzerlab/signac/pull/654/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL19fbWFpbl9fLnB5) | `71.89% <ø> (ø)` | |
| [signac/contrib/migration/v0\_to\_v1.py](https://codecov.io/gh/glotzerlab/signac/pull/654/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL2NvbnRyaWIvbWlncmF0aW9uL3YwX3RvX3YxLnB5) | `40.74% <38.46%> (-59.26%)` | :arrow_down: |
| [signac/contrib/migration/\_\_init\_\_.py](https://codecov.io/gh/glotzerlab/signac/pull/654/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL2NvbnRyaWIvbWlncmF0aW9uL19faW5pdF9fLnB5) | `77.41% <74.46%> (-5.04%)` | :arrow_down: |
| [signac/contrib/project.py](https://codecov.io/gh/glotzerlab/signac/pull/654/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab#diff-c2lnbmFjL2NvbnRyaWIvcHJvamVjdC5weQ==) | `85.13% <100.00%> (-0.02%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/glotzerlab/signac/pull/654?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/glotzerlab/signac/pull/654?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab). Last update [0658c42...822aa46](https://codecov.io/gh/glotzerlab/signac/pull/654?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=glotzerlab).

vyasr: > Codecov is complaining a lot about missing tests. Maybe we can restructure the existing tests to more systematically cover those additional cases?

Thanks for pointing this out. I've gone through the untested cases and improved test coverage where appropriate. The remaining untested cases fall into two buckets:
1. Various lines in `_get_config_schema_version` that will start getting triggered as soon as we have more than two versions and more than a single migration. I'm comfortable leaving these untested for now with the expectation that additional tests in  #643 will start covering these.
2. Two exception case in `apply_migrations` itself. The `FileNotFoundError` on the lock file I think is something we don't have to do anything about. We frequently leave those types of cases untested in signac since they're there to case subtle filesystem errors rather than logic errors. The other exception case is there to handle the scenario when a migration fails. This code is also IMO not worth testing until #643. If either of you can think of a reasonable scenario where we wouldn't fail in `_collect_migrations` (i.e. an invalid schema version) but where the v1->v2 migration could fail, I'm happy to add that to #643.

In general the lack of coverage just reflects that we've added migration logic before we had a need for it in anticipation of future use cases. I made these changes on master because they would have cluttered the PR actually introducing the v1->v2 migration and made them hard to review, and the lack of testability is the tradeoff. We should see that improve with #643 (and I'm sure we'll find additional bugs then, but at least the core logic in place now seems sound).

There is one other change of note that I introduced. While attempting to improve coverage, I realized that because `apply_migrations` was defined as a generator it was a no-op unless the result was iterated over, i.e. `apply_migrations(root)` does nothing unless it's wrapped with something like `list(apply_migrations(root))` or `for (origin, destination) in apply_migrations(root): ...`. That API doesn't make any sense to me, and being able to iterate over the intermediate states of migration is IMO only really useful for testing purposes, so I've removed the yield statement in `apply_migrations` so that it simply performs all migrations at once. Let me know if either of you foresee any issues with that or would prefer a different resolution.
vyasr: @csadorf let me know if you'd like to re-review this. It would be fine IMO to merge this, then work out any further kinks in the context of #643 since that will give us our first real migration to stress test this with anyway, but if you see other issues that you think are worth addressing in the context of this PR we can deal with them first.
csadorf: > @csadorf let me know if you'd like to re-review this. It would be fine IMO to merge this, then work out any further kinks in the context of #643 since that will give us our first real migration to stress test this with anyway, but if you see other issues that you think are worth addressing in the context of this PR we can deal with them first.

Thanks, I will review it right now.
vyasr: @csadorf I'm treating our discussion at the meeting as approval and I'm going to go ahead and merge this PR now.

Patch:
diff --git a/changelog.txt b/changelog.txt
index 4cd5a22a..fca60e2d 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -16,6 +16,11 @@ Added
  - Official support for Python 3.10 (#631).
  - Benchmarks can be run using the ``asv`` (airspeed velocity) tool (#629).
 
+Changed
++++++++
+
+ - Schema migration is now performed on directories rather than signac projects and supports a wider range of schemas (#654).
+
 Deprecated
 ++++++++++
 
diff --git a/signac/__main__.py b/signac/__main__.py
index 7cd746db..f05770bc 100644
--- a/signac/__main__.py
+++ b/signac/__main__.py
@@ -771,13 +771,15 @@ def main_update_cache(args):
 # UNCOMMENT THE FOLLOWING BLOCK WHEN THE FIRST MIGRATION IS INTRODUCED.
 # def main_migrate(args):
 #     "Migrate the project's schema to the current schema version."
-#     from .contrib.migration import apply_migrations
+#     from .contrib.migration import apply_migrations, _get_config_schema_version
 #     from packaging import version
 #     from .version import SCHEMA_VERSION
 #     project = get_project(_ignore_schema_version=True)
 #
+#     root = args.root_directory if args.root_directory else os.getcwd()
+#
 #     schema_version = version.parse(SCHEMA_VERSION)
-#     config_schema_version = version.parse(project.config['schema_version'])
+#     config_schema_version = _get_config_schema_version(root, schema_version)
 #
 #     if config_schema_version > schema_version:
 #         _print_err(
@@ -792,7 +794,7 @@ def main_update_cache(args):
 #         "Do you want to migrate this project's schema version from '{}' to '{}'? "
 #         "WARNING: THIS PROCESS IS IRREVERSIBLE!".format(
 #             config_schema_version, schema_version), 'no'):
-#         apply_migrations(project)
+#         apply_migrations(root)
 #
 #
 def verify_config(cfg, preserve_errors=True):
@@ -2025,6 +2027,13 @@ This feature is still experimental and may be removed in future versions.""",
     #     'migrate',
     #     description="Irreversibly migrate this project's schema version to the "
     #                 "supported version.")
+    # parser_migrate.add_argument(
+    #     "-r",
+    #     "--root-directory",
+    #     type=str,
+    #     default='',
+    #     help="The path to the project.",
+    # )
     # parser_migrate.set_defaults(func=main_migrate)
 
     # This is a hack, as argparse itself does not
diff --git a/signac/contrib/migration/__init__.py b/signac/contrib/migration/__init__.py
index 46545dee..9168fb7f 100644
--- a/signac/contrib/migration/__init__.py
+++ b/signac/contrib/migration/__init__.py
@@ -5,102 +5,139 @@
 
 import os
 import sys
-from contextlib import contextmanager
 
 from filelock import FileLock
 from packaging import version
 
-from ...common.config import get_config
 from ...version import SCHEMA_VERSION, __version__
-from .v0_to_v1 import migrate_v0_to_v1
+from .v0_to_v1 import _load_config_v1, _migrate_v0_to_v1
 
 FN_MIGRATION_LOCKFILE = ".SIGNAC_PROJECT_MIGRATION_LOCK"
 
 
-MIGRATIONS = {
-    ("0", "1"): migrate_v0_to_v1,
+# Config loaders must be functions with the signature
+# def config_loader(root_directory: str) -> MutableMapping
+# When a new schema version is introduced, a corresponding loader only needs to
+# be added if the old loader will no longer function.  This dictionary must
+# contain all unique loaders for schema versions that are supported as starting
+# points for migration. The resulting MutableMapping config objects must be
+# writeable, i.e. it must be possible to persist in-memory changes from these
+# objects to the underlying config files.
+_CONFIG_LOADERS = {
+    "1": _load_config_v1,
 }
 
 
-def _reload_project_config(project):
-    project_reloaded = project.get_project(
-        root=project.root_directory(), search=False, _ignore_schema_version=True
-    )
-    project._config = project_reloaded._config
+_MIGRATIONS = {
+    ("0", "1"): _migrate_v0_to_v1,
+}
 
 
-def _update_project_config(project, **kwargs):
-    """Update the project configuration."""
-    for fn in ("signac.rc", ".signacrc"):
-        config = get_config(project.fn(fn))
-        if "project" in config:
-            break
-    else:
-        raise RuntimeError("Unable to determine project configuration file.")
-    config.update(kwargs)
-    config.write()
-    _reload_project_config(project)
+_PARSED_SCHEMA_VERSION = version.parse(SCHEMA_VERSION)
 
+_VERSION_LIST = list(reversed(sorted(version.parse(v) for v in _CONFIG_LOADERS.keys())))
 
-@contextmanager
-def _lock_for_migration(project):
-    lock = FileLock(project.fn(FN_MIGRATION_LOCKFILE))
-    try:
-        with lock:
-            yield
-    finally:
+
+def _get_config_schema_version(root_directory, version_guess):
+    # Try loading the schema using the loader corresponding to the expected
+    # version if it has a configured loader.
+    versions = _VERSION_LIST
+    if version_guess in _CONFIG_LOADERS:
+        versions = [version_guess] + versions
+    for guess in versions:
         try:
-            os.unlink(lock.lock_file)
-        except FileNotFoundError:
+            # Note: We could consider using a different component as the key
+            # for _CONFIG_LOADERS, but since this is an internal detail it's
+            # not terribly consequential.
+            config = _CONFIG_LOADERS[guess.public](root_directory)
+            break
+        except Exception:
+            # The load failed, go to the next
             pass
+    else:
+        raise RuntimeError("Unable to load config file.")
+    try:
+        return version.parse(config["schema_version"])
+    except KeyError:
+        # The default schema version is version 0.
+        return version.parse("0")
 
 
-def _collect_migrations(project):
-    schema_version = version.parse(SCHEMA_VERSION)
-
-    def get_config_schema_version():
-        return version.parse(project._config["schema_version"])
+def _collect_migrations(root_directory):
+    schema_version = _PARSED_SCHEMA_VERSION
 
-    if get_config_schema_version() > schema_version:
+    current_schema_version = _get_config_schema_version(
+        root_directory, _PARSED_SCHEMA_VERSION
+    )
+    if current_schema_version > schema_version:
         # Project config schema version is newer and therefore not supported.
         raise RuntimeError(
-            "The signac schema version used by this project is {}, but signac {} "
-            "only supports up to schema version {}. Try updating signac.".format(
-                get_config_schema_version(), __version__, SCHEMA_VERSION
-            )
+            "The signac schema version used by this project is "
+            f"{current_schema_version}, but signac {__version__} only "
+            f"supports up to schema version {SCHEMA_VERSION}. Try updating "
+            "signac."
         )
 
-    while get_config_schema_version() < schema_version:
-        for (origin, destination), migration in MIGRATIONS.items():
-            if version.parse(origin) == get_config_schema_version():
+    guess = current_schema_version
+    while _get_config_schema_version(root_directory, guess) < schema_version:
+        for (origin, destination), migration in _MIGRATIONS.items():
+            if version.parse(origin) == _get_config_schema_version(
+                root_directory, guess
+            ):
                 yield (origin, destination), migration
+                guess = version.parse(destination)
                 break
         else:
             raise RuntimeError(
-                "The signac schema version used by this project is {}, but signac {} "
-                "uses schema version {} and does not know how to migrate.".format(
-                    get_config_schema_version(), __version__, schema_version
-                )
+                "The signac schema version used by this project is "
+                f"{_get_config_schema_version(root_directory, guess)}, but "
+                f"signac {__version__} uses schema version {schema_version} "
+                "and does not know how to migrate."
             )
 
 
-def apply_migrations(project):
-    """Apply migrations to a project."""
-    with _lock_for_migration(project):
-        for (origin, destination), migrate in _collect_migrations(project):
-            try:
-                print(
-                    f"Applying migration for version {origin} to {destination}... ",
-                    end="",
-                    file=sys.stderr,
-                )
-                migrate(project)
-            except Exception as e:
-                raise RuntimeError(f"Failed to apply migration {destination}.") from e
-            else:
-                _update_project_config(project, schema_version=destination)
-                print("OK", file=sys.stderr)
-                yield origin, destination
+def apply_migrations(root_directory):
+    """Apply migrations to a project.
+
+    This function identifies and performs all the necessary schema migrations
+    to bring a project up to date with the current schema version of signac.
+    The calling code does not require prior knowledge of the schema version of
+    the project, and the function is idempotent when applied to projects that
+    already have an up-to-date schema.
+
+    Parameters
+    ----------
+    root_directory : str
+        The path to the project to migrate.
+    """
+    try:
+        lock = FileLock(os.path.join(root_directory, FN_MIGRATION_LOCKFILE))
+        with lock:
+            for (origin, destination), migrate in _collect_migrations(root_directory):
+                try:
+                    print(
+                        f"Applying migration for version {origin} to {destination}... ",
+                        end="",
+                        file=sys.stderr,
+                    )
+                    migrate(root_directory)
+                except Exception as e:
+                    raise RuntimeError(
+                        f"Failed to apply migration {destination}."
+                    ) from e
+                else:
+                    config = _CONFIG_LOADERS[version.parse(destination).public](
+                        root_directory
+                    )
+                    config["schema_version"] = destination
+                    config.write()
+
+                    print("OK", file=sys.stderr)
+    finally:
+        try:
+            os.unlink(lock.lock_file)
+        except FileNotFoundError:
+            pass
 
 
 __all__ = [
diff --git a/signac/contrib/migration/v0_to_v1.py b/signac/contrib/migration/v0_to_v1.py
index 13ff6236..3ee6634b 100644
--- a/signac/contrib/migration/v0_to_v1.py
+++ b/signac/contrib/migration/v0_to_v1.py
@@ -6,8 +6,30 @@
 This migration is a null-migration that serves as a template
 for future migrations and testing purposes.
 """
+import os
 
+from signac.common import configobj
 
-def migrate_v0_to_v1(project):
+# A minimal v1 config.
+_cfg = """
+schema_version = string(default='0')
+project = string()
+workspace_dir = string(default='workspace')
+"""
+
+
+def _load_config_v1(root_directory):
+    cfg = configobj.ConfigObj(
+        os.path.join(root_directory, "signac.rc"), configspec=_cfg.split("\n")
+    )
+    validator = configobj.validate.Validator()
+    if cfg.validate(validator) is not True:
+        raise RuntimeError(
+            "This project's config file is not compatible with signac's v1 schema."
+        )
+    return cfg
+
+
+def _migrate_v0_to_v1(root_directory):
     """Migrate from schema version 0 to version 1."""
-    pass  # nothing to do here, serves purely as an example
+    pass
diff --git a/signac/contrib/project.py b/signac/contrib/project.py
index 3b53e9bd..e4ad24ea 100644
--- a/signac/contrib/project.py
+++ b/signac/contrib/project.py
@@ -201,7 +201,7 @@ class _ProjectConfig(Config):
                 "Modifying the project configuration after project "
                 "initialization is deprecated as of version 1.3 and "
                 "will be removed in version 2.0.",
-                DeprecationWarning,
+                FutureWarning,
             )
 
             assert version.parse(__version__) < version.parse("2.0")
@@ -230,8 +230,6 @@ class Project:
         The project configuration to use. By default, it loads the first signac
         project configuration found while searching upward from the current
         working directory (Default value = None).
-    _ignore_schema_version : bool
-        (Default value = False).
 
     """
 
@@ -253,7 +251,7 @@ class Project:
 
     _use_pandas_for_html_repr = True  # toggle use of pandas for html repr
 
-    def __init__(self, config=None, _ignore_schema_version=False):
+    def __init__(self, config=None):
         if config is None:
             config = load_config()
         self._config = _ProjectConfig(
@@ -276,8 +274,7 @@ class Project:
             )
 
         # Ensure that the project's data schema is supported.
-        if not _ignore_schema_version:
-            self._check_schema_compatibility()
+        self._check_schema_compatibility()
 
         # Prepare project document
         self._document = None


Test patch:
diff --git a/tests/test_project.py b/tests/test_project.py
index 724b122f..b92569a0 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -2478,6 +2478,9 @@ class TestProjectInit:
 
 class TestProjectSchema(TestProjectBase):
     def test_project_schema_versions(self):
+        from signac.contrib.migration import apply_migrations
+
+        # Ensure that project initialization fails on an unsupported version.
         impossibly_high_schema_version = "9999"
         assert version.parse(self.project.config["schema_version"]) < version.parse(
             impossibly_high_schema_version
@@ -2490,19 +2493,37 @@ class TestProjectSchema(TestProjectBase):
                 name=str(self.project), root=self.project.root_directory()
             )
 
-    def test_project_schema_version_migration(self):
+        # Ensure that migration fails on an unsupported version.
+        with pytest.raises(RuntimeError):
+            apply_migrations(self.project.root_directory())
+
+        # Ensure that migration fails on an invalid version.
+        invalid_schema_version = "0.5"
+        config = get_config(self.project.fn("signac.rc"))
+        config["schema_version"] = invalid_schema_version
+        config.write()
+        with pytest.raises(RuntimeError):
+            apply_migrations(self.project.root_directory())
+
+    @pytest.mark.parametrize("implicit_version", [True, False])
+    def test_project_schema_version_migration(self, implicit_version):
         from signac.contrib.migration import apply_migrations
 
-        apply_migrations(self.project)
-        self.project._config["schema_version"] = "0"
-        assert self.project._config["schema_version"] == "0"
+        config = get_config(self.project.fn("signac.rc"))
+        if implicit_version:
+            del config["schema_version"]
+            assert "schema_version" not in config
+        else:
+            config["schema_version"] = "0"
+            assert config["schema_version"] == "0"
+        config.write()
         err = io.StringIO()
         with redirect_stderr(err):
-            for origin, destination in apply_migrations(self.project):
-                assert self.project._config["schema_version"] == destination
-                project = signac.get_project(root=self.project.root_directory())
-                assert project._config["schema_version"] == destination
-        assert self.project._config["schema_version"] == "1"
+            apply_migrations(self.project.root_directory())
+        config = get_config(self.project.fn("signac.rc"))
+        assert config["schema_version"] == "1"
+        project = signac.get_project(root=self.project.root_directory())
+        assert project.config["schema_version"] == "1"
         assert "OK" in err.getvalue()
         assert "0 to 1" in err.getvalue()
 
@@ -2516,7 +2537,7 @@ class TestProjectSchema(TestProjectBase):
         # 2. Either update or remove this unit test.
         from signac.contrib.migration import _collect_migrations
 
-        migrations = list(_collect_migrations(self.project))
+        migrations = list(_collect_migrations(self.project.root_directory()))
         assert len(migrations) == 0