inputlayer-client-dev 0.1.0.dev913__py3-none-any.whl → 0.1.0.dev919__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",
@@ -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,8 +1,9 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: inputlayer-client-dev
3
- Version: 0.1.0.dev913
3
+ Version: 0.1.0.dev919
4
4
  Summary: Python Object-Logic Mapper for InputLayer knowledge graph engine
5
5
  License-Expression: Apache-2.0
6
+ License-File: LICENSE
6
7
  Requires-Python: >=3.10
7
8
  Requires-Dist: pydantic<3,>=2.0
8
9
  Requires-Dist: websockets<15,>=12.0
@@ -50,7 +51,7 @@ pip install inputlayer-client-dev[all] # everything
50
51
 
51
52
  Requirements: Python 3.10+ and a running InputLayer server.
52
53
 
53
- 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>`.
54
55
 
55
56
  ## Quick Start
56
57
 
@@ -424,16 +425,16 @@ The SDK includes a Django-style migration system for production schema managemen
424
425
 
425
426
  ```bash
426
427
  # Generate a migration from your models
427
- il makemigrations --models myapp.models
428
+ il migration generate --models myapp.models
428
429
 
429
430
  # Apply pending migrations
430
- il migrate --url ws://localhost:8080/ws --kg production
431
+ il migration apply --url ws://localhost:8080/ws --kg production
431
432
 
432
433
  # Check status
433
- il showmigrations --url ws://localhost:8080/ws --kg production
434
+ il migration status --url ws://localhost:8080/ws --kg production
434
435
 
435
436
  # Rollback
436
- il revert --url ws://localhost:8080/ws --kg production 0001_initial
437
+ il migration revert --url ws://localhost:8080/ws --kg production 0001_initial
437
438
  ```
438
439
 
439
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.
@@ -518,10 +519,10 @@ The autodetector diffs your current Python models against the last migration's s
518
519
 
519
520
  | Command | Description |
520
521
  |---------|-------------|
521
- | `il makemigrations --models <module>` | Generate migration from model diff |
522
- | `il migrate --url <ws> --kg <name>` | Apply pending migrations |
523
- | `il revert --url <ws> --kg <name> <target>` | Revert to a target migration |
524
- | `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 |
525
526
 
526
527
  ### Aggregation Functions
527
528
 
@@ -572,4 +573,4 @@ uv run mypy src/inputlayer/
572
573
 
573
574
  ## License
574
575
 
575
- Apache 2.0 + Commons Clause. See [LICENSE](../../LICENSE).
576
+ Apache 2.0. See [LICENSE](./LICENSE). (The InputLayer core server is separately licensed under the Elastic License 2.0.)
@@ -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
@@ -38,16 +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.dev913.dist-info/METADATA,sha256=w78ZmmnmDRXK9i9sIxeZ6LbYAZAW2qO4EDtxx1rqqlk,19726
51
- inputlayer_client_dev-0.1.0.dev913.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
52
- inputlayer_client_dev-0.1.0.dev913.dist-info/entry_points.txt,sha256=Y90cH3HHrT10BztOigvvD1WdNIt_RYR6k6IsVbs_A8U,54
53
- inputlayer_client_dev-0.1.0.dev913.dist-info/RECORD,,
50
+ inputlayer/migrations/writer.py,sha256=4EZjKTu10pry-3vVchg36I9aNHs-zavLzJeDbZd2rko,1497
51
+ inputlayer_client_dev-0.1.0.dev919.dist-info/METADATA,sha256=Phc3S1tyjDGytvgk6NofH6D6zjZU5CYrDMGdaBkKPXI,19942
52
+ inputlayer_client_dev-0.1.0.dev919.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
53
+ inputlayer_client_dev-0.1.0.dev919.dist-info/entry_points.txt,sha256=xEo6y283VSdmZ4G_KSh1-Cpl3ZPmgM6x9OLT8VJcrqs,70
54
+ inputlayer_client_dev-0.1.0.dev919.dist-info/licenses/LICENSE,sha256=zed5l9sdToIBMxqe_4I7nX1NTIlqxzT_PBO3rj7-ax4,11281
55
+ inputlayer_client_dev-0.1.0.dev919.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ inputlayer-migrate = inputlayer.migrations.cli:main
@@ -0,0 +1,203 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to the Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by the Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding any notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. Please also get in touch with
186
+ us at legal@inputlayer.ai to let us know you're using the license.
187
+
188
+ Copyright 2024-2026 InputLayer
189
+
190
+ Licensed under the Apache License, Version 2.0 (the "License");
191
+ you may not use this file except in compliance with the License.
192
+ You may obtain a copy of the License at
193
+
194
+ http://www.apache.org/licenses/LICENSE-2.0
195
+
196
+ Unless required by applicable law or agreed to in writing, software
197
+ distributed under the License is distributed on an "AS IS" BASIS,
198
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
199
+ See the License for the specific language governing permissions and
200
+ limitations under the License.
201
+
202
+
203
+ Copyright 2024-2026 InputLayer
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- il = inputlayer.migrations.cli:main