runic-py 0.2.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.
Files changed (47) hide show
  1. runic/__init__.py +13 -0
  2. runic/migrate/__init__.py +27 -0
  3. runic/migrate/adapters/__init__.py +129 -0
  4. runic/migrate/adapters/falkordb.py +353 -0
  5. runic/migrate/autogen.py +242 -0
  6. runic/migrate/checksum.py +14 -0
  7. runic/migrate/cli.py +880 -0
  8. runic/migrate/config.py +8 -0
  9. runic/migrate/context.py +526 -0
  10. runic/migrate/exceptions.py +18 -0
  11. runic/migrate/introspect.py +556 -0
  12. runic/migrate/manifest.py +81 -0
  13. runic/migrate/operations.py +173 -0
  14. runic/migrate/script.py +452 -0
  15. runic/migrate/service.py +27 -0
  16. runic/migrate/templates/env.py.mako +86 -0
  17. runic/migrate/templates/script.py.mako +27 -0
  18. runic/migrate/testing.py +48 -0
  19. runic/migrate/version.py +38 -0
  20. runic/orm/__init__.py +82 -0
  21. runic/orm/core/__init__.py +0 -0
  22. runic/orm/core/descriptors.py +305 -0
  23. runic/orm/core/metadata.py +216 -0
  24. runic/orm/core/models.py +312 -0
  25. runic/orm/core/types.py +158 -0
  26. runic/orm/exceptions.py +30 -0
  27. runic/orm/mapper/__init__.py +0 -0
  28. runic/orm/mapper/mapper.py +361 -0
  29. runic/orm/mapper/relationship_loader.py +251 -0
  30. runic/orm/repository/__init__.py +12 -0
  31. runic/orm/repository/async_repository.py +180 -0
  32. runic/orm/repository/cypher.py +67 -0
  33. runic/orm/repository/pagination.py +105 -0
  34. runic/orm/repository/protocol.py +91 -0
  35. runic/orm/repository/repository.py +190 -0
  36. runic/orm/schema/__init__.py +11 -0
  37. runic/orm/schema/index_manager.py +195 -0
  38. runic/orm/schema/schema_manager.py +173 -0
  39. runic/orm/session/__init__.py +0 -0
  40. runic/orm/session/async_session.py +340 -0
  41. runic/orm/session/connection_pool.py +56 -0
  42. runic/orm/session/session.py +385 -0
  43. runic_py-0.2.0.dist-info/METADATA +192 -0
  44. runic_py-0.2.0.dist-info/RECORD +47 -0
  45. runic_py-0.2.0.dist-info/WHEEL +4 -0
  46. runic_py-0.2.0.dist-info/entry_points.txt +2 -0
  47. runic_py-0.2.0.dist-info/licenses/LICENSE.md +21 -0
