runic-py 0.2.0__tar.gz → 0.3.0__tar.gz

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 (76) hide show
  1. {runic_py-0.2.0 → runic_py-0.3.0}/.gitignore +1 -0
  2. runic_py-0.3.0/PKG-INFO +357 -0
  3. runic_py-0.3.0/README.md +323 -0
  4. {runic_py-0.2.0 → runic_py-0.3.0}/pyproject.toml +27 -7
  5. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/__init__.py +10 -0
  6. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/adapters/__init__.py +55 -1
  7. runic_py-0.3.0/runic/migrate/adapters/_base.py +215 -0
  8. runic_py-0.3.0/runic/migrate/adapters/age.py +186 -0
  9. runic_py-0.3.0/runic/migrate/adapters/arcadedb.py +229 -0
  10. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/adapters/falkordb.py +216 -35
  11. runic_py-0.3.0/runic/migrate/adapters/memgraph.py +265 -0
  12. runic_py-0.3.0/runic/migrate/adapters/neo4j.py +283 -0
  13. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/cli.py +29 -20
  14. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/operations.py +38 -67
  15. runic_py-0.3.0/runic/migrate/schema.py +350 -0
  16. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/testing.py +17 -6
  17. runic_py-0.3.0/runic/orm/__init__.py +163 -0
  18. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/descriptors.py +125 -1
  19. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/types.py +3 -0
  20. runic_py-0.3.0/runic/orm/driver/__init__.py +138 -0
  21. runic_py-0.3.0/runic/orm/driver/age.py +582 -0
  22. runic_py-0.3.0/runic/orm/driver/arcadedb.py +98 -0
  23. runic_py-0.3.0/runic/orm/driver/bolt.py +214 -0
  24. runic_py-0.3.0/runic/orm/driver/factory.py +108 -0
  25. runic_py-0.3.0/runic/orm/driver/falkordb.py +174 -0
  26. runic_py-0.3.0/runic/orm/driver/memgraph.py +133 -0
  27. runic_py-0.3.0/runic/orm/driver/neo4j.py +130 -0
  28. runic_py-0.3.0/runic/orm/mapper/__init__.py +5 -0
  29. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/mapper/mapper.py +175 -61
  30. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/mapper/relationship_loader.py +26 -15
  31. runic_py-0.3.0/runic/orm/mapper/relationship_writer.py +161 -0
  32. runic_py-0.3.0/runic/orm/operations.py +88 -0
  33. runic_py-0.3.0/runic/orm/query/__init__.py +102 -0
  34. runic_py-0.3.0/runic/orm/query/builder.py +1114 -0
  35. runic_py-0.3.0/runic/orm/query/expressions.py +349 -0
  36. runic_py-0.3.0/runic/orm/query/specialised.py +350 -0
  37. runic_py-0.3.0/runic/orm/query/traversal.py +144 -0
  38. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/__init__.py +1 -4
  39. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/async_repository.py +35 -24
  40. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/cypher.py +7 -22
  41. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/protocol.py +12 -8
  42. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/repository.py +48 -28
  43. runic_py-0.3.0/runic/orm/schema/__init__.py +8 -0
  44. runic_py-0.3.0/runic/orm/schema/index_manager.py +56 -0
  45. runic_py-0.3.0/runic/orm/session/async_session.py +601 -0
  46. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/session/connection_pool.py +14 -13
  47. runic_py-0.3.0/runic/orm/session/session.py +741 -0
  48. runic_py-0.2.0/PKG-INFO +0 -192
  49. runic_py-0.2.0/README.md +0 -171
  50. runic_py-0.2.0/runic/orm/__init__.py +0 -82
  51. runic_py-0.2.0/runic/orm/repository/pagination.py +0 -105
  52. runic_py-0.2.0/runic/orm/schema/__init__.py +0 -11
  53. runic_py-0.2.0/runic/orm/schema/index_manager.py +0 -195
  54. runic_py-0.2.0/runic/orm/schema/schema_manager.py +0 -173
  55. runic_py-0.2.0/runic/orm/session/__init__.py +0 -0
  56. runic_py-0.2.0/runic/orm/session/async_session.py +0 -340
  57. runic_py-0.2.0/runic/orm/session/session.py +0 -385
  58. {runic_py-0.2.0 → runic_py-0.3.0}/LICENSE.md +0 -0
  59. {runic_py-0.2.0 → runic_py-0.3.0}/runic/__init__.py +0 -0
  60. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/autogen.py +0 -0
  61. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/checksum.py +0 -0
  62. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/config.py +0 -0
  63. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/context.py +0 -0
  64. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/exceptions.py +0 -0
  65. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/introspect.py +0 -0
  66. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/manifest.py +0 -0
  67. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/script.py +0 -0
  68. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/service.py +0 -0
  69. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/templates/env.py.mako +0 -0
  70. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/templates/script.py.mako +0 -0
  71. {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/version.py +0 -0
  72. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/__init__.py +0 -0
  73. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/metadata.py +0 -0
  74. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/models.py +0 -0
  75. {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/exceptions.py +0 -0
  76. {runic_py-0.2.0/runic/orm/mapper → runic_py-0.3.0/runic/orm/session}/__init__.py +0 -0
@@ -115,3 +115,4 @@ wheels/
115
115
  .task/
116
116
  .runic
117
117
  runic_test/
118
+ .claude/ralph-loop.local.md
@@ -0,0 +1,357 @@
1
+ Metadata-Version: 2.4
2
+ Name: runic-py
3
+ Version: 0.3.0
4
+ Summary: OGM and schema migrations for Graph Databases.
5
+ Project-URL: Homepage, https://runic-py.readthedocs.io/en/latest/
6
+ Project-URL: Repository, https://github.com/jenreh/runic
7
+ Author: Jens Rehpöhler
8
+ License: MIT
9
+ License-File: LICENSE.md
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Home Automation
15
+ Requires-Python: >=3.14
16
+ Requires-Dist: mako<2.0.0,>=1.3.0
17
+ Requires-Dist: rich<16.0.0,>=15.0.0
18
+ Requires-Dist: typer<1.0.0,>=0.26.0
19
+ Provides-Extra: age
20
+ Requires-Dist: psycopg[binary]>=3.3.4; extra == 'age'
21
+ Provides-Extra: all
22
+ Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'all'
23
+ Requires-Dist: neo4j>=6.2.0; extra == 'all'
24
+ Requires-Dist: psycopg[binary]>=3.3.4; extra == 'all'
25
+ Provides-Extra: arcadedb
26
+ Requires-Dist: neo4j>=6.2.0; extra == 'arcadedb'
27
+ Provides-Extra: falkordb
28
+ Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'falkordb'
29
+ Provides-Extra: memgraph
30
+ Requires-Dist: neo4j>=6.2.0; extra == 'memgraph'
31
+ Provides-Extra: neo4j
32
+ Requires-Dist: neo4j>=6.2.0; extra == 'neo4j'
33
+ Description-Content-Type: text/markdown
34
+
35
+ <div align="center">
36
+ <img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
37
+
38
+ # Runic
39
+
40
+ **Graph schema migrations and ORM for Cypher-based graph databases.**
41
+
42
+ ![Version](https://img.shields.io/badge/version-0.3.0-blue)
43
+ [![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
44
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
45
+
46
+ [Features](#features) • [Installation](#installation) • [Migrations](#migrations) • [ORM](#runicorm) • [Documentation](#documentation)
47
+
48
+ </div>
49
+
50
+ ---
51
+
52
+ **Runic** is a Python toolkit for Cypher-based graph databases that covers two layers:
53
+
54
+ - **`runic.migrate`** — Alembic-style schema migrations with revision tracking, a CLI, and rollback snapshots.
55
+ - **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all via a pluggable driver layer supporting FalkorDB, ArcadeDB, and any Bolt-compatible database.
56
+
57
+ ## Features
58
+
59
+ ### Migration CLI
60
+
61
+ - **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
62
+ - **Graph-Native** — Migration state stored inside dedicated graph nodes; no external state table needed.
63
+ - **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
64
+ - **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
65
+ - **Rollback Snapshots** — Optional snapshot-based rollback for high-risk, non-reversible migrations.
66
+
67
+ ### Graph ORM
68
+
69
+ - **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
70
+ - **Pluggable Driver Layer** — `GraphDriver` / `GraphDialect` protocols; built-in drivers for FalkorDB, ArcadeDB (via Bolt), and any Bolt-compatible DB. Switch backends without changing model code.
71
+ - **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and offset reads.
72
+ - **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
73
+ - **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
74
+ - **Native Graph Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
75
+ - **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
76
+
77
+ ## Installation
78
+
79
+ Install the core package, then add the optional extra for your database backend:
80
+
81
+ | Backend | Extra | Package installed |
82
+ | --- | --- | --- |
83
+ | FalkorDB | `falkordb` | `falkordb` |
84
+ | Neo4j | `neo4j` | `neo4j` |
85
+ | Memgraph | `memgraph` | `neo4j` (Bolt) |
86
+ | ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
87
+ | Apache AGE | `age` | `psycopg[binary]` |
88
+ | All backends | `all` | all of the above |
89
+
90
+ ```bash
91
+ # FalkorDB
92
+ uv add "runic-py[falkordb]"
93
+
94
+ # Neo4j
95
+ uv add "runic-py[neo4j]"
96
+
97
+ # Memgraph or ArcadeDB (both use the Neo4j Bolt driver)
98
+ uv add "runic-py[memgraph]"
99
+ uv add "runic-py[arcadedb]"
100
+
101
+ # Apache AGE (PostgreSQL extension)
102
+ uv add "runic-py[age]"
103
+
104
+ # All backends at once
105
+ uv add "runic-py[all]"
106
+ ```
107
+
108
+ > [!NOTE]
109
+ > Runic requires Python 3.14+. The core package has no graph-driver dependency — install only the extra for your backend.
110
+
111
+ ---
112
+
113
+ ## Migrations
114
+
115
+ Initialize your project and generate a new revision:
116
+
117
+ ```bash
118
+ runic init
119
+ runic revision -m "create user index"
120
+ ```
121
+
122
+ Open the generated file in `runic/versions/` and define your upgrade and downgrade:
123
+
124
+ ```python
125
+ revision = "1975ea83b712"
126
+ down_revision = None
127
+
128
+
129
+ def upgrade(op) -> None:
130
+ op.create_range_index("User", "email")
131
+
132
+
133
+ def downgrade(op) -> None:
134
+ op.drop_range_index("User", "email")
135
+ ```
136
+
137
+ Apply or roll back:
138
+
139
+ ```bash
140
+ runic upgrade # apply all pending revisions
141
+ runic downgrade # roll back one step
142
+ runic downgrade 1975e # roll back to a specific revision (prefix is enough)
143
+ ```
144
+
145
+ ### Baselining an existing graph
146
+
147
+ Bring an existing graph under runic control without re-running anything:
148
+
149
+ ```bash
150
+ runic baseline -m "baseline" # introspect, generate root revision, stamp it
151
+ runic current # verify it is now tracked
152
+ ```
153
+
154
+ The generated revision recreates all indexes from scratch — safe to replay on a fresh graph (CI, cloning, new tenants):
155
+
156
+ ```bash
157
+ runic upgrade head # rebuilds full schema on an empty graph
158
+ ```
159
+
160
+ ### Programmatic SDK
161
+
162
+ ```python
163
+ from pathlib import Path
164
+ from runic import Runic, init
165
+ from runic.migrate.adapters import create_adapter
166
+
167
+ init(Path("runic/"))
168
+
169
+ # Any supported backend — swap the adapter name and kwargs
170
+ adapter = create_adapter(
171
+ "falkordb", url="falkor://localhost:6379", graph_name="my_graph"
172
+ )
173
+ # adapter = create_adapter("neo4j", host="localhost", port=7687, database="neo4j", username="neo4j", password="secret")
174
+
175
+ runic = Runic(adapter, script_location=Path("runic/"))
176
+ runic.migrate.upgrade("head")
177
+
178
+ print("current:", runic.migrate.current())
179
+ ```
180
+
181
+ ---
182
+
183
+ ## runic.orm
184
+
185
+ ### Defining models
186
+
187
+ ```python
188
+ from runic.orm import Field, Node, Edge, Relation
189
+
190
+
191
+ class User(Node, labels=["User"]):
192
+ id: str
193
+ email: str = Field(unique=True)
194
+ name: str
195
+
196
+
197
+ class Post(Node, labels=["Post"]):
198
+ id: str
199
+ title: str = Field(index_type="FULLTEXT")
200
+ published: bool = False
201
+
202
+
203
+ class AuthoredEdge(Edge, type="AUTHORED"):
204
+ created_at: str # ISO-8601
205
+
206
+
207
+ class Author(Node, labels=["Author"]):
208
+ id: str
209
+ name: str
210
+ posts: list[Post] = Relation(
211
+ relationship="AUTHORED",
212
+ direction="OUTGOING",
213
+ target="Post",
214
+ edge_model=AuthoredEdge,
215
+ )
216
+ ```
217
+
218
+ ### Session-based CRUD
219
+
220
+ `Session` accepts any `GraphDriver`. Use `create_driver()` to build one for your backend:
221
+
222
+ ```python
223
+ from runic.orm import Session, Repository, create_driver
224
+
225
+ # Pick your backend — the rest of the code is identical
226
+ driver = create_driver("falkordb", host="localhost", port=6379, graph="myapp")
227
+
228
+ with Session(driver) as session:
229
+ session.add_all([
230
+ User(id="alice", email="alice@example.com", name="Alice"),
231
+ User(id="bob", email="bob@example.com", name="Bob"),
232
+ ])
233
+ session.commit()
234
+
235
+ with Session(driver) as session:
236
+ repo = Repository(session, User)
237
+ alice = session.get(User, "alice")
238
+ alice.name = "Alice Smith" # change tracking — no explicit dirty flag
239
+ session.commit()
240
+
241
+ with Session(driver) as session:
242
+ user = session.get(User, "bob")
243
+ session.delete(user)
244
+ session.commit()
245
+ ```
246
+
247
+ ### Relationships
248
+
249
+ ```python
250
+ # Lazy load (default) — triggers a query on first access
251
+ with Session(driver) as session:
252
+ author = session.get(Author, "alice")
253
+ posts = author.posts # query executed here
254
+
255
+ # Eager load — single round-trip
256
+ with Session(driver) as session:
257
+ author = session.get(Author, "alice", fetch=["posts"])
258
+ posts = author.posts # already loaded, no extra query
259
+ ```
260
+
261
+ ### Pagination and custom queries
262
+
263
+ ```python
264
+ from runic.orm import Repository
265
+
266
+ with Session(driver) as session:
267
+ repo = Repository(session, User)
268
+ first_page = repo.find_all(skip=0, limit=20)
269
+ next_page = repo.find_all(skip=20, limit=20)
270
+ ```
271
+
272
+ Extend `Repository` to add typed Cypher helpers:
273
+
274
+ ```python
275
+ class UserRepository(Repository[User]):
276
+ def find_by_email(self, email: str) -> User | None:
277
+ return self.cypher_one(
278
+ "MATCH (u:User {email: $email}) RETURN u",
279
+ {"email": email},
280
+ returns=User,
281
+ )
282
+ ```
283
+
284
+ ### Composable queries
285
+
286
+ `select()` creates a query statement independently of any session, enabling
287
+ dynamic query composition (e.g. conditional UI filters) before execution:
288
+
289
+ ```python
290
+ from runic.orm import select
291
+
292
+ stmt = select(User).where(User.active == True)
293
+ if min_age > 0:
294
+ stmt = stmt.where(User.age >= min_age)
295
+
296
+ with Session(driver) as session:
297
+ users: list[User] = session.scalars(stmt) # list[User]
298
+ user: User | None = session.scalar(stmt) # User | None
299
+ n: int = session.count(stmt) # int
300
+ ```
301
+
302
+ The same `stmt` is reusable — pass it to multiple sessions or execute it
303
+ multiple times. The older `session.query(User).where(...).all()` pattern is
304
+ still fully supported.
305
+
306
+ ### Schema management
307
+
308
+ Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
309
+
310
+ ```python
311
+ from runic.orm import Field, IndexManager, Node, SchemaManager
312
+
313
+
314
+ class Place(Node, labels=["Place"]):
315
+ id: str
316
+ name: str = Field(index_type="FULLTEXT")
317
+ slug: str = Field(unique=True)
318
+ lat: float = Field(index=True)
319
+ lon: float = Field(index=True)
320
+
321
+
322
+ schema = SchemaManager(driver)
323
+ schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
324
+
325
+ result = schema.validate_schema([Place])
326
+ print("valid:", result.is_valid)
327
+ ```
328
+
329
+ ### Native graph types
330
+
331
+ `Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
332
+
333
+ ```python
334
+ from datetime import UTC, datetime
335
+ from enum import StrEnum
336
+ from runic.orm import Field, GeoLocation, Node, Vector
337
+
338
+
339
+ class Status(StrEnum):
340
+ DRAFT = "draft"
341
+ PUBLISHED = "published"
342
+
343
+
344
+ class Article(Node, labels=["Article"]):
345
+ id: str = Field(primary_key=True)
346
+ category: str = Field(interned=True) # intern() deduplication
347
+ status: Status # EnumConverter auto-assigned
348
+ published_at: datetime | None = None # DatetimeConverter auto-assigned
349
+ embedding: Vector | None = None # VectorConverter → vecf32()
350
+ origin: GeoLocation | None = None # GeoLocationConverter → point()
351
+ ```
352
+
353
+ ---
354
+
355
+ ## Documentation
356
+
357
+ Full conceptual overview, async usage, advanced CLI flags, and API reference at the complete [Runic Documentation](https://runic-py.readthedocs.io/latest/).
@@ -0,0 +1,323 @@
1
+ <div align="center">
2
+ <img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
3
+
4
+ # Runic
5
+
6
+ **Graph schema migrations and ORM for Cypher-based graph databases.**
7
+
8
+ ![Version](https://img.shields.io/badge/version-0.3.0-blue)
9
+ [![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
10
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
11
+
12
+ [Features](#features) • [Installation](#installation) • [Migrations](#migrations) • [ORM](#runicorm) • [Documentation](#documentation)
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ **Runic** is a Python toolkit for Cypher-based graph databases that covers two layers:
19
+
20
+ - **`runic.migrate`** — Alembic-style schema migrations with revision tracking, a CLI, and rollback snapshots.
21
+ - **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all via a pluggable driver layer supporting FalkorDB, ArcadeDB, and any Bolt-compatible database.
22
+
23
+ ## Features
24
+
25
+ ### Migration CLI
26
+
27
+ - **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
28
+ - **Graph-Native** — Migration state stored inside dedicated graph nodes; no external state table needed.
29
+ - **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
30
+ - **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
31
+ - **Rollback Snapshots** — Optional snapshot-based rollback for high-risk, non-reversible migrations.
32
+
33
+ ### Graph ORM
34
+
35
+ - **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
36
+ - **Pluggable Driver Layer** — `GraphDriver` / `GraphDialect` protocols; built-in drivers for FalkorDB, ArcadeDB (via Bolt), and any Bolt-compatible DB. Switch backends without changing model code.
37
+ - **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and offset reads.
38
+ - **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
39
+ - **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
40
+ - **Native Graph Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
41
+ - **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
42
+
43
+ ## Installation
44
+
45
+ Install the core package, then add the optional extra for your database backend:
46
+
47
+ | Backend | Extra | Package installed |
48
+ | --- | --- | --- |
49
+ | FalkorDB | `falkordb` | `falkordb` |
50
+ | Neo4j | `neo4j` | `neo4j` |
51
+ | Memgraph | `memgraph` | `neo4j` (Bolt) |
52
+ | ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
53
+ | Apache AGE | `age` | `psycopg[binary]` |
54
+ | All backends | `all` | all of the above |
55
+
56
+ ```bash
57
+ # FalkorDB
58
+ uv add "runic-py[falkordb]"
59
+
60
+ # Neo4j
61
+ uv add "runic-py[neo4j]"
62
+
63
+ # Memgraph or ArcadeDB (both use the Neo4j Bolt driver)
64
+ uv add "runic-py[memgraph]"
65
+ uv add "runic-py[arcadedb]"
66
+
67
+ # Apache AGE (PostgreSQL extension)
68
+ uv add "runic-py[age]"
69
+
70
+ # All backends at once
71
+ uv add "runic-py[all]"
72
+ ```
73
+
74
+ > [!NOTE]
75
+ > Runic requires Python 3.14+. The core package has no graph-driver dependency — install only the extra for your backend.
76
+
77
+ ---
78
+
79
+ ## Migrations
80
+
81
+ Initialize your project and generate a new revision:
82
+
83
+ ```bash
84
+ runic init
85
+ runic revision -m "create user index"
86
+ ```
87
+
88
+ Open the generated file in `runic/versions/` and define your upgrade and downgrade:
89
+
90
+ ```python
91
+ revision = "1975ea83b712"
92
+ down_revision = None
93
+
94
+
95
+ def upgrade(op) -> None:
96
+ op.create_range_index("User", "email")
97
+
98
+
99
+ def downgrade(op) -> None:
100
+ op.drop_range_index("User", "email")
101
+ ```
102
+
103
+ Apply or roll back:
104
+
105
+ ```bash
106
+ runic upgrade # apply all pending revisions
107
+ runic downgrade # roll back one step
108
+ runic downgrade 1975e # roll back to a specific revision (prefix is enough)
109
+ ```
110
+
111
+ ### Baselining an existing graph
112
+
113
+ Bring an existing graph under runic control without re-running anything:
114
+
115
+ ```bash
116
+ runic baseline -m "baseline" # introspect, generate root revision, stamp it
117
+ runic current # verify it is now tracked
118
+ ```
119
+
120
+ The generated revision recreates all indexes from scratch — safe to replay on a fresh graph (CI, cloning, new tenants):
121
+
122
+ ```bash
123
+ runic upgrade head # rebuilds full schema on an empty graph
124
+ ```
125
+
126
+ ### Programmatic SDK
127
+
128
+ ```python
129
+ from pathlib import Path
130
+ from runic import Runic, init
131
+ from runic.migrate.adapters import create_adapter
132
+
133
+ init(Path("runic/"))
134
+
135
+ # Any supported backend — swap the adapter name and kwargs
136
+ adapter = create_adapter(
137
+ "falkordb", url="falkor://localhost:6379", graph_name="my_graph"
138
+ )
139
+ # adapter = create_adapter("neo4j", host="localhost", port=7687, database="neo4j", username="neo4j", password="secret")
140
+
141
+ runic = Runic(adapter, script_location=Path("runic/"))
142
+ runic.migrate.upgrade("head")
143
+
144
+ print("current:", runic.migrate.current())
145
+ ```
146
+
147
+ ---
148
+
149
+ ## runic.orm
150
+
151
+ ### Defining models
152
+
153
+ ```python
154
+ from runic.orm import Field, Node, Edge, Relation
155
+
156
+
157
+ class User(Node, labels=["User"]):
158
+ id: str
159
+ email: str = Field(unique=True)
160
+ name: str
161
+
162
+
163
+ class Post(Node, labels=["Post"]):
164
+ id: str
165
+ title: str = Field(index_type="FULLTEXT")
166
+ published: bool = False
167
+
168
+
169
+ class AuthoredEdge(Edge, type="AUTHORED"):
170
+ created_at: str # ISO-8601
171
+
172
+
173
+ class Author(Node, labels=["Author"]):
174
+ id: str
175
+ name: str
176
+ posts: list[Post] = Relation(
177
+ relationship="AUTHORED",
178
+ direction="OUTGOING",
179
+ target="Post",
180
+ edge_model=AuthoredEdge,
181
+ )
182
+ ```
183
+
184
+ ### Session-based CRUD
185
+
186
+ `Session` accepts any `GraphDriver`. Use `create_driver()` to build one for your backend:
187
+
188
+ ```python
189
+ from runic.orm import Session, Repository, create_driver
190
+
191
+ # Pick your backend — the rest of the code is identical
192
+ driver = create_driver("falkordb", host="localhost", port=6379, graph="myapp")
193
+
194
+ with Session(driver) as session:
195
+ session.add_all([
196
+ User(id="alice", email="alice@example.com", name="Alice"),
197
+ User(id="bob", email="bob@example.com", name="Bob"),
198
+ ])
199
+ session.commit()
200
+
201
+ with Session(driver) as session:
202
+ repo = Repository(session, User)
203
+ alice = session.get(User, "alice")
204
+ alice.name = "Alice Smith" # change tracking — no explicit dirty flag
205
+ session.commit()
206
+
207
+ with Session(driver) as session:
208
+ user = session.get(User, "bob")
209
+ session.delete(user)
210
+ session.commit()
211
+ ```
212
+
213
+ ### Relationships
214
+
215
+ ```python
216
+ # Lazy load (default) — triggers a query on first access
217
+ with Session(driver) as session:
218
+ author = session.get(Author, "alice")
219
+ posts = author.posts # query executed here
220
+
221
+ # Eager load — single round-trip
222
+ with Session(driver) as session:
223
+ author = session.get(Author, "alice", fetch=["posts"])
224
+ posts = author.posts # already loaded, no extra query
225
+ ```
226
+
227
+ ### Pagination and custom queries
228
+
229
+ ```python
230
+ from runic.orm import Repository
231
+
232
+ with Session(driver) as session:
233
+ repo = Repository(session, User)
234
+ first_page = repo.find_all(skip=0, limit=20)
235
+ next_page = repo.find_all(skip=20, limit=20)
236
+ ```
237
+
238
+ Extend `Repository` to add typed Cypher helpers:
239
+
240
+ ```python
241
+ class UserRepository(Repository[User]):
242
+ def find_by_email(self, email: str) -> User | None:
243
+ return self.cypher_one(
244
+ "MATCH (u:User {email: $email}) RETURN u",
245
+ {"email": email},
246
+ returns=User,
247
+ )
248
+ ```
249
+
250
+ ### Composable queries
251
+
252
+ `select()` creates a query statement independently of any session, enabling
253
+ dynamic query composition (e.g. conditional UI filters) before execution:
254
+
255
+ ```python
256
+ from runic.orm import select
257
+
258
+ stmt = select(User).where(User.active == True)
259
+ if min_age > 0:
260
+ stmt = stmt.where(User.age >= min_age)
261
+
262
+ with Session(driver) as session:
263
+ users: list[User] = session.scalars(stmt) # list[User]
264
+ user: User | None = session.scalar(stmt) # User | None
265
+ n: int = session.count(stmt) # int
266
+ ```
267
+
268
+ The same `stmt` is reusable — pass it to multiple sessions or execute it
269
+ multiple times. The older `session.query(User).where(...).all()` pattern is
270
+ still fully supported.
271
+
272
+ ### Schema management
273
+
274
+ Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
275
+
276
+ ```python
277
+ from runic.orm import Field, IndexManager, Node, SchemaManager
278
+
279
+
280
+ class Place(Node, labels=["Place"]):
281
+ id: str
282
+ name: str = Field(index_type="FULLTEXT")
283
+ slug: str = Field(unique=True)
284
+ lat: float = Field(index=True)
285
+ lon: float = Field(index=True)
286
+
287
+
288
+ schema = SchemaManager(driver)
289
+ schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
290
+
291
+ result = schema.validate_schema([Place])
292
+ print("valid:", result.is_valid)
293
+ ```
294
+
295
+ ### Native graph types
296
+
297
+ `Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
298
+
299
+ ```python
300
+ from datetime import UTC, datetime
301
+ from enum import StrEnum
302
+ from runic.orm import Field, GeoLocation, Node, Vector
303
+
304
+
305
+ class Status(StrEnum):
306
+ DRAFT = "draft"
307
+ PUBLISHED = "published"
308
+
309
+
310
+ class Article(Node, labels=["Article"]):
311
+ id: str = Field(primary_key=True)
312
+ category: str = Field(interned=True) # intern() deduplication
313
+ status: Status # EnumConverter auto-assigned
314
+ published_at: datetime | None = None # DatetimeConverter auto-assigned
315
+ embedding: Vector | None = None # VectorConverter → vecf32()
316
+ origin: GeoLocation | None = None # GeoLocationConverter → point()
317
+ ```
318
+
319
+ ---
320
+
321
+ ## Documentation
322
+
323
+ Full conceptual overview, async usage, advanced CLI flags, and API reference at the complete [Runic Documentation](https://runic-py.readthedocs.io/latest/).