muscles-migrations 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- muscles_migrations-0.2.1/PKG-INFO +14 -0
- muscles_migrations-0.2.1/README.md +49 -0
- muscles_migrations-0.2.1/pyproject.toml +27 -0
- muscles_migrations-0.2.1/setup.cfg +4 -0
- muscles_migrations-0.2.1/src/muscles/migrations/__init__.py +5 -0
- muscles_migrations-0.2.1/src/muscles/migrations/native.py +13 -0
- muscles_migrations-0.2.1/src/muscles/migrations/versioned.py +6 -0
- muscles_migrations-0.2.1/src/muscles_migrations.egg-info/PKG-INFO +14 -0
- muscles_migrations-0.2.1/src/muscles_migrations.egg-info/SOURCES.txt +13 -0
- muscles_migrations-0.2.1/src/muscles_migrations.egg-info/dependency_links.txt +1 -0
- muscles_migrations-0.2.1/src/muscles_migrations.egg-info/requires.txt +7 -0
- muscles_migrations-0.2.1/src/muscles_migrations.egg-info/top_level.txt +1 -0
- muscles_migrations-0.2.1/tests/test_native_journals.py +245 -0
- muscles_migrations-0.2.1/tests/test_registry.py +39 -0
- muscles_migrations-0.2.1/tests/test_versioned.py +66 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muscles-migrations
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Storage-independent migration orchestration for Muscles
|
|
5
|
+
Project-URL: Homepage, https://github.com/butkoden/muscles-migrations
|
|
6
|
+
Project-URL: Repository, https://github.com/butkoden/muscles-migrations
|
|
7
|
+
Project-URL: Documentation, https://github.com/butkoden/muscles-migrations#readme
|
|
8
|
+
Project-URL: Releases, https://github.com/butkoden/muscles-migrations/releases
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: muscles-data<1.0.0,>=0.2.0
|
|
11
|
+
Provides-Extra: redis
|
|
12
|
+
Requires-Dist: muscles-data-redis<1.0.0,>=0.2.0; extra == "redis"
|
|
13
|
+
Provides-Extra: mongodb
|
|
14
|
+
Requires-Dist: muscles-data-mongodb<1.0.0,>=0.2.0; extra == "mongodb"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# muscles-migrations
|
|
2
|
+
|
|
3
|
+
Compatibility package: new code should import the registry and source contracts
|
|
4
|
+
from `muscles-data`. SQL migration backends live in `muscles-data-sql`; Redis and
|
|
5
|
+
MongoDB backends live in their respective data adapter packages. This package
|
|
6
|
+
keeps legacy imports available for existing projects. See
|
|
7
|
+
`muscles-data/docs/migrations.en.md` for discovery, command integration, locks
|
|
8
|
+
and migration history.
|
|
9
|
+
|
|
10
|
+
Register migration sources with a backend implementing `upgrade(target)`.
|
|
11
|
+
The registry validates dependencies before applying sources in dependency order.
|
|
12
|
+
Backends own their version tracking, concurrency handling and rollback semantics.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from muscles.data import MigrationRegistry, MigrationSource, discover_migration_modules
|
|
18
|
+
from muscles.data.sql.native_migrations import NativeSqlMigration, NativeSqlMigrationSourceBackend
|
|
19
|
+
|
|
20
|
+
registry = MigrationRegistry()
|
|
21
|
+
modules = discover_migration_modules("myapp.migrations")
|
|
22
|
+
migrations = [NativeSqlMigration.from_module(module) for module in modules]
|
|
23
|
+
registry.register(MigrationSource(
|
|
24
|
+
name="accounts",
|
|
25
|
+
backend=NativeSqlMigrationSourceBackend(
|
|
26
|
+
os.environ["DATABASE_URL"], "accounts", migrations
|
|
27
|
+
),
|
|
28
|
+
))
|
|
29
|
+
registry.upgrade()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`DATABASE_URL` must point to a SQLite or PostgreSQL database supported by the
|
|
33
|
+
native SQL migration backend. The application should create one registry in
|
|
34
|
+
its composition root and pass it to its CLI/runtime integration; packages
|
|
35
|
+
should not create duplicate registries or connection managers.
|
|
36
|
+
|
|
37
|
+
## Experimental Redis and MongoDB backends
|
|
38
|
+
|
|
39
|
+
Redis and MongoDB migration backends are provided by their respective data
|
|
40
|
+
adapter packages. Their backend configuration and migration object types are
|
|
41
|
+
owned by those adapters; the shared registry contract remains in `muscles-data`.
|
|
42
|
+
|
|
43
|
+
Locks deliberately do not expire: an expired lease could allow a second runner
|
|
44
|
+
to overlap a long-running migration. If a process dies, an operator must first
|
|
45
|
+
confirm it is no longer running and inspect the migration state before manually
|
|
46
|
+
clearing the lock. A migration left in `running` requires manual recovery; it is
|
|
47
|
+
never automatically retried. The backing journal must itself be durable and
|
|
48
|
+
protected against eviction or deletion.
|
|
49
|
+
Repository: https://github.com/butkoden/muscles-migrations
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "muscles-migrations"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "Storage-independent migration orchestration for Muscles"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = ["muscles-data>=0.2.0,<1.0.0"]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
redis = ["muscles-data-redis>=0.2.0,<1.0.0"]
|
|
14
|
+
mongodb = ["muscles-data-mongodb>=0.2.0,<1.0.0"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/butkoden/muscles-migrations"
|
|
18
|
+
Repository = "https://github.com/butkoden/muscles-migrations"
|
|
19
|
+
Documentation = "https://github.com/butkoden/muscles-migrations#readme"
|
|
20
|
+
Releases = "https://github.com/butkoden/muscles-migrations/releases"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
package-dir = {"" = "src"}
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
include = ["muscles.migrations*"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Compatibility imports for optional backend-specific migration packages."""
|
|
2
|
+
|
|
3
|
+
__all__ = ["RedisJournal", "RedisMigrationBackend", "MongoJournal", "MongoMigrationBackend"]
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def __getattr__(name):
|
|
7
|
+
if name in {"RedisJournal", "RedisMigrationBackend"}:
|
|
8
|
+
from muscles.data.redis import migrations
|
|
9
|
+
return getattr(migrations, name)
|
|
10
|
+
if name in {"MongoJournal", "MongoMigrationBackend"}:
|
|
11
|
+
from muscles.data.mongodb import migrations
|
|
12
|
+
return getattr(migrations, name)
|
|
13
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Compatibility imports for the shared versioned migration contract."""
|
|
2
|
+
from muscles.data.versioned_migrations import (
|
|
3
|
+
Migration, MigrationJournal, MigrationStateError, VersionedMigrationBackend,
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
__all__ = ["Migration", "MigrationJournal", "MigrationStateError", "VersionedMigrationBackend"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: muscles-migrations
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Storage-independent migration orchestration for Muscles
|
|
5
|
+
Project-URL: Homepage, https://github.com/butkoden/muscles-migrations
|
|
6
|
+
Project-URL: Repository, https://github.com/butkoden/muscles-migrations
|
|
7
|
+
Project-URL: Documentation, https://github.com/butkoden/muscles-migrations#readme
|
|
8
|
+
Project-URL: Releases, https://github.com/butkoden/muscles-migrations/releases
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: muscles-data<1.0.0,>=0.2.0
|
|
11
|
+
Provides-Extra: redis
|
|
12
|
+
Requires-Dist: muscles-data-redis<1.0.0,>=0.2.0; extra == "redis"
|
|
13
|
+
Provides-Extra: mongodb
|
|
14
|
+
Requires-Dist: muscles-data-mongodb<1.0.0,>=0.2.0; extra == "mongodb"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/muscles/migrations/__init__.py
|
|
4
|
+
src/muscles/migrations/native.py
|
|
5
|
+
src/muscles/migrations/versioned.py
|
|
6
|
+
src/muscles_migrations.egg-info/PKG-INFO
|
|
7
|
+
src/muscles_migrations.egg-info/SOURCES.txt
|
|
8
|
+
src/muscles_migrations.egg-info/dependency_links.txt
|
|
9
|
+
src/muscles_migrations.egg-info/requires.txt
|
|
10
|
+
src/muscles_migrations.egg-info/top_level.txt
|
|
11
|
+
tests/test_native_journals.py
|
|
12
|
+
tests/test_registry.py
|
|
13
|
+
tests/test_versioned.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
muscles
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from muscles.migrations.native import RedisJournal, MongoJournal, RedisMigrationBackend, MongoMigrationBackend
|
|
9
|
+
from muscles.migrations.versioned import Migration, MigrationStateError, VersionedMigrationBackend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_legacy_backends_reexport_adapter_implementations():
|
|
13
|
+
from muscles.data.mongodb.migrations import MongoMigrationBackend as MongoBackend
|
|
14
|
+
from muscles.data.redis.migrations import RedisMigrationBackend as RedisBackend
|
|
15
|
+
|
|
16
|
+
assert MongoMigrationBackend is MongoBackend
|
|
17
|
+
assert RedisMigrationBackend is RedisBackend
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RedisFake:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.values = {}
|
|
23
|
+
|
|
24
|
+
def get(self, key):
|
|
25
|
+
return self.values.get(key)
|
|
26
|
+
|
|
27
|
+
def set(self, key, value, nx=False):
|
|
28
|
+
if nx and key in self.values:
|
|
29
|
+
return False
|
|
30
|
+
self.values[key] = value
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
def eval(self, script, count, key, token):
|
|
34
|
+
if self.values.get(key) == token:
|
|
35
|
+
del self.values[key]
|
|
36
|
+
return 1
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
def scan_iter(self, match):
|
|
40
|
+
from fnmatch import fnmatch
|
|
41
|
+
return iter([key for key in self.values if fnmatch(key, match)])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CollectionFake:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self.values = {}
|
|
47
|
+
|
|
48
|
+
def find_one(self, query):
|
|
49
|
+
return self.values.get(query["_id"])
|
|
50
|
+
|
|
51
|
+
def find(self, query, projection=None):
|
|
52
|
+
return [dict(value) for value in self.values.values()
|
|
53
|
+
if all(value.get(key) == expected for key, expected in query.items())]
|
|
54
|
+
|
|
55
|
+
def insert_one(self, doc):
|
|
56
|
+
if doc["_id"] in self.values:
|
|
57
|
+
raise ValueError("duplicate")
|
|
58
|
+
self.values[doc["_id"]] = dict(doc)
|
|
59
|
+
|
|
60
|
+
def update_one(self, query, update):
|
|
61
|
+
from types import SimpleNamespace
|
|
62
|
+
doc = self.values.get(query["_id"])
|
|
63
|
+
matched = doc is not None and all(doc.get(k) == v for k, v in query.items())
|
|
64
|
+
if matched:
|
|
65
|
+
doc.update(update["$set"])
|
|
66
|
+
return SimpleNamespace(matched_count=int(matched))
|
|
67
|
+
|
|
68
|
+
def delete_one(self, query):
|
|
69
|
+
from types import SimpleNamespace
|
|
70
|
+
doc = self.values.get(query["_id"])
|
|
71
|
+
matched = doc is not None and all(doc.get(k) == v for k, v in query.items())
|
|
72
|
+
if matched:
|
|
73
|
+
del self.values[query["_id"]]
|
|
74
|
+
return SimpleNamespace(deleted_count=int(matched))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.parametrize("journal", [RedisJournal(RedisFake(), "test"), MongoJournal(CollectionFake(), "test")])
|
|
78
|
+
def test_native_journal_records_versions(journal):
|
|
79
|
+
calls = []
|
|
80
|
+
backend = VersionedMigrationBackend(journal, [Migration("001", "checksum", lambda: calls.append(1))])
|
|
81
|
+
backend.upgrade()
|
|
82
|
+
backend.upgrade()
|
|
83
|
+
assert calls == [1]
|
|
84
|
+
assert journal.get("001") == ("checksum", "complete")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_sources_are_isolated():
|
|
88
|
+
client = RedisFake()
|
|
89
|
+
one, two = RedisJournal(client, "a"), RedisJournal(client, "b")
|
|
90
|
+
one.begin("001", "checksum")
|
|
91
|
+
assert two.get("001") is None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_redis_legacy_journal_identity_is_preserved():
|
|
95
|
+
client = RedisFake()
|
|
96
|
+
old_id = hashlib.sha256(json.dumps(["test", "001"], ensure_ascii=False).encode()).hexdigest()
|
|
97
|
+
client.values[f"muscles:migrations:{old_id}"] = json.dumps({"checksum": "checksum", "state": "complete"})
|
|
98
|
+
calls = []
|
|
99
|
+
backend = RedisMigrationBackend(client, "test", [Migration("001", "checksum", lambda: calls.append("up"))])
|
|
100
|
+
backend.upgrade()
|
|
101
|
+
assert backend.status()["current"] == ["001"]
|
|
102
|
+
assert not calls
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_redis_unattributable_legacy_history_requires_reconciliation():
|
|
106
|
+
client = RedisFake()
|
|
107
|
+
old_id = hashlib.sha256(json.dumps(["other", "001"], ensure_ascii=False).encode()).hexdigest()
|
|
108
|
+
client.values[f"muscles:migrations:{old_id}"] = json.dumps({"checksum": "hash", "state": "complete"})
|
|
109
|
+
backend = RedisMigrationBackend(client, "test", [])
|
|
110
|
+
with pytest.raises(MigrationStateError, match="reconcile"):
|
|
111
|
+
backend.status()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_redis_journal_rejects_glob_metacharacters_in_prefix():
|
|
115
|
+
with pytest.raises(ValueError, match="prefix"):
|
|
116
|
+
RedisMigrationBackend(RedisFake(), "test", [], prefix="shared:*")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.parametrize("backend", [
|
|
120
|
+
RedisMigrationBackend(RedisFake(), "test", []),
|
|
121
|
+
MongoMigrationBackend(CollectionFake(), "test", []),
|
|
122
|
+
])
|
|
123
|
+
def test_backend_prevents_concurrent_run_and_releases_lock(backend):
|
|
124
|
+
calls = []
|
|
125
|
+
def run():
|
|
126
|
+
with pytest.raises(Exception):
|
|
127
|
+
backend.upgrade()
|
|
128
|
+
calls.append(1)
|
|
129
|
+
backend.migrations = (Migration("001", "checksum", run),)
|
|
130
|
+
backend.upgrade()
|
|
131
|
+
backend.upgrade()
|
|
132
|
+
assert calls == [1]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@pytest.mark.parametrize("backend", [
|
|
136
|
+
RedisMigrationBackend(RedisFake(), "test", []),
|
|
137
|
+
MongoMigrationBackend(CollectionFake(), "test", []),
|
|
138
|
+
])
|
|
139
|
+
def test_backend_status_and_downgrade_track_nontransactional_reversion(backend):
|
|
140
|
+
calls = []
|
|
141
|
+
backend.migrations = (Migration("001", "checksum", lambda: calls.append("up"), lambda: calls.append("down")),)
|
|
142
|
+
assert backend.plan() == ["001"]
|
|
143
|
+
backend.upgrade()
|
|
144
|
+
assert backend.status()["current"] == ["001"]
|
|
145
|
+
backend.downgrade("base")
|
|
146
|
+
assert backend.status()["current"] == []
|
|
147
|
+
assert calls == ["up", "down"]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@pytest.mark.parametrize("backend", [
|
|
151
|
+
RedisMigrationBackend(RedisFake(), "test", []),
|
|
152
|
+
MongoMigrationBackend(CollectionFake(), "test", []),
|
|
153
|
+
])
|
|
154
|
+
def test_completed_journal_records_application_time(backend):
|
|
155
|
+
backend.migrations = (Migration("001", "checksum", lambda: None),)
|
|
156
|
+
backend.upgrade()
|
|
157
|
+
if isinstance(backend, RedisMigrationBackend):
|
|
158
|
+
entry = json.loads(backend.client.get(backend.journal._key("001")))
|
|
159
|
+
else:
|
|
160
|
+
entry = backend.collection.find_one({"_id": next(key for key in backend.collection.values if not key.startswith("lock:"))})
|
|
161
|
+
assert entry.get("applied_at") is not None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.mark.parametrize("backend", [
|
|
165
|
+
RedisMigrationBackend(RedisFake(), "test", []),
|
|
166
|
+
MongoMigrationBackend(CollectionFake(), "test", []),
|
|
167
|
+
])
|
|
168
|
+
def test_interrupted_rollback_is_not_retried_automatically(backend):
|
|
169
|
+
def fail():
|
|
170
|
+
raise RuntimeError("interrupted")
|
|
171
|
+
|
|
172
|
+
backend.migrations = (Migration("001", "checksum", lambda: None, fail),)
|
|
173
|
+
backend.upgrade()
|
|
174
|
+
with pytest.raises(RuntimeError, match="interrupted"):
|
|
175
|
+
backend.downgrade("base")
|
|
176
|
+
assert backend.journal.get("001") == ("checksum", "reverting")
|
|
177
|
+
with pytest.raises(MigrationStateError, match="interrupted"):
|
|
178
|
+
backend.status()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@pytest.mark.parametrize("backend", [
|
|
182
|
+
RedisMigrationBackend(RedisFake(), "test", []),
|
|
183
|
+
MongoMigrationBackend(CollectionFake(), "test", []),
|
|
184
|
+
])
|
|
185
|
+
def test_missing_previously_applied_migration_is_rejected(backend):
|
|
186
|
+
backend.migrations = (Migration("001", "first", lambda: None), Migration("002", "second", lambda: None))
|
|
187
|
+
backend.upgrade()
|
|
188
|
+
backend.migrations = backend.migrations[:1]
|
|
189
|
+
with pytest.raises(MigrationStateError, match="missing"):
|
|
190
|
+
backend.status()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.mark.parametrize("backend", [
|
|
194
|
+
RedisMigrationBackend(RedisFake(), "test", []),
|
|
195
|
+
MongoMigrationBackend(CollectionFake(), "test", []),
|
|
196
|
+
])
|
|
197
|
+
def test_lost_lock_ownership_is_reported(backend):
|
|
198
|
+
with pytest.raises(MigrationStateError, match="lock"):
|
|
199
|
+
with backend._lock():
|
|
200
|
+
if isinstance(backend, RedisMigrationBackend):
|
|
201
|
+
backend.client.set(backend.lock_key, "other-owner")
|
|
202
|
+
else:
|
|
203
|
+
backend.collection.values[backend.lock_id]["token"] = "other-owner"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.skipif(not os.getenv("MUSCLES_MIGRATION_TEST_REDIS"), reason="isolated Redis not configured")
|
|
207
|
+
def test_redis_migration_lifecycle_against_real_server():
|
|
208
|
+
from redis import Redis
|
|
209
|
+
|
|
210
|
+
client = Redis.from_url(os.environ["MUSCLES_MIGRATION_TEST_REDIS"])
|
|
211
|
+
prefix = f"muscles:example:{uuid4().hex}"
|
|
212
|
+
key = f"{prefix}:data"
|
|
213
|
+
backend = RedisMigrationBackend(client, "example", [
|
|
214
|
+
Migration("001", "checksum", lambda: client.set(key, "value"), lambda: client.delete(key))
|
|
215
|
+
], prefix=prefix)
|
|
216
|
+
try:
|
|
217
|
+
backend.upgrade()
|
|
218
|
+
assert client.get(key) == b"value"
|
|
219
|
+
assert backend.status()["current"] == ["001"]
|
|
220
|
+
backend.downgrade("base")
|
|
221
|
+
assert client.get(key) is None
|
|
222
|
+
assert backend.status()["current"] == []
|
|
223
|
+
finally:
|
|
224
|
+
client.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.mark.skipif(not os.getenv("MUSCLES_MIGRATION_TEST_MONGODB"), reason="isolated MongoDB not configured")
|
|
228
|
+
def test_mongodb_migration_lifecycle_against_real_server():
|
|
229
|
+
from pymongo import MongoClient
|
|
230
|
+
|
|
231
|
+
client = MongoClient(os.environ["MUSCLES_MIGRATION_TEST_MONGODB"], serverSelectionTimeoutMS=5000)
|
|
232
|
+
database = client[f"muscles_test_{uuid4().hex}"]
|
|
233
|
+
backend = MongoMigrationBackend(database.journal, "example", [
|
|
234
|
+
Migration("001", "checksum", lambda: database.data.insert_one({"_id": "one"}),
|
|
235
|
+
lambda: database.data.delete_one({"_id": "one"}))
|
|
236
|
+
])
|
|
237
|
+
try:
|
|
238
|
+
backend.upgrade()
|
|
239
|
+
assert database.data.find_one({"_id": "one"}) is not None
|
|
240
|
+
assert backend.status()["current"] == ["001"]
|
|
241
|
+
backend.downgrade("base")
|
|
242
|
+
assert database.data.find_one({"_id": "one"}) is None
|
|
243
|
+
assert backend.status()["current"] == []
|
|
244
|
+
finally:
|
|
245
|
+
client.close()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from muscles.migrations import MigrationRegistry, MigrationSource
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_legacy_imports_share_the_data_contract():
|
|
7
|
+
from muscles.data.migrations import MigrationRegistry as DataRegistry, MigrationSource as DataSource
|
|
8
|
+
|
|
9
|
+
assert MigrationRegistry is DataRegistry
|
|
10
|
+
assert MigrationSource is DataSource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Backend:
|
|
14
|
+
def __init__(self, name, calls):
|
|
15
|
+
self.name, self.calls = name, calls
|
|
16
|
+
|
|
17
|
+
def upgrade(self, target="head"):
|
|
18
|
+
self.calls.append((self.name, target))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_sources_run_after_dependencies():
|
|
22
|
+
calls = []
|
|
23
|
+
registry = MigrationRegistry()
|
|
24
|
+
registry.register(MigrationSource("sites", Backend("sites", calls), ("accounts",)))
|
|
25
|
+
registry.register(MigrationSource("accounts", Backend("accounts", calls)))
|
|
26
|
+
registry.upgrade()
|
|
27
|
+
assert calls == [("accounts", "head"), ("sites", "head")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_missing_or_cyclic_dependencies_fail_before_upgrade():
|
|
31
|
+
calls = []
|
|
32
|
+
registry = MigrationRegistry()
|
|
33
|
+
registry.register(MigrationSource("a", Backend("a", calls), ("b",)))
|
|
34
|
+
with pytest.raises(ValueError):
|
|
35
|
+
registry.upgrade()
|
|
36
|
+
registry.register(MigrationSource("b", Backend("b", calls), ("a",)))
|
|
37
|
+
with pytest.raises(ValueError):
|
|
38
|
+
registry.upgrade()
|
|
39
|
+
assert not calls
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import importlib.util
|
|
3
|
+
|
|
4
|
+
from muscles.migrations.versioned import Migration, MigrationStateError, VersionedMigrationBackend
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Journal:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.entries = {}
|
|
10
|
+
|
|
11
|
+
def get(self, version):
|
|
12
|
+
return self.entries.get(version)
|
|
13
|
+
|
|
14
|
+
def begin(self, version, checksum):
|
|
15
|
+
self.entries[version] = (checksum, "running")
|
|
16
|
+
|
|
17
|
+
def complete(self, version, checksum):
|
|
18
|
+
self.entries[version] = (checksum, "complete")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_applies_once_and_detects_changed_migration():
|
|
22
|
+
calls = []
|
|
23
|
+
journal = Journal()
|
|
24
|
+
backend = VersionedMigrationBackend(journal, [Migration("001", "checksum", lambda: calls.append(1))])
|
|
25
|
+
backend.upgrade()
|
|
26
|
+
backend.upgrade()
|
|
27
|
+
assert calls == [1]
|
|
28
|
+
with pytest.raises(MigrationStateError):
|
|
29
|
+
VersionedMigrationBackend(journal, [Migration("001", "changed", lambda: None)]).upgrade()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_failure_leaves_running_state_and_does_not_rerun():
|
|
33
|
+
journal = Journal()
|
|
34
|
+
|
|
35
|
+
def fail():
|
|
36
|
+
raise RuntimeError("failure")
|
|
37
|
+
|
|
38
|
+
backend = VersionedMigrationBackend(journal, [Migration("001", "checksum", fail)])
|
|
39
|
+
with pytest.raises(RuntimeError):
|
|
40
|
+
backend.upgrade()
|
|
41
|
+
with pytest.raises(MigrationStateError):
|
|
42
|
+
backend.upgrade()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_changed_file_after_discovery_is_not_executed(tmp_path):
|
|
46
|
+
path = tmp_path / "m001.py"
|
|
47
|
+
path.write_text("revision = '001'\ndef upgrade():\n pass\n", encoding="utf-8")
|
|
48
|
+
spec = importlib.util.spec_from_file_location("test_non_sql_migration", path)
|
|
49
|
+
module = importlib.util.module_from_spec(spec)
|
|
50
|
+
spec.loader.exec_module(module)
|
|
51
|
+
migration = Migration.from_module(module)
|
|
52
|
+
path.write_text(path.read_text() + "# changed\n", encoding="utf-8")
|
|
53
|
+
with pytest.raises(MigrationStateError, match="changed"):
|
|
54
|
+
VersionedMigrationBackend(Journal(), [migration]).upgrade()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_migration_module_receives_explicit_resource(tmp_path):
|
|
58
|
+
path = tmp_path / "m002.py"
|
|
59
|
+
path.write_text("revision = '002'\ndef upgrade(resource):\n resource.append('up')\n", encoding="utf-8")
|
|
60
|
+
spec = importlib.util.spec_from_file_location("test_non_sql_migration_resource", path)
|
|
61
|
+
module = importlib.util.module_from_spec(spec)
|
|
62
|
+
spec.loader.exec_module(module)
|
|
63
|
+
resource = []
|
|
64
|
+
migration = Migration.from_module(module, resource=resource)
|
|
65
|
+
VersionedMigrationBackend(Journal(), [migration]).upgrade()
|
|
66
|
+
assert resource == ["up"]
|