inputlayer-client-dev 0.1.0.dev915__py3-none-any.whl → 0.1.0.dev921__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 CHANGED
@@ -68,8 +68,6 @@ from inputlayer.knowledge_graph import (
68
68
  WhyResult,
69
69
  )
70
70
 
71
- # Migrations
72
- from inputlayer.migrations import Migration
73
71
 
74
72
  # Notifications
75
73
  from inputlayer.notifications import NotificationEvent
@@ -116,8 +114,6 @@ __all__ = [
116
114
  "KnowledgeGraph",
117
115
  "KnowledgeGraphExistsError",
118
116
  "KnowledgeGraphNotFoundError",
119
- # Migrations
120
- "Migration",
121
117
  # Notifications
122
118
  "NotificationEvent",
123
119
  "PermissionError",
inputlayer/compiler.py CHANGED
@@ -666,6 +666,17 @@ def compile_rule(
666
666
  else:
667
667
  head_parts.append(column_to_variable(col))
668
668
 
669
+ # Compile filter conditions BEFORE building body atoms, so every column
670
+ # the condition references is registered in the env and gets bound to a
671
+ # variable in its atom. Compiling them after produced atoms with `_` for
672
+ # condition-only columns while the condition referenced an unbound
673
+ # variable - which the engine accepts and silently satisfies, deriving
674
+ # wrong results (e.g. a tier == "gold" filter matching every row).
675
+ cond_parts: list[str] = []
676
+ if condition:
677
+ cond_parts = compile_bool_expr(condition, env)
678
+ cond_parts = [p for p in cond_parts if p]
679
+
669
680
  # Build body atoms
670
681
  body_atoms: list[str] = []
671
682
  for rn, cls, alias in body_relations:
@@ -680,12 +691,6 @@ def compile_rule(
680
691
  atom_parts.append("_")
681
692
  body_atoms.append(f"{rn}({', '.join(atom_parts)})")
682
693
 
683
- # Compile filter conditions
684
- cond_parts: list[str] = []
685
- if condition:
686
- cond_parts = compile_bool_expr(condition, env)
687
- cond_parts = [p for p in cond_parts if p]
688
-
689
694
  all_body = body_atoms + cond_parts
690
695
  prefix = "+" if persistent else ""
691
696
  head_str = f"{prefix}{head_name}({', '.join(head_parts)})"
@@ -2,8 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
6
-
7
5
  from inputlayer.migrations.operations import (
8
6
  CreateIndex,
9
7
  CreateRelation,
@@ -19,22 +17,6 @@ from inputlayer.migrations.operations import (
19
17
  from inputlayer.migrations.state import ModelState
20
18
 
21
19
 
22
- class Migration:
23
- """Base class for migration files.
24
-
25
- Subclass as ``M`` in each migration file::
26
-
27
- class M(Migration):
28
- dependencies = ["0001_initial"]
29
- operations = [ops.CreateRelation(...)]
30
- state = {"relations": {...}, "rules": {...}, "indexes": {}}
31
- """
32
-
33
- dependencies: list[str] = [] # noqa: RUF012
34
- operations: list[Operation] = [] # noqa: RUF012
35
- state: dict[str, Any] = {} # noqa: RUF012
36
-
37
-
38
20
  __all__ = [
39
21
  "CreateIndex",
40
22
  "CreateRelation",
@@ -42,7 +24,6 @@ __all__ = [
42
24
  "DropIndex",
43
25
  "DropRelation",
44
26
  "DropRule",
45
- "Migration",
46
27
  "ModelState",
47
28
  "Operation",
48
29
  "ReplaceRule",
@@ -205,6 +205,20 @@ def _add_connection_args(parser: argparse.ArgumentParser) -> None:
205
205
  )
206
206
 
207
207
 
208
+ def _add_migrations_dir(sub: argparse.ArgumentParser) -> None:
209
+ """Accept --migrations-dir after the subcommand as well as before it.
210
+
211
+ Delegating callers (the `il` CLI) always place flags after the
212
+ subcommand. SUPPRESS keeps the subparser from clobbering the value the
213
+ global flag already parsed.
214
+ """
215
+ sub.add_argument(
216
+ "--migrations-dir",
217
+ default=argparse.SUPPRESS,
218
+ help="Directory for migration files (default: ./migrations)",
219
+ )
220
+
221
+
208
222
  def build_parser() -> argparse.ArgumentParser:
209
223
  """Build the CLI argument parser."""
210
224
  parser = argparse.ArgumentParser(
@@ -226,22 +240,26 @@ def build_parser() -> argparse.ArgumentParser:
226
240
  help="Python module path containing models (e.g. myapp.models)",
227
241
  )
228
242
  make.add_argument("--name", default=None, help="Custom migration name suffix")
243
+ _add_migrations_dir(make)
229
244
  make.set_defaults(func=_cmd_makemigrations)
230
245
 
231
246
  # migrate
232
247
  mig = subparsers.add_parser("migrate", help="Apply pending migrations")
233
248
  _add_connection_args(mig)
249
+ _add_migrations_dir(mig)
234
250
  mig.set_defaults(func=_cmd_migrate)
235
251
 
236
252
  # revert
237
253
  rev = subparsers.add_parser("revert", help="Revert migrations to a target")
238
254
  _add_connection_args(rev)
255
+ _add_migrations_dir(rev)
239
256
  rev.add_argument("target", help="Migration name to revert to (e.g. 0001_initial)")
240
257
  rev.set_defaults(func=_cmd_revert)
241
258
 
242
259
  # showmigrations
243
260
  show = subparsers.add_parser("showmigrations", help="Show migration status")
244
261
  _add_connection_args(show)
262
+ _add_migrations_dir(show)
245
263
  show.set_defaults(func=_cmd_showmigrations)
246
264
 
247
265
  return parser
@@ -249,6 +267,9 @@ def build_parser() -> argparse.ArgumentParser:
249
267
 
250
268
  def main(argv: list[str] | None = None) -> int:
251
269
  """CLI entry point."""
270
+ from inputlayer.exceptions import InputLayerError
271
+ from inputlayer.migrations.errors import MigrationError
272
+
252
273
  parser = build_parser()
253
274
  args = parser.parse_args(argv)
254
275
 
@@ -256,7 +277,11 @@ def main(argv: list[str] | None = None) -> int:
256
277
  parser.print_help()
257
278
  return 1
258
279
 
259
- return args.func(args)
280
+ try:
281
+ return args.func(args)
282
+ except (MigrationError, InputLayerError) as exc:
283
+ print(f"error: {exc}")
284
+ return 1
260
285
 
261
286
 
262
287
  if __name__ == "__main__":
@@ -0,0 +1,27 @@
1
+ """Migration errors and the engine-result check.
2
+
3
+ The WebSocket protocol reports engine failures as a result-set with a
4
+ single "error" column rather than an exception, so anything that must
5
+ know whether a statement actually executed has to check for that shape
6
+ explicitly. The migration stack must: silently swallowed errors are how
7
+ the recorder shipped broken (write-only applied-state) in the first
8
+ place.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+
16
+ class MigrationError(Exception):
17
+ """Raised when a migration fails to load, apply, or revert."""
18
+
19
+
20
+ def check_engine_result(result: Any, context: str) -> Any:
21
+ """Raise MigrationError if an execute() result is an engine error frame."""
22
+ columns = list(getattr(result, "columns", None) or [])
23
+ if columns == ["error"]:
24
+ rows = getattr(result, "rows", None) or []
25
+ detail = str(rows[0][0]) if rows and rows[0] else "unknown engine error"
26
+ raise MigrationError(f"engine error while {context}: {detail}")
27
+ return result
@@ -2,26 +2,35 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from inputlayer.migrations.errors import MigrationError, check_engine_result
5
6
  from inputlayer.migrations.loader import MigrationInfo
6
7
  from inputlayer.migrations.recorder import KGExecutor, MigrationRecorder
7
8
 
8
-
9
- class MigrationError(Exception):
10
- """Raised when a migration fails to apply or revert."""
9
+ __all__ = ["MigrationError", "apply_migration", "migrate", "revert_migration", "revert_to"]
11
10
 
12
11
 
13
12
  def apply_migration(kg: KGExecutor, migration: MigrationInfo) -> None:
14
- """Apply a single migration's forward operations."""
13
+ """Apply a single migration's forward operations.
14
+
15
+ Raises MigrationError on the first failing operation. Operations are
16
+ not transactional: earlier operations of this migration stay applied,
17
+ but the migration is NOT recorded as applied, so the failure is
18
+ visible and a re-run picks up where the schema actually is.
19
+ """
15
20
  for op in migration.operations:
16
21
  for cmd in op.forward_commands():
17
- kg.execute(cmd)
22
+ check_engine_result(
23
+ kg.execute(cmd), f"applying {migration.name} ({op.describe()})"
24
+ )
18
25
 
19
26
 
20
27
  def revert_migration(kg: KGExecutor, migration: MigrationInfo) -> None:
21
28
  """Revert a single migration's operations in reverse order."""
22
29
  for op in reversed(migration.operations):
23
30
  for cmd in op.backward_commands():
24
- kg.execute(cmd)
31
+ check_engine_result(
32
+ kg.execute(cmd), f"reverting {migration.name} ({op.describe()})"
33
+ )
25
34
 
26
35
 
27
36
  def migrate(
@@ -1,14 +1,13 @@
1
- """Migration loader - discover and import migration files from a directory."""
1
+ """Migration loader - discover and load JSON migration files from a directory."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import importlib.util
6
5
  import re
7
6
  from dataclasses import dataclass
8
7
  from pathlib import Path
9
8
  from typing import Any
10
9
 
11
- from inputlayer.migrations import Migration
10
+ from inputlayer.migrations.errors import MigrationError
12
11
 
13
12
 
14
13
  @dataclass
@@ -27,13 +26,56 @@ class MigrationInfo:
27
26
  return self.name
28
27
 
29
28
 
30
- _MIGRATION_RE = re.compile(r"^(\d{4})_.+\.py$")
29
+ _MIGRATION_RE = re.compile(r"^(\d{4})_.+\.json$")
30
+
31
+
32
+ def _load_json_migration(entry: Path, name: str, number: int) -> MigrationInfo:
33
+ """Load a language-neutral JSON migration (the current format)."""
34
+ import json
35
+
36
+ from inputlayer.migrations.operations import operation_from_dict
37
+ from inputlayer.migrations.writer import MIGRATION_FORMAT
38
+
39
+ try:
40
+ document = json.loads(entry.read_text(encoding="utf-8"))
41
+ except json.JSONDecodeError as exc:
42
+ raise MigrationError(f"{entry.name}: invalid JSON: {exc}") from exc
43
+ if not isinstance(document, dict):
44
+ raise MigrationError(f"{entry.name}: migration document must be a JSON object")
45
+ fmt = document.get("format")
46
+ if not isinstance(fmt, int) or fmt > MIGRATION_FORMAT:
47
+ raise MigrationError(
48
+ f"{entry.name}: unsupported migration format {fmt!r} "
49
+ f"(this SDK reads up to {MIGRATION_FORMAT} - upgrade the SDK)"
50
+ )
51
+ state = dict(document.get("state", {}))
52
+ # JSON has no tuples; restore (col, type) pairs so state comparisons in
53
+ # the autodetector see no phantom changes against in-memory model state.
54
+ if "relations" in state:
55
+ state["relations"] = {
56
+ rel: [tuple(col) for col in cols] for rel, cols in state["relations"].items()
57
+ }
58
+ try:
59
+ operations = [operation_from_dict(d) for d in document.get("operations", [])]
60
+ except (KeyError, TypeError, ValueError) as exc:
61
+ raise MigrationError(f"{entry.name}: invalid operation: {exc}") from exc
62
+ return MigrationInfo(
63
+ name=name,
64
+ number=number,
65
+ filename=entry.name,
66
+ dependencies=list(document.get("dependencies", [])),
67
+ operations=operations,
68
+ state=state,
69
+ )
31
70
 
32
71
 
33
72
  def load_migrations(directory: str | Path) -> list[MigrationInfo]:
34
73
  """Discover and load all migration files from a directory.
35
74
 
36
- Returns migrations sorted by number.
75
+ Migrations are language-neutral JSON documents; that is the only
76
+ format (pre-1.0, no legacy loading). Non-matching files - including
77
+ stray .py files from older SDKs - are ignored. Returns migrations
78
+ sorted by number.
37
79
  """
38
80
  directory = Path(directory)
39
81
  if not directory.is_dir():
@@ -50,28 +92,31 @@ def load_migrations(directory: str | Path) -> list[MigrationInfo]:
50
92
  number = int(match.group(1))
51
93
  name = entry.stem # e.g. "0001_initial"
52
94
 
53
- # Import the module dynamically
54
- spec = importlib.util.spec_from_file_location(f"migrations.{name}", entry)
55
- if spec is None or spec.loader is None:
56
- continue
57
- module = importlib.util.module_from_spec(spec)
58
- spec.loader.exec_module(module)
59
-
60
- # Extract the M class
61
- m_cls = getattr(module, "M", None)
62
- if m_cls is None or not (isinstance(m_cls, type) and issubclass(m_cls, Migration)):
63
- continue
64
-
65
- migrations.append(MigrationInfo(
66
- name=name,
67
- number=number,
68
- filename=entry.name,
69
- dependencies=list(getattr(m_cls, "dependencies", [])),
70
- operations=list(getattr(m_cls, "operations", [])),
71
- state=dict(getattr(m_cls, "state", {})),
72
- ))
73
-
74
- return sorted(migrations, key=lambda m: m.number)
95
+ migrations.append(_load_json_migration(entry, name, number))
96
+
97
+ migrations.sort(key=lambda m: m.number)
98
+
99
+ # Duplicate names or numbers are always a mistake (a stale legacy .py
100
+ # twin of a regenerated .json, or a merge collision) and previously
101
+ # caused the same migration to be applied twice - destructively for
102
+ # Drop/Replace operations. Refuse loudly.
103
+ by_name: dict[str, str] = {}
104
+ by_number: dict[int, str] = {}
105
+ for m in migrations:
106
+ if m.name in by_name:
107
+ raise MigrationError(
108
+ f"duplicate migration name '{m.name}': {by_name[m.name]} and "
109
+ f"{m.filename} - delete the stale one"
110
+ )
111
+ if m.number in by_number:
112
+ raise MigrationError(
113
+ f"duplicate migration number {m.number:04d}: "
114
+ f"{by_number[m.number]} and {m.filename} - renumber one of them"
115
+ )
116
+ by_name[m.name] = m.filename
117
+ by_number[m.number] = m.filename
118
+
119
+ return migrations
75
120
 
76
121
 
77
122
  def get_latest_state(directory: str | Path) -> dict[str, Any]:
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from datetime import datetime, timezone
6
- from typing import Protocol
6
+ from typing import Any, Protocol
7
+
8
+ from inputlayer.migrations.errors import check_engine_result
7
9
 
8
10
 
9
11
  class KGExecutor(Protocol):
@@ -12,7 +14,11 @@ class KGExecutor(Protocol):
12
14
  def execute(self, iql: str) -> object: ...
13
15
 
14
16
 
15
- MIGRATION_RELATION = "__inputlayer_migrations__"
17
+ # NOTE: must be a valid relation name (lowercase letter first). The previous
18
+ # dunder name was rejected by schema declarations but silently auto-created
19
+ # by inserts, leaving recorded state in a relation that queries could not
20
+ # read - migration status/revert never worked against a real engine.
21
+ MIGRATION_RELATION = "inputlayer_migrations"
16
22
 
17
23
 
18
24
  class MigrationRecorder:
@@ -21,24 +27,57 @@ class MigrationRecorder:
21
27
  def __init__(self, kg: KGExecutor) -> None:
22
28
  self._kg = kg
23
29
 
30
+ def _execute(self, iql: str, context: str) -> Any:
31
+ return check_engine_result(self._kg.execute(iql), context)
32
+
24
33
  def ensure_schema(self) -> None:
25
34
  """Create the migration tracking relation if it doesn't exist."""
26
- self._kg.execute(f"+{MIGRATION_RELATION}(name: string, applied_at: string)")
35
+ # The engine reports "already registered" as an error frame; that is
36
+ # the one expected, harmless outcome, so only genuinely new failures
37
+ # surface. Anything else (bad name, auth, parse) must not be
38
+ # swallowed - that is how the recorder shipped write-only once.
39
+ result = self._kg.execute(
40
+ f"+{MIGRATION_RELATION}(name: string, applied_at: string)"
41
+ )
42
+ columns = list(getattr(result, "columns", None) or [])
43
+ if columns == ["error"]:
44
+ rows = getattr(result, "rows", None) or []
45
+ detail = str(rows[0][0]) if rows and rows[0] else ""
46
+ if "exist" not in detail and "registered" not in detail:
47
+ check_engine_result(result, "creating the migration tracking relation")
27
48
 
28
49
  def get_applied(self) -> list[str]:
29
- """Return sorted list of applied migration names."""
30
- result = self._kg.execute(f"?Name, At <- {MIGRATION_RELATION}(Name, At)")
50
+ """Return sorted list of applied migration names.
51
+
52
+ Plain query form: the engine rejects `?X <- rel(...)` projections
53
+ ("Query cannot contain a rule definition"), which previously made
54
+ this return [] forever and broke status/revert/idempotent apply.
55
+ """
56
+ result = self._execute(
57
+ f"?{MIGRATION_RELATION}(Name, At)", "reading applied migrations"
58
+ )
31
59
  rows = getattr(result, "rows", []) or []
32
- return sorted(str(row[0]) for row in rows)
60
+ return sorted({str(row[0]) for row in rows})
33
61
 
34
62
  def record_applied(self, name: str) -> None:
35
63
  """Record that a migration has been applied."""
36
64
  now = datetime.now(timezone.utc).isoformat()
37
- self._kg.execute(f'+{MIGRATION_RELATION}("{name}", "{now}")')
65
+ safe = _escape(name)
66
+ self._execute(
67
+ f'+{MIGRATION_RELATION}[("{safe}", "{now}")]',
68
+ f"recording {name} as applied",
69
+ )
38
70
 
39
71
  def record_reverted(self, name: str) -> None:
40
72
  """Remove the record for a reverted migration."""
41
- self._kg.execute(
73
+ safe = _escape(name)
74
+ self._execute(
42
75
  f'-{MIGRATION_RELATION}(Name, At) <- '
43
- f'{MIGRATION_RELATION}(Name, At), Name = "{name}"'
76
+ f'{MIGRATION_RELATION}(Name, At), Name = "{safe}"',
77
+ f"recording {name} as reverted",
44
78
  )
79
+
80
+
81
+ def _escape(value: str) -> str:
82
+ """Escape a string for interpolation into an IQL string literal."""
83
+ return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -1,154 +1,21 @@
1
- """Migration file writer - generates Python source for migration files."""
1
+ """Migration file writer - generates language-neutral JSON migration files.
2
+
3
+ A migration is data, not code: typed operations (each with enough
4
+ structure to derive both apply and revert IQL) plus a state snapshot.
5
+ Any SDK language - and the il CLI natively - can read and apply them.
6
+ JSON is the only migration format.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
11
+ import json
12
+ import re
5
13
  from typing import Any
6
14
 
7
- from inputlayer.migrations.operations import (
8
- CreateIndex,
9
- CreateRelation,
10
- CreateRule,
11
- DropIndex,
12
- DropRelation,
13
- DropRule,
14
- Operation,
15
- ReplaceRule,
16
- RunIQL,
17
- )
18
-
19
-
20
- def _repr_str_list(items: list[str], indent: str = " ") -> str:
21
- """Render a list of strings as a formatted Python list."""
22
- if not items:
23
- return "[]"
24
- if len(items) == 1:
25
- return f"[{items[0]!r}]"
26
- lines = [f"{indent}{item!r}," for item in items]
27
- return "[\n" + "\n".join(lines) + f"\n{indent[:-4]}]"
28
-
15
+ from inputlayer.migrations.errors import MigrationError
16
+ from inputlayer.migrations.operations import Operation
29
17
 
30
- def _repr_columns(cols: list[tuple[str, str]], indent: str = " ") -> str:
31
- """Render column list as Python source."""
32
- if not cols:
33
- return "[]"
34
- lines = [f'{indent}("{c}", "{t}"),' for c, t in cols]
35
- return "[\n" + "\n".join(lines) + f"\n{indent[:-4]}]"
36
-
37
-
38
- def _render_operation(op: Operation) -> str:
39
- """Render a single operation as a Python constructor call."""
40
- if isinstance(op, CreateRelation):
41
- cols = _repr_columns(op.columns)
42
- return (
43
- f'ops.CreateRelation(\n'
44
- f' name="{op.name}",\n'
45
- f' columns={cols},\n'
46
- f' )'
47
- )
48
- if isinstance(op, DropRelation):
49
- cols = _repr_columns(op.columns)
50
- return (
51
- f'ops.DropRelation(\n'
52
- f' name="{op.name}",\n'
53
- f' columns={cols},\n'
54
- f' )'
55
- )
56
- if isinstance(op, CreateRule):
57
- clauses = _repr_str_list(op.clauses)
58
- return (
59
- f'ops.CreateRule(\n'
60
- f' name="{op.name}",\n'
61
- f' clauses={clauses},\n'
62
- f' )'
63
- )
64
- if isinstance(op, DropRule):
65
- clauses = _repr_str_list(op.clauses)
66
- return (
67
- f'ops.DropRule(\n'
68
- f' name="{op.name}",\n'
69
- f' clauses={clauses},\n'
70
- f' )'
71
- )
72
- if isinstance(op, ReplaceRule):
73
- old = _repr_str_list(op.old_clauses)
74
- new = _repr_str_list(op.new_clauses)
75
- return (
76
- f'ops.ReplaceRule(\n'
77
- f' name="{op.name}",\n'
78
- f' old_clauses={old},\n'
79
- f' new_clauses={new},\n'
80
- f' )'
81
- )
82
- if isinstance(op, CreateIndex):
83
- return (
84
- f'ops.CreateIndex(\n'
85
- f' name="{op.name}",\n'
86
- f' relation="{op.relation}",\n'
87
- f' column="{op.column}",\n'
88
- f' metric="{op.metric}",\n'
89
- f' m={op.m},\n'
90
- f' ef_construction={op.ef_construction},\n'
91
- f' ef_search={op.ef_search},\n'
92
- f' )'
93
- )
94
- if isinstance(op, DropIndex):
95
- return (
96
- f'ops.DropIndex(\n'
97
- f' name="{op.name}",\n'
98
- f' relation="{op.relation}",\n'
99
- f' column="{op.column}",\n'
100
- f' metric="{op.metric}",\n'
101
- f' m={op.m},\n'
102
- f' ef_construction={op.ef_construction},\n'
103
- f' ef_search={op.ef_search},\n'
104
- f' )'
105
- )
106
- if isinstance(op, RunIQL):
107
- fwd = _repr_str_list(op.forward)
108
- bwd = _repr_str_list(op.backward)
109
- return (
110
- f'ops.RunIQL(\n'
111
- f' forward={fwd},\n'
112
- f' backward={bwd},\n'
113
- f' )'
114
- )
115
- raise TypeError(f"Unknown operation type: {type(op).__name__}")
116
-
117
-
118
- def _render_state(state: dict[str, Any]) -> str:
119
- """Render state dict as formatted Python source."""
120
- lines = [" state = {"]
121
-
122
- # Relations
123
- lines.append(' "relations": {')
124
- for name, cols in sorted(state.get("relations", {}).items()):
125
- col_strs = [f'("{c}", "{t}")' for c, t in cols]
126
- lines.append(f' "{name}": [{", ".join(col_strs)}],')
127
- lines.append(" },")
128
-
129
- # Rules
130
- lines.append(' "rules": {')
131
- for name, clauses in sorted(state.get("rules", {}).items()):
132
- if len(clauses) == 1:
133
- lines.append(f' "{name}": [{clauses[0]!r}],')
134
- else:
135
- lines.append(f' "{name}": [')
136
- for c in clauses:
137
- lines.append(f" {c!r},")
138
- lines.append(" ],")
139
- lines.append(" },")
140
-
141
- # Indexes
142
- lines.append(' "indexes": {')
143
- for name, info in sorted(state.get("indexes", {}).items()):
144
- lines.append(f' "{name}": {{')
145
- for k, v in info.items():
146
- lines.append(f' "{k}": {v!r},')
147
- lines.append(" },")
148
- lines.append(" },")
149
-
150
- lines.append(" }")
151
- return "\n".join(lines)
18
+ MIGRATION_FORMAT = 1
152
19
 
153
20
 
154
21
  def generate_migration(
@@ -163,46 +30,22 @@ def generate_migration(
163
30
 
164
31
  Returns (filename, content).
165
32
  """
33
+ if name_suffix is not None and not re.fullmatch(r"[a-z0-9_]+", name_suffix):
34
+ raise MigrationError(
35
+ f"invalid migration name {name_suffix!r}: use lowercase letters, "
36
+ "digits, and underscores only"
37
+ )
166
38
  if number == 1 and name_suffix is None:
167
39
  name_suffix = "initial"
168
40
  elif name_suffix is None:
169
41
  name_suffix = "auto"
170
42
 
171
- filename = f"{number:04d}_{name_suffix}.py"
172
-
173
- # Build content
174
- lines = [
175
- f"# Migration: {filename}",
176
- "# Auto-generated by inputlayer-migrate",
177
- "",
178
- "from inputlayer.migrations import Migration",
179
- "from inputlayer.migrations import operations as ops",
180
- "",
181
- "",
182
- "class M(Migration):",
183
- ]
184
-
185
- # Dependencies
186
- if dependencies:
187
- deps_str = ", ".join(f'"{d}"' for d in dependencies)
188
- lines.append(f" dependencies = [{deps_str}]")
189
- else:
190
- lines.append(" dependencies = []")
191
- lines.append("")
192
-
193
- # Operations
194
- if operations:
195
- lines.append(" operations = [")
196
- for op in operations:
197
- rendered = _render_operation(op)
198
- lines.append(f" {rendered},")
199
- lines.append(" ]")
200
- else:
201
- lines.append(" operations = []")
202
- lines.append("")
203
-
204
- # State
205
- lines.append(_render_state(state))
206
- lines.append("")
43
+ filename = f"{number:04d}_{name_suffix}.json"
207
44
 
208
- return filename, "\n".join(lines)
45
+ document = {
46
+ "format": MIGRATION_FORMAT,
47
+ "dependencies": dependencies,
48
+ "operations": [op.to_dict() for op in operations],
49
+ "state": state,
50
+ }
51
+ return filename, json.dumps(document, indent=2, sort_keys=False) + "\n"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: inputlayer-client-dev
3
- Version: 0.1.0.dev915
3
+ Version: 0.1.0.dev921
4
4
  Summary: Python Object-Logic Mapper for InputLayer knowledge graph engine
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -51,7 +51,7 @@ pip install inputlayer-client-dev[all] # everything
51
51
 
52
52
  Requirements: Python 3.10+ and a running InputLayer server.
53
53
 
54
- This also installs the `il` CLI for managing schema migrations.
54
+ This also installs the `inputlayer-migrate` tool for schema migrations; with the InputLayer product CLI installed, use it as `il migration <verb>`.
55
55
 
56
56
  ## Quick Start
57
57
 
@@ -425,16 +425,16 @@ The SDK includes a Django-style migration system for production schema managemen
425
425
 
426
426
  ```bash
427
427
  # Generate a migration from your models
428
- il makemigrations --models myapp.models
428
+ il migration generate --models myapp.models
429
429
 
430
430
  # Apply pending migrations
431
- il migrate --url ws://localhost:8080/ws --kg production
431
+ il migration apply --url ws://localhost:8080/ws --kg production
432
432
 
433
433
  # Check status
434
- il showmigrations --url ws://localhost:8080/ws --kg production
434
+ il migration status --url ws://localhost:8080/ws --kg production
435
435
 
436
436
  # Rollback
437
- il revert --url ws://localhost:8080/ws --kg production 0001_initial
437
+ il migration revert --url ws://localhost:8080/ws --kg production 0001_initial
438
438
  ```
439
439
 
440
440
  The autodetector diffs your current Python models against the last migration's state and generates the minimal set of operations (create/drop relations, create/drop/replace rules, create/drop indexes). Each migration file is self-contained with a full state snapshot.
@@ -519,10 +519,10 @@ The autodetector diffs your current Python models against the last migration's s
519
519
 
520
520
  | Command | Description |
521
521
  |---------|-------------|
522
- | `il makemigrations --models <module>` | Generate migration from model diff |
523
- | `il migrate --url <ws> --kg <name>` | Apply pending migrations |
524
- | `il revert --url <ws> --kg <name> <target>` | Revert to a target migration |
525
- | `il showmigrations --url <ws> --kg <name>` | Show applied/pending status |
522
+ | `il migration generate --models <module>` | Generate migration from model diff |
523
+ | `il migration apply --url <ws> --kg <name>` | Apply pending migrations |
524
+ | `il migration revert --url <ws> --kg <name> <target>` | Revert to a target migration |
525
+ | `il migration status --url <ws> --kg <name>` | Show applied/pending status |
526
526
 
527
527
  ### Aggregation Functions
528
528
 
@@ -1,4 +1,4 @@
1
- inputlayer/__init__.py,sha256=Pj9Ix9f2pSDObrcI0jDVb6Xy66PBp8pTiTW25drYNyw,3175
1
+ inputlayer/__init__.py,sha256=BkzTEu2UVsC1Rl2d0uZd5HVIdGGLICZz30RLdUhppqY,3084
2
2
  inputlayer/_ast.py,sha256=NjO0JaNbgWAPzpFRRkfbvRGfTcyFZiqi93Y5Oni-H7M,4257
3
3
  inputlayer/_naming.py,sha256=TXZj6AfiNRQQIKqHtCOm05_5OUR8mFQDu4eNqEeaBek,1212
4
4
  inputlayer/_protocol.py,sha256=-P0MgP2Vhv5OT9UdA8iDuEJPlU-whJO5x_H7qsWlV2M,6743
@@ -8,7 +8,7 @@ inputlayer/aggregations.py,sha256=qQm9PUxTZGCIfCThexrD3wa71327pshI9wJCLkFJI74,29
8
8
  inputlayer/auth.py,sha256=Vp3Jkx9cQUTjPX31cpMIIkPDoBWmjalg1kcyxeYl8pA,3249
9
9
  inputlayer/client.py,sha256=U8zBp0srmTFmzFY626IbrXtlaXbxlWj5Cj_R1vtD17I,7584
10
10
  inputlayer/client_sync.py,sha256=Dz38mRQT3Mc7wwmOYgVQP9dBCVgMFIESATmy7fBYAjc,8346
11
- inputlayer/compiler.py,sha256=3ELVDd0dRNCSLjvQcUQ7paFdqFra5sznWGrrTfvhCwg,24378
11
+ inputlayer/compiler.py,sha256=5YMFZO6sl6lZ63fUH2bCb-PEl1QK_sbB1ntdHRjmi-k,24792
12
12
  inputlayer/connection.py,sha256=tYbRH5Q1OzR1KfguSEhfZ1LXTeX708OIdp-yatbMeoA,13772
13
13
  inputlayer/derived.py,sha256=ngjuYmKY88ZSnmb4RiTB2bDEr9buzoM15J6NdKqhSNo,4793
14
14
  inputlayer/exceptions.py,sha256=6b2WEkpfLnf02MLyXzXA8ohUqIwXFdo4RDWw46FINH8,2712
@@ -38,17 +38,18 @@ inputlayer/integrations/langgraph/memory.py,sha256=FSNm6d2MDMiJPeKWqXCH02J6_aWXT
38
38
  inputlayer/integrations/langgraph/nodes.py,sha256=BlozF5M8JwfReIhUlELSRUCJ0GNEmryBQAE3r_k_VXM,7193
39
39
  inputlayer/integrations/langgraph/router.py,sha256=yBufX0E92MpMhG2UPYyDAyUA2ynQGyknHrMTDzae3MM,5831
40
40
  inputlayer/integrations/langgraph/state.py,sha256=fApBzS7MwfROww487NNQuOj4pxj5CaEWO1pwielwtro,1149
41
- inputlayer/migrations/__init__.py,sha256=iSOQi_p51td4kHimHdGdVtfvLV6i7hYiZhAMrFx8IlQ,1098
41
+ inputlayer/migrations/__init__.py,sha256=tQmzYSv5C0ijse2sbacgskqMMH9bhBSlPyS6Pw0MLXw,597
42
42
  inputlayer/migrations/autodetector.py,sha256=PdJucKS25WCzzlQB-rS6OCEE-rWAixsGr8IzCFIkUo4,4207
43
- inputlayer/migrations/cli.py,sha256=5MZG3yhplSJiClDeannfR1GTqgg6Y52MFLq5h6ghehI,7901
44
- inputlayer/migrations/executor.py,sha256=wCkXkVGDBOLHIHOAhMaXR1ZFDdzKcdBZIxmc4yMZx7A,2535
45
- inputlayer/migrations/loader.py,sha256=fqma6D58N04podRndx1zcHsMlA6UVsIiWbvn6Rw7PQk,2659
43
+ inputlayer/migrations/cli.py,sha256=7ui7Vuboq_jpEfeM7Tf1s4hBTaE2Kam5eq22ivKKIvs,8731
44
+ inputlayer/migrations/errors.py,sha256=_gmlVatOAlYzjRvb3rN2PDYMyRPBar9eEqt8TOiLftQ,1025
45
+ inputlayer/migrations/executor.py,sha256=vj10XkAHsz0iNGQsbrIqaqKC3BXlpHn2NAblj8k5E2E,3094
46
+ inputlayer/migrations/loader.py,sha256=fMqhIcDeYauJpEwxVp_IPxVc08-LsAW791M-rJQzbLM,4704
46
47
  inputlayer/migrations/operations.py,sha256=_qiguq0GFcXVZgIJa4vHu7eeKSa5BUh2maDcex9X61M,8420
47
- inputlayer/migrations/recorder.py,sha256=2LZyhifIyultiNLLKmUnqmi9voKtDfmOYVDmjC5hNxs,1499
48
+ inputlayer/migrations/recorder.py,sha256=qARvQKL_s84vWTEEuHFAmVPvAZiTYCW4X-HtLIdpnbA,3282
48
49
  inputlayer/migrations/state.py,sha256=BH5_ly9I9bVPn1lvhqS_UWS7NvYg2iCLJrbG50-mwlQ,3695
49
- inputlayer/migrations/writer.py,sha256=XbQN5t5H538AASStAQ9jkrveYZwf2aFEFiXZsifUWI0,6465
50
- inputlayer_client_dev-0.1.0.dev915.dist-info/METADATA,sha256=yT9_etRi-yTz84DceEAv8rrP-GOjyjoQITR1qG4OTBE,19810
51
- inputlayer_client_dev-0.1.0.dev915.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
52
- inputlayer_client_dev-0.1.0.dev915.dist-info/entry_points.txt,sha256=Y90cH3HHrT10BztOigvvD1WdNIt_RYR6k6IsVbs_A8U,54
53
- inputlayer_client_dev-0.1.0.dev915.dist-info/licenses/LICENSE,sha256=zed5l9sdToIBMxqe_4I7nX1NTIlqxzT_PBO3rj7-ax4,11281
54
- inputlayer_client_dev-0.1.0.dev915.dist-info/RECORD,,
50
+ inputlayer/migrations/writer.py,sha256=4EZjKTu10pry-3vVchg36I9aNHs-zavLzJeDbZd2rko,1497
51
+ inputlayer_client_dev-0.1.0.dev921.dist-info/METADATA,sha256=j6xsBI7GizHy4JBZes1QFLTzWnwz6VYlAcuuR_mhfDc,19942
52
+ inputlayer_client_dev-0.1.0.dev921.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
53
+ inputlayer_client_dev-0.1.0.dev921.dist-info/entry_points.txt,sha256=xEo6y283VSdmZ4G_KSh1-Cpl3ZPmgM6x9OLT8VJcrqs,70
54
+ inputlayer_client_dev-0.1.0.dev921.dist-info/licenses/LICENSE,sha256=zed5l9sdToIBMxqe_4I7nX1NTIlqxzT_PBO3rj7-ax4,11281
55
+ inputlayer_client_dev-0.1.0.dev921.dist-info/RECORD,,
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ inputlayer-migrate = inputlayer.migrations.cli:main
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- il = inputlayer.migrations.cli:main