inputlayer-client-dev 0.1.0__py3-none-any.whl
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.
- inputlayer/__init__.py +151 -0
- inputlayer/_ast.py +133 -0
- inputlayer/_naming.py +44 -0
- inputlayer/_protocol.py +225 -0
- inputlayer/_proxy.py +244 -0
- inputlayer/aggregations.py +126 -0
- inputlayer/auth.py +70 -0
- inputlayer/client.py +178 -0
- inputlayer/client_sync.py +231 -0
- inputlayer/compiler.py +672 -0
- inputlayer/connection.py +314 -0
- inputlayer/derived.py +141 -0
- inputlayer/exceptions.py +76 -0
- inputlayer/functions.py +285 -0
- inputlayer/index.py +40 -0
- inputlayer/knowledge_graph.py +589 -0
- inputlayer/migrations/__init__.py +51 -0
- inputlayer/migrations/autodetector.py +120 -0
- inputlayer/migrations/cli.py +254 -0
- inputlayer/migrations/executor.py +95 -0
- inputlayer/migrations/loader.py +91 -0
- inputlayer/migrations/operations.py +298 -0
- inputlayer/migrations/recorder.py +44 -0
- inputlayer/migrations/state.py +107 -0
- inputlayer/migrations/writer.py +183 -0
- inputlayer/notifications.py +83 -0
- inputlayer/py.typed +0 -0
- inputlayer/relation.py +102 -0
- inputlayer/result.py +89 -0
- inputlayer/session.py +75 -0
- inputlayer/types.py +192 -0
- inputlayer_client_dev-0.1.0.dist-info/METADATA +364 -0
- inputlayer_client_dev-0.1.0.dist-info/RECORD +35 -0
- inputlayer_client_dev-0.1.0.dist-info/WHEEL +4 -0
- inputlayer_client_dev-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Autodetector - diff two ModelStates to produce a list of Operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from inputlayer.migrations.operations import (
|
|
6
|
+
CreateIndex,
|
|
7
|
+
CreateRelation,
|
|
8
|
+
CreateRule,
|
|
9
|
+
DropIndex,
|
|
10
|
+
DropRelation,
|
|
11
|
+
DropRule,
|
|
12
|
+
Operation,
|
|
13
|
+
ReplaceRule,
|
|
14
|
+
)
|
|
15
|
+
from inputlayer.migrations.state import ModelState
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def detect_changes(old: ModelState, new: ModelState) -> list[Operation]:
|
|
19
|
+
"""Diff two states and return an ordered list of operations.
|
|
20
|
+
|
|
21
|
+
Ordering:
|
|
22
|
+
1. Create new relations (needed before rules can reference them)
|
|
23
|
+
2. Drop old rules (before relations they depend on are dropped)
|
|
24
|
+
3. Replace modified rules
|
|
25
|
+
4. Create new rules
|
|
26
|
+
5. Drop removed relations
|
|
27
|
+
6. Handle indexes (drop removed, create new)
|
|
28
|
+
"""
|
|
29
|
+
ops: list[Operation] = []
|
|
30
|
+
|
|
31
|
+
old_rels = set(old.relations)
|
|
32
|
+
new_rels = set(new.relations)
|
|
33
|
+
old_rules = set(old.rules)
|
|
34
|
+
new_rules = set(new.rules)
|
|
35
|
+
old_idxs = set(old.indexes)
|
|
36
|
+
new_idxs = set(new.indexes)
|
|
37
|
+
|
|
38
|
+
# 1. Create new relations
|
|
39
|
+
for name in sorted(new_rels - old_rels):
|
|
40
|
+
ops.append(CreateRelation(name=name, columns=new.relations[name]))
|
|
41
|
+
|
|
42
|
+
# 2. Modified relations (columns changed) → drop + recreate
|
|
43
|
+
# InputLayer can't ALTER, so this is destructive
|
|
44
|
+
for name in sorted(old_rels & new_rels):
|
|
45
|
+
if old.relations[name] != new.relations[name]:
|
|
46
|
+
ops.append(DropRelation(name=name, columns=old.relations[name]))
|
|
47
|
+
ops.append(CreateRelation(name=name, columns=new.relations[name]))
|
|
48
|
+
|
|
49
|
+
# 3. Drop rules that no longer exist
|
|
50
|
+
for name in sorted(old_rules - new_rules):
|
|
51
|
+
ops.append(DropRule(name=name, clauses=old.rules[name]))
|
|
52
|
+
|
|
53
|
+
# 4. Replace modified rules
|
|
54
|
+
for name in sorted(old_rules & new_rules):
|
|
55
|
+
if old.rules[name] != new.rules[name]:
|
|
56
|
+
ops.append(ReplaceRule(
|
|
57
|
+
name=name,
|
|
58
|
+
old_clauses=old.rules[name],
|
|
59
|
+
new_clauses=new.rules[name],
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
# 5. Create new rules
|
|
63
|
+
for name in sorted(new_rules - old_rules):
|
|
64
|
+
ops.append(CreateRule(name=name, clauses=new.rules[name]))
|
|
65
|
+
|
|
66
|
+
# 6. Drop removed relations (after their rules are gone)
|
|
67
|
+
for name in sorted(old_rels - new_rels):
|
|
68
|
+
ops.append(DropRelation(name=name, columns=old.relations[name]))
|
|
69
|
+
|
|
70
|
+
# 7. Drop removed indexes
|
|
71
|
+
for name in sorted(old_idxs - new_idxs):
|
|
72
|
+
info = old.indexes[name]
|
|
73
|
+
ops.append(DropIndex(
|
|
74
|
+
name=name,
|
|
75
|
+
relation=info["relation"],
|
|
76
|
+
column=info["column"],
|
|
77
|
+
metric=info.get("metric", "cosine"),
|
|
78
|
+
m=info.get("m", 16),
|
|
79
|
+
ef_construction=info.get("ef_construction", 100),
|
|
80
|
+
ef_search=info.get("ef_search", 50),
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
# 8. Modified indexes → drop + recreate
|
|
84
|
+
for name in sorted(old_idxs & new_idxs):
|
|
85
|
+
if old.indexes[name] != new.indexes[name]:
|
|
86
|
+
old_info = old.indexes[name]
|
|
87
|
+
ops.append(DropIndex(
|
|
88
|
+
name=name,
|
|
89
|
+
relation=old_info["relation"],
|
|
90
|
+
column=old_info["column"],
|
|
91
|
+
metric=old_info.get("metric", "cosine"),
|
|
92
|
+
m=old_info.get("m", 16),
|
|
93
|
+
ef_construction=old_info.get("ef_construction", 100),
|
|
94
|
+
ef_search=old_info.get("ef_search", 50),
|
|
95
|
+
))
|
|
96
|
+
new_info = new.indexes[name]
|
|
97
|
+
ops.append(CreateIndex(
|
|
98
|
+
name=name,
|
|
99
|
+
relation=new_info["relation"],
|
|
100
|
+
column=new_info["column"],
|
|
101
|
+
metric=new_info.get("metric", "cosine"),
|
|
102
|
+
m=new_info.get("m", 16),
|
|
103
|
+
ef_construction=new_info.get("ef_construction", 100),
|
|
104
|
+
ef_search=new_info.get("ef_search", 50),
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
# 9. Create new indexes
|
|
108
|
+
for name in sorted(new_idxs - old_idxs):
|
|
109
|
+
info = new.indexes[name]
|
|
110
|
+
ops.append(CreateIndex(
|
|
111
|
+
name=name,
|
|
112
|
+
relation=info["relation"],
|
|
113
|
+
column=info["column"],
|
|
114
|
+
metric=info.get("metric", "cosine"),
|
|
115
|
+
m=info.get("m", 16),
|
|
116
|
+
ef_construction=info.get("ef_construction", 100),
|
|
117
|
+
ef_search=info.get("ef_search", 50),
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
return ops
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""CLI entry point for the migration system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import importlib
|
|
7
|
+
import inspect
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from inputlayer.migrations.autodetector import detect_changes
|
|
14
|
+
from inputlayer.migrations.loader import get_latest_state, get_next_number, load_migrations
|
|
15
|
+
from inputlayer.migrations.state import ModelState
|
|
16
|
+
from inputlayer.migrations.writer import generate_migration
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _discover_models(module_path: str) -> tuple[list, list, list]:
|
|
20
|
+
"""Import a module and discover Relation, Derived, and HnswIndex objects.
|
|
21
|
+
|
|
22
|
+
Returns (relations, derived, indexes).
|
|
23
|
+
"""
|
|
24
|
+
from inputlayer.derived import Derived
|
|
25
|
+
from inputlayer.index import HnswIndex
|
|
26
|
+
from inputlayer.relation import Relation
|
|
27
|
+
|
|
28
|
+
mod = importlib.import_module(module_path)
|
|
29
|
+
|
|
30
|
+
relations = []
|
|
31
|
+
derived = []
|
|
32
|
+
indexes = []
|
|
33
|
+
|
|
34
|
+
for _name, obj in inspect.getmembers(mod):
|
|
35
|
+
if isinstance(obj, HnswIndex):
|
|
36
|
+
indexes.append(obj)
|
|
37
|
+
elif isinstance(obj, type) and issubclass(obj, Derived) and obj is not Derived:
|
|
38
|
+
derived.append(obj)
|
|
39
|
+
elif isinstance(obj, type) and issubclass(obj, Relation) and obj is not Relation and not issubclass(obj, Derived):
|
|
40
|
+
relations.append(obj)
|
|
41
|
+
|
|
42
|
+
return relations, derived, indexes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _cmd_makemigrations(args: argparse.Namespace) -> int:
|
|
46
|
+
"""Generate a new migration file by diffing current models against last state."""
|
|
47
|
+
migrations_dir = Path(args.migrations_dir)
|
|
48
|
+
migrations_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
# Discover models
|
|
51
|
+
relations, derived_list, indexes = _discover_models(args.models)
|
|
52
|
+
|
|
53
|
+
if not relations and not derived_list and not indexes:
|
|
54
|
+
print(f"No models found in {args.models}")
|
|
55
|
+
return 1
|
|
56
|
+
|
|
57
|
+
# Build current state
|
|
58
|
+
new_state = ModelState.from_models(
|
|
59
|
+
relations=relations,
|
|
60
|
+
derived=derived_list,
|
|
61
|
+
indexes=indexes,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Load previous state
|
|
65
|
+
old_state_dict = get_latest_state(migrations_dir)
|
|
66
|
+
old_state = ModelState.from_dict(old_state_dict)
|
|
67
|
+
|
|
68
|
+
# Detect changes
|
|
69
|
+
operations = detect_changes(old_state, new_state)
|
|
70
|
+
|
|
71
|
+
if not operations:
|
|
72
|
+
print("No changes detected.")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
# Generate migration file
|
|
76
|
+
number = get_next_number(migrations_dir)
|
|
77
|
+
migrations = load_migrations(migrations_dir)
|
|
78
|
+
deps = [migrations[-1].name] if migrations else []
|
|
79
|
+
|
|
80
|
+
filename, content = generate_migration(
|
|
81
|
+
number,
|
|
82
|
+
operations,
|
|
83
|
+
new_state.to_dict(),
|
|
84
|
+
deps,
|
|
85
|
+
name_suffix=args.name if hasattr(args, "name") and args.name else None,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
filepath = migrations_dir / filename
|
|
89
|
+
filepath.write_text(content)
|
|
90
|
+
|
|
91
|
+
print(f"Created migration: {filepath}")
|
|
92
|
+
for op in operations:
|
|
93
|
+
print(f" - {op.describe()}")
|
|
94
|
+
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cmd_migrate(args: argparse.Namespace) -> int:
|
|
99
|
+
"""Apply pending migrations."""
|
|
100
|
+
from inputlayer.client_sync import InputLayerSync
|
|
101
|
+
from inputlayer.migrations.executor import migrate
|
|
102
|
+
from inputlayer.migrations.recorder import MigrationRecorder
|
|
103
|
+
|
|
104
|
+
migrations_dir = Path(args.migrations_dir)
|
|
105
|
+
migrations = load_migrations(migrations_dir)
|
|
106
|
+
|
|
107
|
+
if not migrations:
|
|
108
|
+
print("No migrations found.")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
client = InputLayerSync(
|
|
112
|
+
args.url,
|
|
113
|
+
username=args.username,
|
|
114
|
+
password=args.password,
|
|
115
|
+
api_key=args.api_key,
|
|
116
|
+
)
|
|
117
|
+
with client:
|
|
118
|
+
kg = client.knowledge_graph(args.kg)
|
|
119
|
+
recorder = MigrationRecorder(kg)
|
|
120
|
+
applied = migrate(kg, migrations, recorder)
|
|
121
|
+
|
|
122
|
+
if applied:
|
|
123
|
+
print(f"Applied {len(applied)} migration(s):")
|
|
124
|
+
for name in applied:
|
|
125
|
+
print(f" [X] {name}")
|
|
126
|
+
else:
|
|
127
|
+
print("No migrations to apply.")
|
|
128
|
+
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cmd_revert(args: argparse.Namespace) -> int:
|
|
133
|
+
"""Revert migrations back to a target."""
|
|
134
|
+
from inputlayer.client_sync import InputLayerSync
|
|
135
|
+
from inputlayer.migrations.executor import revert_to
|
|
136
|
+
from inputlayer.migrations.recorder import MigrationRecorder
|
|
137
|
+
|
|
138
|
+
migrations_dir = Path(args.migrations_dir)
|
|
139
|
+
migrations = load_migrations(migrations_dir)
|
|
140
|
+
|
|
141
|
+
client = InputLayerSync(
|
|
142
|
+
args.url,
|
|
143
|
+
username=args.username,
|
|
144
|
+
password=args.password,
|
|
145
|
+
api_key=args.api_key,
|
|
146
|
+
)
|
|
147
|
+
with client:
|
|
148
|
+
kg = client.knowledge_graph(args.kg)
|
|
149
|
+
recorder = MigrationRecorder(kg)
|
|
150
|
+
reverted = revert_to(kg, migrations, recorder, args.target)
|
|
151
|
+
|
|
152
|
+
if reverted:
|
|
153
|
+
print(f"Reverted {len(reverted)} migration(s):")
|
|
154
|
+
for name in reverted:
|
|
155
|
+
print(f" [ ] {name}")
|
|
156
|
+
else:
|
|
157
|
+
print("Nothing to revert.")
|
|
158
|
+
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _cmd_showmigrations(args: argparse.Namespace) -> int:
|
|
163
|
+
"""Show migration status."""
|
|
164
|
+
from inputlayer.client_sync import InputLayerSync
|
|
165
|
+
from inputlayer.migrations.recorder import MigrationRecorder
|
|
166
|
+
|
|
167
|
+
migrations_dir = Path(args.migrations_dir)
|
|
168
|
+
migrations = load_migrations(migrations_dir)
|
|
169
|
+
|
|
170
|
+
if not migrations:
|
|
171
|
+
print("No migrations found.")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
client = InputLayerSync(
|
|
175
|
+
args.url,
|
|
176
|
+
username=args.username,
|
|
177
|
+
password=args.password,
|
|
178
|
+
api_key=args.api_key,
|
|
179
|
+
)
|
|
180
|
+
with client:
|
|
181
|
+
kg = client.knowledge_graph(args.kg)
|
|
182
|
+
recorder = MigrationRecorder(kg)
|
|
183
|
+
recorder.ensure_schema()
|
|
184
|
+
applied = set(recorder.get_applied())
|
|
185
|
+
|
|
186
|
+
for m in migrations:
|
|
187
|
+
mark = "X" if m.name in applied else " "
|
|
188
|
+
print(f" [{mark}] {m.name}")
|
|
189
|
+
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _add_connection_args(parser: argparse.ArgumentParser) -> None:
|
|
194
|
+
"""Add common connection arguments to a subparser."""
|
|
195
|
+
parser.add_argument("--url", required=True, help="WebSocket URL (e.g. ws://localhost:8080/ws)")
|
|
196
|
+
parser.add_argument("--kg", required=True, help="Knowledge graph name")
|
|
197
|
+
parser.add_argument("--username", default=None, help="Username for authentication")
|
|
198
|
+
parser.add_argument("--password", default=None, help="Password for authentication")
|
|
199
|
+
parser.add_argument("--api-key", dest="api_key", default=None, help="API key for authentication")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
203
|
+
"""Build the CLI argument parser."""
|
|
204
|
+
parser = argparse.ArgumentParser(
|
|
205
|
+
prog="inputlayer-migrate",
|
|
206
|
+
description="InputLayer migration management tool",
|
|
207
|
+
)
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--migrations-dir",
|
|
210
|
+
default="migrations",
|
|
211
|
+
help="Directory for migration files (default: ./migrations)",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
215
|
+
|
|
216
|
+
# makemigrations
|
|
217
|
+
make = subparsers.add_parser("makemigrations", help="Generate a new migration")
|
|
218
|
+
make.add_argument("--models", required=True, help="Python module path containing models (e.g. myapp.models)")
|
|
219
|
+
make.add_argument("--name", default=None, help="Custom migration name suffix")
|
|
220
|
+
make.set_defaults(func=_cmd_makemigrations)
|
|
221
|
+
|
|
222
|
+
# migrate
|
|
223
|
+
mig = subparsers.add_parser("migrate", help="Apply pending migrations")
|
|
224
|
+
_add_connection_args(mig)
|
|
225
|
+
mig.set_defaults(func=_cmd_migrate)
|
|
226
|
+
|
|
227
|
+
# revert
|
|
228
|
+
rev = subparsers.add_parser("revert", help="Revert migrations to a target")
|
|
229
|
+
_add_connection_args(rev)
|
|
230
|
+
rev.add_argument("target", help="Migration name to revert to (e.g. 0001_initial)")
|
|
231
|
+
rev.set_defaults(func=_cmd_revert)
|
|
232
|
+
|
|
233
|
+
# showmigrations
|
|
234
|
+
show = subparsers.add_parser("showmigrations", help="Show migration status")
|
|
235
|
+
_add_connection_args(show)
|
|
236
|
+
show.set_defaults(func=_cmd_showmigrations)
|
|
237
|
+
|
|
238
|
+
return parser
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def main(argv: list[str] | None = None) -> int:
|
|
242
|
+
"""CLI entry point."""
|
|
243
|
+
parser = build_parser()
|
|
244
|
+
args = parser.parse_args(argv)
|
|
245
|
+
|
|
246
|
+
if not hasattr(args, "func"):
|
|
247
|
+
parser.print_help()
|
|
248
|
+
return 1
|
|
249
|
+
|
|
250
|
+
return args.func(args)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
sys.exit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Migration executor - apply and revert migrations against a KG."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from inputlayer.migrations.loader import MigrationInfo
|
|
8
|
+
from inputlayer.migrations.recorder import KGExecutor, MigrationRecorder
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MigrationError(Exception):
|
|
12
|
+
"""Raised when a migration fails to apply or revert."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def apply_migration(kg: KGExecutor, migration: MigrationInfo) -> None:
|
|
16
|
+
"""Apply a single migration's forward operations."""
|
|
17
|
+
for op in migration.operations:
|
|
18
|
+
for cmd in op.forward_commands():
|
|
19
|
+
kg.execute(cmd)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def revert_migration(kg: KGExecutor, migration: MigrationInfo) -> None:
|
|
23
|
+
"""Revert a single migration's operations in reverse order."""
|
|
24
|
+
for op in reversed(migration.operations):
|
|
25
|
+
for cmd in op.backward_commands():
|
|
26
|
+
kg.execute(cmd)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def migrate(
|
|
30
|
+
kg: KGExecutor,
|
|
31
|
+
migrations: list[MigrationInfo],
|
|
32
|
+
recorder: MigrationRecorder,
|
|
33
|
+
*,
|
|
34
|
+
target: str | None = None,
|
|
35
|
+
) -> list[str]:
|
|
36
|
+
"""Apply unapplied migrations up to target (or all if target is None).
|
|
37
|
+
|
|
38
|
+
Returns list of applied migration names.
|
|
39
|
+
"""
|
|
40
|
+
recorder.ensure_schema()
|
|
41
|
+
applied = set(recorder.get_applied())
|
|
42
|
+
applied_names: list[str] = []
|
|
43
|
+
|
|
44
|
+
for m in migrations:
|
|
45
|
+
if m.name in applied:
|
|
46
|
+
continue
|
|
47
|
+
if target is not None and m.name == target:
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
apply_migration(kg, m)
|
|
51
|
+
recorder.record_applied(m.name)
|
|
52
|
+
applied_names.append(m.name)
|
|
53
|
+
|
|
54
|
+
if target is not None and m.name == target:
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
return applied_names
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def revert_to(
|
|
61
|
+
kg: KGExecutor,
|
|
62
|
+
migrations: list[MigrationInfo],
|
|
63
|
+
recorder: MigrationRecorder,
|
|
64
|
+
target: str,
|
|
65
|
+
) -> list[str]:
|
|
66
|
+
"""Revert migrations back to (but not including) target.
|
|
67
|
+
|
|
68
|
+
Returns list of reverted migration names.
|
|
69
|
+
"""
|
|
70
|
+
recorder.ensure_schema()
|
|
71
|
+
applied = set(recorder.get_applied())
|
|
72
|
+
|
|
73
|
+
# Find target index
|
|
74
|
+
target_idx = None
|
|
75
|
+
for i, m in enumerate(migrations):
|
|
76
|
+
if m.name == target:
|
|
77
|
+
target_idx = i
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
if target_idx is None:
|
|
81
|
+
raise MigrationError(f"Migration {target!r} not found")
|
|
82
|
+
|
|
83
|
+
# Revert in reverse order: everything after target that's applied
|
|
84
|
+
to_revert = [
|
|
85
|
+
m for m in reversed(migrations[target_idx + 1:])
|
|
86
|
+
if m.name in applied
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
reverted_names: list[str] = []
|
|
90
|
+
for m in to_revert:
|
|
91
|
+
revert_migration(kg, m)
|
|
92
|
+
recorder.record_reverted(m.name)
|
|
93
|
+
reverted_names.append(m.name)
|
|
94
|
+
|
|
95
|
+
return reverted_names
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Migration loader - discover and import migration files from a directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from inputlayer.migrations import Migration
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class MigrationInfo:
|
|
17
|
+
"""Metadata about a loaded migration file."""
|
|
18
|
+
|
|
19
|
+
name: str # e.g. "0001_initial"
|
|
20
|
+
number: int # e.g. 1
|
|
21
|
+
filename: str # e.g. "0001_initial.py"
|
|
22
|
+
dependencies: list[str]
|
|
23
|
+
operations: list # list[Operation]
|
|
24
|
+
state: dict[str, Any]
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def module_name(self) -> str:
|
|
28
|
+
return self.name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_MIGRATION_RE = re.compile(r"^(\d{4})_.+\.py$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_migrations(directory: str | Path) -> list[MigrationInfo]:
|
|
35
|
+
"""Discover and load all migration files from a directory.
|
|
36
|
+
|
|
37
|
+
Returns migrations sorted by number.
|
|
38
|
+
"""
|
|
39
|
+
directory = Path(directory)
|
|
40
|
+
if not directory.is_dir():
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
migrations: list[MigrationInfo] = []
|
|
44
|
+
for entry in sorted(directory.iterdir()):
|
|
45
|
+
if not entry.is_file():
|
|
46
|
+
continue
|
|
47
|
+
match = _MIGRATION_RE.match(entry.name)
|
|
48
|
+
if not match:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
number = int(match.group(1))
|
|
52
|
+
name = entry.stem # e.g. "0001_initial"
|
|
53
|
+
|
|
54
|
+
# Import the module dynamically
|
|
55
|
+
spec = importlib.util.spec_from_file_location(f"migrations.{name}", entry)
|
|
56
|
+
if spec is None or spec.loader is None:
|
|
57
|
+
continue
|
|
58
|
+
module = importlib.util.module_from_spec(spec)
|
|
59
|
+
spec.loader.exec_module(module)
|
|
60
|
+
|
|
61
|
+
# Extract the M class
|
|
62
|
+
m_cls = getattr(module, "M", None)
|
|
63
|
+
if m_cls is None or not (isinstance(m_cls, type) and issubclass(m_cls, Migration)):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
migrations.append(MigrationInfo(
|
|
67
|
+
name=name,
|
|
68
|
+
number=number,
|
|
69
|
+
filename=entry.name,
|
|
70
|
+
dependencies=list(getattr(m_cls, "dependencies", [])),
|
|
71
|
+
operations=list(getattr(m_cls, "operations", [])),
|
|
72
|
+
state=dict(getattr(m_cls, "state", {})),
|
|
73
|
+
))
|
|
74
|
+
|
|
75
|
+
return sorted(migrations, key=lambda m: m.number)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_latest_state(directory: str | Path) -> dict[str, Any]:
|
|
79
|
+
"""Get the state from the most recent migration, or empty if none exist."""
|
|
80
|
+
migrations = load_migrations(directory)
|
|
81
|
+
if not migrations:
|
|
82
|
+
return {"relations": {}, "rules": {}, "indexes": {}}
|
|
83
|
+
return migrations[-1].state
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def get_next_number(directory: str | Path) -> int:
|
|
87
|
+
"""Get the next migration number."""
|
|
88
|
+
migrations = load_migrations(directory)
|
|
89
|
+
if not migrations:
|
|
90
|
+
return 1
|
|
91
|
+
return migrations[-1].number + 1
|