runic/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ from runic.migrate.exceptions import (
2
+ MultipleBasesError,
3
+ MultipleHeadsError,
4
+ ConstraintFailedError,
5
+ ConstraintTimeoutError,
6
+ )
7
+
8
+ __all__ = [
9
+ "ConstraintFailedError",
10
+ "ConstraintTimeoutError",
11
+ "MultipleBasesError",
12
+ "MultipleHeadsError",
13
+ ]
@@ -0,0 +1,27 @@
1
+ from runic.migrate import context
2
+ from runic.migrate.context import IrreversibleMigrationError, Runic
3
+ from runic.migrate.manifest import (
4
+ FulltextIndex,
5
+ MandatoryConstraint,
6
+ RangeIndex,
7
+ SchemaManifest,
8
+ UniqueConstraint,
9
+ VectorIndex,
10
+ )
11
+ from runic.migrate.script import AmbiguousRevision, RevisionNotFound
12
+ from runic.migrate.service import init
13
+
14
+ __all__ = [
15
+ "AmbiguousRevision",
16
+ "FulltextIndex",
17
+ "IrreversibleMigrationError",
18
+ "MandatoryConstraint",
19
+ "RangeIndex",
20
+ "RevisionNotFound",
21
+ "Runic",
22
+ "SchemaManifest",
23
+ "UniqueConstraint",
24
+ "VectorIndex",
25
+ "context",
26
+ "init",
27
+ ]
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Protocol, runtime_checkable
4
+
5
+ from runic.migrate.introspect import LiveSchema
6
+
7
+
8
+ @runtime_checkable
9
+ class GraphAdapter(Protocol):
10
+ """Protocol all graph-database adapters must satisfy.
11
+
12
+ The runic core depends only on this interface — no FalkorDB or any other
13
+ concrete database client leaks into shared code.
14
+
15
+ Note: ``LiveSchema`` (returned by ``read_live_schema``) is currently parsed
16
+ from FalkorDB's ``CALL db.indexes()`` / ``CALL db.constraints()`` output in
17
+ ``runic.migrate.introspect``. A future adapter must override ``read_live_schema``
18
+ and may supply its own introspection logic.
19
+ """
20
+
21
+ @property
22
+ def name(self) -> str: ...
23
+
24
+ # Low-level query execution
25
+ def run_query(self, query: str, params: dict | None = None) -> Any: ...
26
+ def run_ro_query(self, query: str) -> Any: ...
27
+
28
+ # Sibling adapter for a different graph/database on the same connection
29
+ def fork(self, graph_name: str) -> GraphAdapter: ...
30
+
31
+ # Version tracking
32
+ def get_version(self) -> list[str]: ...
33
+ def set_version(self, revisions: list[str]) -> None: ...
34
+
35
+ # Schema introspection
36
+ def read_live_schema(self) -> LiveSchema: ...
37
+
38
+ # Schema DDL
39
+ def create_range_index(
40
+ self, label: str, prop: str, *, rel: bool = False
41
+ ) -> None: ...
42
+ def drop_range_index(self, label: str, prop: str, *, rel: bool = False) -> None: ...
43
+ def create_fulltext_index(
44
+ self,
45
+ label: str,
46
+ *props: str,
47
+ language: str | None = None,
48
+ stopwords: list[str] | None = None,
49
+ ) -> None: ...
50
+ def drop_fulltext_index(self, label: str, *props: str) -> None: ...
51
+ def create_vector_index(
52
+ self,
53
+ label: str,
54
+ prop: str,
55
+ dimension: int,
56
+ similarity: str,
57
+ *,
58
+ m: int = 16,
59
+ ef_construction: int = 200,
60
+ ef_runtime: int = 10,
61
+ ) -> None: ...
62
+ def drop_vector_index(self, label: str, prop: str) -> None: ...
63
+ def create_constraint(
64
+ self, kind: str, entity: str, label: str, props: list[str]
65
+ ) -> None: ...
66
+ def drop_constraint(
67
+ self, kind: str, entity: str, label: str, props: list[str]
68
+ ) -> None: ...
69
+
70
+ # Lifecycle
71
+ def delete_graph(self) -> None: ...
72
+
73
+ # Snapshots
74
+ def snapshot(self, snap_name: str) -> None: ...
75
+ def restore_snapshot(self, snap_name: str) -> None: ...
76
+ def snapshot_exists(self, snap_name: str) -> bool: ...
77
+
78
+ # Checksum & attribution tracking
79
+ def get_checksums(self) -> dict[str, str]: ...
80
+ def set_checksum(
81
+ self, rev_id: str, checksum: str, installed_by: str | None = None
82
+ ) -> None: ...
83
+ def get_installed_by(self) -> dict[str, str]: ...
84
+
85
+
86
+ def create_adapter(backend: str, **kwargs: Any) -> GraphAdapter:
87
+ """Instantiate a named adapter from keyword arguments.
88
+
89
+ Two connection variants are supported for ``"falkordb"``:
90
+
91
+ **URL variant** — credentials embedded in the connection string::
92
+
93
+ create_adapter(
94
+ "falkordb", url="falkor://:mypassword@localhost:6379", graph_name="my_graph"
95
+ )
96
+
97
+ **Params variant** — explicit host/port/auth kwargs::
98
+
99
+ create_adapter(
100
+ "falkordb",
101
+ host="localhost",
102
+ port=6379,
103
+ username="myuser",
104
+ password="mypassword",
105
+ graph_name="my_graph",
106
+ )
107
+ """
108
+ if backend == "falkordb":
109
+ from runic.migrate.adapters.falkordb import FalkorDBAdapter
110
+
111
+ graph_name = kwargs["graph_name"]
112
+ if "url" in kwargs:
113
+ return FalkorDBAdapter.from_url(
114
+ kwargs["url"],
115
+ graph_name,
116
+ username=kwargs.get("username"),
117
+ password=kwargs.get("password"),
118
+ )
119
+ return FalkorDBAdapter.from_params(
120
+ graph_name,
121
+ host=kwargs.get("host", "localhost"),
122
+ port=int(kwargs.get("port", 6379)),
123
+ username=kwargs.get("username"),
124
+ password=kwargs.get("password"),
125
+ )
126
+ raise KeyError(f"Unknown adapter backend {backend!r}. Supported: 'falkordb'")
127
+
128
+
129
+ __all__ = ["GraphAdapter", "create_adapter"]
@@ -0,0 +1,353 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from typing import Any
6
+
7
+ from runic.migrate import introspect
8
+ from runic.migrate.adapters import GraphAdapter
9
+ from runic.migrate.exceptions import ConstraintFailedError, ConstraintTimeoutError
10
+ from runic.migrate.introspect import LiveSchema
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ _POLL_RETRIES = 30
15
+ _POLL_INTERVAL = 0.5
16
+
17
+ _VERSION_LABEL = "_FalkorMigrateVersion"
18
+ _GET_VERSION_QUERY = f"MATCH (v:{_VERSION_LABEL}) RETURN v.revisions, v.revision"
19
+ _SET_VERSION_QUERY = (
20
+ f"MERGE (v:{_VERSION_LABEL} {{singleton: true}})"
21
+ " SET v.revisions = $revisions, v.applied_at = timestamp()"
22
+ )
23
+ _GET_TRACKING_QUERY = f"MATCH (v:{_VERSION_LABEL}) RETURN v.checksums, v.installed_by"
24
+ _SET_TRACKING_QUERY = (
25
+ f"MERGE (v:{_VERSION_LABEL} {{singleton: true}})"
26
+ " SET v.checksums = $checksums, v.installed_by = $installed_by"
27
+ )
28
+
29
+
30
+ def _parse_kv_list(items: list | None) -> dict[str, str]:
31
+ if not items:
32
+ return {}
33
+ result: dict[str, str] = {}
34
+ for item in items:
35
+ if item:
36
+ k, _, v = str(item).partition(":")
37
+ result[k] = v
38
+ return result
39
+
40
+
41
+ def _encode_kv_list(d: dict[str, str]) -> list[str]:
42
+ return [f"{k}:{v}" for k, v in d.items()]
43
+
44
+
45
+ class FalkorDBAdapter(GraphAdapter):
46
+ """GraphAdapter implementation for FalkorDB (standalone or embedded via falkordblite)."""
47
+
48
+ def __init__(self, db: Any, graph: Any) -> None:
49
+ self._db = db
50
+ self._graph = graph
51
+
52
+ @classmethod
53
+ def from_url(
54
+ cls,
55
+ url: str,
56
+ graph_name: str,
57
+ *,
58
+ username: str | None = None,
59
+ password: str | None = None,
60
+ ) -> FalkorDBAdapter:
61
+ from falkordb import FalkorDB
62
+
63
+ kwargs: dict = {"protocol": 2}
64
+ if username is not None:
65
+ kwargs["username"] = username
66
+ if password is not None:
67
+ kwargs["password"] = password
68
+ db = FalkorDB.from_url(url, **kwargs)
69
+ return cls(db, db.select_graph(graph_name))
70
+
71
+ @classmethod
72
+ def from_params(
73
+ cls,
74
+ graph_name: str,
75
+ *,
76
+ host: str = "localhost",
77
+ port: int = 6379,
78
+ username: str | None = None,
79
+ password: str | None = None,
80
+ ) -> FalkorDBAdapter:
81
+ from falkordb import FalkorDB
82
+
83
+ kwargs: dict = {"host": host, "port": port}
84
+ if username is not None:
85
+ kwargs["username"] = username
86
+ if password is not None:
87
+ kwargs["password"] = password
88
+ db = FalkorDB(**kwargs)
89
+ return cls(db, db.select_graph(graph_name))
90
+
91
+ def fork(self, graph_name: str) -> FalkorDBAdapter:
92
+ """Return a sibling adapter on the same connection for a different graph name."""
93
+ return FalkorDBAdapter(self._db, self._db.select_graph(graph_name))
94
+
95
+ # ------------------------------------------------------------------
96
+ # GraphAdapter Protocol
97
+ # ------------------------------------------------------------------
98
+
99
+ @property
100
+ def name(self) -> str:
101
+ return self._graph.name # type: ignore[no-any-return]
102
+
103
+ def run_query(self, query: str, params: dict | None = None) -> Any:
104
+ return self._graph.query(query, params) if params else self._graph.query(query)
105
+
106
+ def run_ro_query(self, query: str) -> Any:
107
+ return self._graph.ro_query(query)
108
+
109
+ def run_command(self, *args: Any) -> Any:
110
+ return self._db.execute_command(*args)
111
+
112
+ # ------------------------------------------------------------------
113
+ # Version tracking
114
+ # ------------------------------------------------------------------
115
+
116
+ def get_version(self) -> list[str]:
117
+ try:
118
+ result = self._graph.ro_query(_GET_VERSION_QUERY)
119
+ except Exception as exc:
120
+ if "empty key" in str(exc).lower():
121
+ return []
122
+ raise
123
+ rows = result.result_set
124
+ if not rows:
125
+ return []
126
+ row = rows[0]
127
+ col0 = row[0]
128
+ col1 = row[1] if len(row) > 1 else None
129
+
130
+ if isinstance(col0, list):
131
+ return [r for r in col0 if r is not None]
132
+ if isinstance(col0, str):
133
+ return [col0]
134
+ if isinstance(col1, str):
135
+ return [col1]
136
+ return []
137
+
138
+ def set_version(self, revisions: list[str]) -> None:
139
+ log.info("stamping versions: %s", revisions)
140
+ self._graph.query(_SET_VERSION_QUERY, {"revisions": revisions})
141
+
142
+ # ------------------------------------------------------------------
143
+ # Schema introspection
144
+ # ------------------------------------------------------------------
145
+
146
+ def read_live_schema(self) -> LiveSchema:
147
+ return introspect.read_live_schema(self._graph)
148
+
149
+ # ------------------------------------------------------------------
150
+ # Schema DDL — range indexes
151
+ # ------------------------------------------------------------------
152
+
153
+ def create_range_index(self, label: str, prop: str, *, rel: bool = False) -> None:
154
+ if rel:
155
+ query = f"CREATE INDEX FOR ()-[r:{label}]->() ON (r.{prop})"
156
+ else:
157
+ query = f"CREATE INDEX FOR (n:{label}) ON (n.{prop})"
158
+ log.info("creating range index on %s.%s", label, prop)
159
+ self._graph.query(query)
160
+
161
+ def drop_range_index(self, label: str, prop: str, *, rel: bool = False) -> None: # noqa: ARG002
162
+ query = f"DROP INDEX ON :{label}({prop})"
163
+ log.info("dropping range index on %s.%s", label, prop)
164
+ self._graph.query(query)
165
+
166
+ # ------------------------------------------------------------------
167
+ # Schema DDL — fulltext indexes
168
+ # ------------------------------------------------------------------
169
+
170
+ def create_fulltext_index(
171
+ self,
172
+ label: str,
173
+ *props: str,
174
+ language: str | None = None,
175
+ stopwords: list[str] | None = None,
176
+ ) -> None:
177
+ if language or stopwords:
178
+ map_parts = [f"label: '{label}'"]
179
+ if language:
180
+ map_parts.append(f"language: '{language}'")
181
+ if stopwords:
182
+ sw = "[" + ", ".join(f"'{w}'" for w in stopwords) + "]"
183
+ map_parts.append(f"stopwords: {sw}")
184
+ map_literal = "{" + ", ".join(map_parts) + "}"
185
+ props_str = ", ".join(f"'{p}'" for p in props)
186
+ query = f"CALL db.idx.fulltext.createNodeIndex({map_literal}, {props_str})"
187
+ else:
188
+ props_str = ", ".join(f"'{p}'" for p in props)
189
+ query = f"CALL db.idx.fulltext.createNodeIndex('{label}', {props_str})"
190
+ log.info("creating fulltext index on %s %s", label, list(props))
191
+ self._graph.query(query)
192
+
193
+ def drop_fulltext_index(self, label: str, *props: str) -> None:
194
+ log.info("dropping fulltext index on %s %s", label, list(props))
195
+ for prop in props:
196
+ query = f"DROP FULLTEXT INDEX FOR (n:{label}) ON (n.{prop})"
197
+ self._graph.query(query)
198
+
199
+ # ------------------------------------------------------------------
200
+ # Schema DDL — vector indexes
201
+ # ------------------------------------------------------------------
202
+
203
+ def create_vector_index(
204
+ self,
205
+ label: str,
206
+ prop: str,
207
+ dimension: int,
208
+ similarity: str,
209
+ *,
210
+ m: int = 16,
211
+ ef_construction: int = 200,
212
+ ef_runtime: int = 10,
213
+ ) -> None:
214
+ options = (
215
+ f"{{dimension: {dimension}, similarityFunction: '{similarity}', "
216
+ f"M: {m}, efConstruction: {ef_construction}, efRuntime: {ef_runtime}}}"
217
+ )
218
+ query = f"CREATE VECTOR INDEX FOR (n:{label}) ON (n.{prop}) OPTIONS {options}"
219
+ log.info("creating vector index on %s.%s", label, prop)
220
+ self._graph.query(query)
221
+
222
+ def drop_vector_index(self, label: str, prop: str) -> None:
223
+ query = f"DROP VECTOR INDEX FOR (n:{label}) (n.{prop})"
224
+ log.info("dropping vector index on %s.%s", label, prop)
225
+ self._graph.query(query)
226
+
227
+ # ------------------------------------------------------------------
228
+ # Schema DDL — constraints
229
+ # ------------------------------------------------------------------
230
+
231
+ def create_constraint(
232
+ self, kind: str, entity: str, label: str, props: list[str]
233
+ ) -> None:
234
+ if kind == "UNIQUE":
235
+ for prop in props:
236
+ self.create_range_index(label, prop)
237
+ prop_count = str(len(props))
238
+ log.info("creating %s constraint on %s %s %s", kind, entity, label, props)
239
+ self._db.execute_command(
240
+ "GRAPH.CONSTRAINT",
241
+ "CREATE",
242
+ label,
243
+ kind,
244
+ entity,
245
+ label,
246
+ "PROPERTIES",
247
+ prop_count,
248
+ *props,
249
+ )
250
+ self._poll_constraint(label, props)
251
+
252
+ def _poll_constraint(self, label: str, props: list[str]) -> None:
253
+ for _ in range(_POLL_RETRIES):
254
+ result = self._graph.ro_query("CALL db.constraints()")
255
+ for row in result.result_set:
256
+ entry = row[0]
257
+ status = entry[4] if isinstance(entry, (list, tuple)) else str(entry)
258
+ if status == "FAILED":
259
+ raise ConstraintFailedError(f"constraint on {label}.{props} failed")
260
+ if status == "OPERATIONAL":
261
+ return
262
+ time.sleep(_POLL_INTERVAL)
263
+ raise ConstraintTimeoutError(
264
+ f"constraint on {label}.{props} did not become OPERATIONAL"
265
+ )
266
+
267
+ def drop_constraint(
268
+ self, kind: str, entity: str, label: str, props: list[str]
269
+ ) -> None:
270
+ prop_count = str(len(props))
271
+ log.info("dropping %s constraint on %s %s %s", kind, entity, label, props)
272
+ self._db.execute_command(
273
+ "GRAPH.CONSTRAINT",
274
+ "DROP",
275
+ label,
276
+ kind,
277
+ entity,
278
+ label,
279
+ "PROPERTIES",
280
+ prop_count,
281
+ *props,
282
+ )
283
+
284
+ # ------------------------------------------------------------------
285
+ # Snapshots
286
+ # ------------------------------------------------------------------
287
+
288
+ def snapshot(self, snap_name: str) -> None:
289
+ # GRAPH.COPY fails on an empty key; initialize graph if it doesn't exist yet
290
+ if self._graph.name not in self._db.list_graphs():
291
+ self._graph.query("RETURN 1")
292
+ self._graph.copy(snap_name)
293
+ log.debug("snapshot taken: %s → %s", self._graph.name, snap_name)
294
+
295
+ def restore_snapshot(self, snap_name: str) -> None:
296
+ snap_graph = self._db.select_graph(snap_name)
297
+ self._graph.delete()
298
+ snap_graph.copy(self._graph.name)
299
+ snap_graph.delete()
300
+ log.debug("snapshot restored: %s → %s", snap_name, self._graph.name)
301
+
302
+ def snapshot_exists(self, snap_name: str) -> bool:
303
+ return snap_name in self._db.list_graphs()
304
+
305
+ # ------------------------------------------------------------------
306
+ # Checksum & attribution tracking
307
+ # ------------------------------------------------------------------
308
+
309
+ def _get_tracking(self) -> tuple[dict[str, str], dict[str, str]]:
310
+ """Return (checksums, installed_by) dicts from the version node."""
311
+ try:
312
+ result = self._graph.ro_query(_GET_TRACKING_QUERY)
313
+ except Exception as exc:
314
+ if "empty key" in str(exc).lower():
315
+ return {}, {}
316
+ raise
317
+ rows = result.result_set
318
+ if not rows:
319
+ return {}, {}
320
+ row = rows[0]
321
+ checksums = _parse_kv_list(row[0] if row[0] is not None else None)
322
+ installed = _parse_kv_list(
323
+ row[1] if len(row) > 1 and row[1] is not None else None
324
+ )
325
+ return checksums, installed
326
+
327
+ def get_checksums(self) -> dict[str, str]:
328
+ checksums, _ = self._get_tracking()
329
+ return checksums
330
+
331
+ def get_installed_by(self) -> dict[str, str]:
332
+ _, installed = self._get_tracking()
333
+ return installed
334
+
335
+ def set_checksum(
336
+ self, rev_id: str, checksum: str, installed_by: str | None = None
337
+ ) -> None:
338
+ checksums, installed = self._get_tracking()
339
+ checksums[rev_id] = checksum
340
+ if installed_by is not None:
341
+ installed[rev_id] = installed_by
342
+ self._graph.query(
343
+ _SET_TRACKING_QUERY,
344
+ {
345
+ "checksums": _encode_kv_list(checksums),
346
+ "installed_by": _encode_kv_list(installed),
347
+ },
348
+ )
349
+ log.debug("recorded checksum for revision %s", rev_id)
350
+
351
+ def delete_graph(self) -> None:
352
+ """Delete the underlying graph (used for ephemeral test cleanup)."""
353
+ self._graph.delete()