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.
@@ -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