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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Compatibility exports; migration orchestration lives in muscles-data."""
2
+
3
+ from muscles.data.migrations import MigrationBackend, MigrationRegistry, MigrationSource
4
+
5
+ __all__ = ["MigrationBackend", "MigrationRegistry", "MigrationSource"]
@@ -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,7 @@
1
+ muscles-data<1.0.0,>=0.2.0
2
+
3
+ [mongodb]
4
+ muscles-data-mongodb<1.0.0,>=0.2.0
5
+
6
+ [redis]
7
+ muscles-data-redis<1.0.0,>=0.2.0
@@ -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"]