runic-py 0.2.2__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.
- {runic_py-0.2.2 → runic_py-0.3.0}/.gitignore +1 -0
- runic_py-0.2.2/README.md → runic_py-0.3.0/PKG-INFO +111 -27
- runic_py-0.2.2/PKG-INFO → runic_py-0.3.0/README.md +77 -48
- {runic_py-0.2.2 → runic_py-0.3.0}/pyproject.toml +27 -7
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/__init__.py +10 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/adapters/__init__.py +55 -1
- runic_py-0.3.0/runic/migrate/adapters/_base.py +215 -0
- runic_py-0.3.0/runic/migrate/adapters/age.py +186 -0
- runic_py-0.3.0/runic/migrate/adapters/arcadedb.py +229 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/adapters/falkordb.py +216 -35
- runic_py-0.3.0/runic/migrate/adapters/memgraph.py +265 -0
- runic_py-0.3.0/runic/migrate/adapters/neo4j.py +283 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/cli.py +9 -4
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/operations.py +38 -67
- runic_py-0.3.0/runic/migrate/schema.py +350 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/testing.py +17 -6
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/__init__.py +45 -10
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/core/descriptors.py +7 -1
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/core/types.py +3 -0
- runic_py-0.3.0/runic/orm/driver/__init__.py +138 -0
- runic_py-0.3.0/runic/orm/driver/age.py +582 -0
- runic_py-0.3.0/runic/orm/driver/arcadedb.py +98 -0
- runic_py-0.3.0/runic/orm/driver/bolt.py +214 -0
- runic_py-0.3.0/runic/orm/driver/factory.py +108 -0
- runic_py-0.3.0/runic/orm/driver/falkordb.py +174 -0
- runic_py-0.3.0/runic/orm/driver/memgraph.py +133 -0
- runic_py-0.3.0/runic/orm/driver/neo4j.py +130 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/mapper/mapper.py +105 -66
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/mapper/relationship_loader.py +26 -15
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/mapper/relationship_writer.py +29 -13
- runic_py-0.3.0/runic/orm/operations.py +88 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/query/__init__.py +46 -7
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/query/builder.py +78 -480
- runic_py-0.3.0/runic/orm/query/specialised.py +350 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/repository/__init__.py +1 -4
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/repository/async_repository.py +26 -28
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/repository/cypher.py +7 -22
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/repository/protocol.py +12 -8
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/repository/repository.py +32 -31
- runic_py-0.3.0/runic/orm/schema/__init__.py +8 -0
- runic_py-0.3.0/runic/orm/schema/index_manager.py +56 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/session/async_session.py +153 -25
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/session/connection_pool.py +14 -13
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/session/session.py +190 -31
- runic_py-0.2.2/runic/orm/repository/pagination.py +0 -105
- runic_py-0.2.2/runic/orm/schema/__init__.py +0 -11
- runic_py-0.2.2/runic/orm/schema/index_manager.py +0 -195
- runic_py-0.2.2/runic/orm/schema/schema_manager.py +0 -173
- {runic_py-0.2.2 → runic_py-0.3.0}/LICENSE.md +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/__init__.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/autogen.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/checksum.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/config.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/context.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/exceptions.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/introspect.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/manifest.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/script.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/service.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/templates/env.py.mako +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/templates/script.py.mako +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/migrate/version.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/core/__init__.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/core/metadata.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/core/models.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/exceptions.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/mapper/__init__.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/query/expressions.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/query/traversal.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.0}/runic/orm/session/__init__.py +0 -0
|
@@ -1,11 +1,45 @@
|
|
|
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
|
+
|
|
1
35
|
<div align="center">
|
|
2
36
|
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
|
|
3
37
|
|
|
4
38
|
# Runic
|
|
5
39
|
|
|
6
|
-
**Graph schema migrations and ORM for
|
|
40
|
+
**Graph schema migrations and ORM for Cypher-based graph databases.**
|
|
7
41
|
|
|
8
|
-

|
|
9
43
|
[](https://www.python.org)
|
|
10
44
|
[](LICENSE.md)
|
|
11
45
|
|
|
@@ -15,44 +49,64 @@
|
|
|
15
49
|
|
|
16
50
|
---
|
|
17
51
|
|
|
18
|
-
**Runic** is a Python toolkit for
|
|
52
|
+
**Runic** is a Python toolkit for Cypher-based graph databases that covers two layers:
|
|
19
53
|
|
|
20
54
|
- **`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
|
|
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.
|
|
22
56
|
|
|
23
57
|
## Features
|
|
24
58
|
|
|
25
59
|
### Migration CLI
|
|
26
60
|
|
|
27
61
|
- **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
|
|
28
|
-
- **Graph-Native** — Migration state stored inside dedicated graph nodes
|
|
62
|
+
- **Graph-Native** — Migration state stored inside dedicated graph nodes; no external state table needed.
|
|
29
63
|
- **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
|
|
30
64
|
- **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
|
|
31
|
-
- **Rollback Snapshots** —
|
|
65
|
+
- **Rollback Snapshots** — Optional snapshot-based rollback for high-risk, non-reversible migrations.
|
|
32
66
|
|
|
33
67
|
### Graph ORM
|
|
34
68
|
|
|
35
69
|
- **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
|
|
36
|
-
- **
|
|
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.
|
|
37
72
|
- **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
|
|
38
73
|
- **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
|
|
39
|
-
- **Native
|
|
74
|
+
- **Native Graph Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
|
|
40
75
|
- **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
|
|
41
76
|
|
|
42
77
|
## Installation
|
|
43
78
|
|
|
44
|
-
|
|
45
|
-
uv pip install runic
|
|
46
|
-
```
|
|
79
|
+
Install the core package, then add the optional extra for your database backend:
|
|
47
80
|
|
|
48
|
-
|
|
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 |
|
|
49
89
|
|
|
50
90
|
```bash
|
|
51
|
-
|
|
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]"
|
|
52
106
|
```
|
|
53
107
|
|
|
54
108
|
> [!NOTE]
|
|
55
|
-
> Runic requires Python 3.14
|
|
109
|
+
> Runic requires Python 3.14+. The core package has no graph-driver dependency — install only the extra for your backend.
|
|
56
110
|
|
|
57
111
|
---
|
|
58
112
|
|
|
@@ -90,7 +144,7 @@ runic downgrade 1975e # roll back to a specific revision (prefix is enough)
|
|
|
90
144
|
|
|
91
145
|
### Baselining an existing graph
|
|
92
146
|
|
|
93
|
-
Bring an
|
|
147
|
+
Bring an existing graph under runic control without re-running anything:
|
|
94
148
|
|
|
95
149
|
```bash
|
|
96
150
|
runic baseline -m "baseline" # introspect, generate root revision, stamp it
|
|
@@ -112,9 +166,12 @@ from runic.migrate.adapters import create_adapter
|
|
|
112
166
|
|
|
113
167
|
init(Path("runic/"))
|
|
114
168
|
|
|
169
|
+
# Any supported backend — swap the adapter name and kwargs
|
|
115
170
|
adapter = create_adapter(
|
|
116
171
|
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
|
|
117
172
|
)
|
|
173
|
+
# adapter = create_adapter("neo4j", host="localhost", port=7687, database="neo4j", username="neo4j", password="secret")
|
|
174
|
+
|
|
118
175
|
runic = Runic(adapter, script_location=Path("runic/"))
|
|
119
176
|
runic.migrate.upgrade("head")
|
|
120
177
|
|
|
@@ -160,23 +217,28 @@ class Author(Node, labels=["Author"]):
|
|
|
160
217
|
|
|
161
218
|
### Session-based CRUD
|
|
162
219
|
|
|
220
|
+
`Session` accepts any `GraphDriver`. Use `create_driver()` to build one for your backend:
|
|
221
|
+
|
|
163
222
|
```python
|
|
164
|
-
from runic.orm import Session, Repository
|
|
223
|
+
from runic.orm import Session, Repository, create_driver
|
|
165
224
|
|
|
166
|
-
|
|
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:
|
|
167
229
|
session.add_all([
|
|
168
230
|
User(id="alice", email="alice@example.com", name="Alice"),
|
|
169
231
|
User(id="bob", email="bob@example.com", name="Bob"),
|
|
170
232
|
])
|
|
171
233
|
session.commit()
|
|
172
234
|
|
|
173
|
-
with Session(
|
|
235
|
+
with Session(driver) as session:
|
|
174
236
|
repo = Repository(session, User)
|
|
175
237
|
alice = session.get(User, "alice")
|
|
176
238
|
alice.name = "Alice Smith" # change tracking — no explicit dirty flag
|
|
177
239
|
session.commit()
|
|
178
240
|
|
|
179
|
-
with Session(
|
|
241
|
+
with Session(driver) as session:
|
|
180
242
|
user = session.get(User, "bob")
|
|
181
243
|
session.delete(user)
|
|
182
244
|
session.commit()
|
|
@@ -186,12 +248,12 @@ with Session(graph) as session:
|
|
|
186
248
|
|
|
187
249
|
```python
|
|
188
250
|
# Lazy load (default) — triggers a query on first access
|
|
189
|
-
with Session(
|
|
251
|
+
with Session(driver) as session:
|
|
190
252
|
author = session.get(Author, "alice")
|
|
191
253
|
posts = author.posts # query executed here
|
|
192
254
|
|
|
193
255
|
# Eager load — single round-trip
|
|
194
|
-
with Session(
|
|
256
|
+
with Session(driver) as session:
|
|
195
257
|
author = session.get(Author, "alice", fetch=["posts"])
|
|
196
258
|
posts = author.posts # already loaded, no extra query
|
|
197
259
|
```
|
|
@@ -199,12 +261,12 @@ with Session(graph) as session:
|
|
|
199
261
|
### Pagination and custom queries
|
|
200
262
|
|
|
201
263
|
```python
|
|
202
|
-
from runic.orm import
|
|
264
|
+
from runic.orm import Repository
|
|
203
265
|
|
|
204
|
-
with Session(
|
|
266
|
+
with Session(driver) as session:
|
|
205
267
|
repo = Repository(session, User)
|
|
206
|
-
|
|
207
|
-
|
|
268
|
+
first_page = repo.find_all(skip=0, limit=20)
|
|
269
|
+
next_page = repo.find_all(skip=20, limit=20)
|
|
208
270
|
```
|
|
209
271
|
|
|
210
272
|
Extend `Repository` to add typed Cypher helpers:
|
|
@@ -219,6 +281,28 @@ class UserRepository(Repository[User]):
|
|
|
219
281
|
)
|
|
220
282
|
```
|
|
221
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
|
+
|
|
222
306
|
### Schema management
|
|
223
307
|
|
|
224
308
|
Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
|
|
@@ -235,14 +319,14 @@ class Place(Node, labels=["Place"]):
|
|
|
235
319
|
lon: float = Field(index=True)
|
|
236
320
|
|
|
237
321
|
|
|
238
|
-
schema = SchemaManager(
|
|
322
|
+
schema = SchemaManager(driver)
|
|
239
323
|
schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
|
|
240
324
|
|
|
241
325
|
result = schema.validate_schema([Place])
|
|
242
326
|
print("valid:", result.is_valid)
|
|
243
327
|
```
|
|
244
328
|
|
|
245
|
-
### Native
|
|
329
|
+
### Native graph types
|
|
246
330
|
|
|
247
331
|
`Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
|
|
248
332
|
|
|
@@ -1,32 +1,11 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: runic-py
|
|
3
|
-
Version: 0.2.2
|
|
4
|
-
Summary: OGM and schema migrations for FalkorDB.
|
|
5
|
-
Project-URL: Homepage, https://runic-py.readthedocs.io/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: falkordb<2.0.0,>=1.6.0
|
|
17
|
-
Requires-Dist: mako<2.0.0,>=1.3.0
|
|
18
|
-
Requires-Dist: rich<16.0.0,>=15.0.0
|
|
19
|
-
Requires-Dist: typer<1.0.0,>=0.26.0
|
|
20
|
-
Description-Content-Type: text/markdown
|
|
21
|
-
|
|
22
1
|
<div align="center">
|
|
23
2
|
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
|
|
24
3
|
|
|
25
4
|
# Runic
|
|
26
5
|
|
|
27
|
-
**Graph schema migrations and ORM for
|
|
6
|
+
**Graph schema migrations and ORM for Cypher-based graph databases.**
|
|
28
7
|
|
|
29
|
-

|
|
30
9
|
[](https://www.python.org)
|
|
31
10
|
[](LICENSE.md)
|
|
32
11
|
|
|
@@ -36,44 +15,64 @@ Description-Content-Type: text/markdown
|
|
|
36
15
|
|
|
37
16
|
---
|
|
38
17
|
|
|
39
|
-
**Runic** is a Python toolkit for
|
|
18
|
+
**Runic** is a Python toolkit for Cypher-based graph databases that covers two layers:
|
|
40
19
|
|
|
41
20
|
- **`runic.migrate`** — Alembic-style schema migrations with revision tracking, a CLI, and rollback snapshots.
|
|
42
|
-
- **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all
|
|
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.
|
|
43
22
|
|
|
44
23
|
## Features
|
|
45
24
|
|
|
46
25
|
### Migration CLI
|
|
47
26
|
|
|
48
27
|
- **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
|
|
49
|
-
- **Graph-Native** — Migration state stored inside dedicated graph nodes
|
|
28
|
+
- **Graph-Native** — Migration state stored inside dedicated graph nodes; no external state table needed.
|
|
50
29
|
- **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
|
|
51
30
|
- **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
|
|
52
|
-
- **Rollback Snapshots** —
|
|
31
|
+
- **Rollback Snapshots** — Optional snapshot-based rollback for high-risk, non-reversible migrations.
|
|
53
32
|
|
|
54
33
|
### Graph ORM
|
|
55
34
|
|
|
56
35
|
- **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
|
|
57
|
-
- **
|
|
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.
|
|
58
38
|
- **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
|
|
59
39
|
- **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
|
|
60
|
-
- **Native
|
|
40
|
+
- **Native Graph Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
|
|
61
41
|
- **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
|
|
62
42
|
|
|
63
43
|
## Installation
|
|
64
44
|
|
|
65
|
-
|
|
66
|
-
uv pip install runic
|
|
67
|
-
```
|
|
45
|
+
Install the core package, then add the optional extra for your database backend:
|
|
68
46
|
|
|
69
|
-
|
|
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 |
|
|
70
55
|
|
|
71
56
|
```bash
|
|
72
|
-
|
|
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]"
|
|
73
72
|
```
|
|
74
73
|
|
|
75
74
|
> [!NOTE]
|
|
76
|
-
> Runic requires Python 3.14
|
|
75
|
+
> Runic requires Python 3.14+. The core package has no graph-driver dependency — install only the extra for your backend.
|
|
77
76
|
|
|
78
77
|
---
|
|
79
78
|
|
|
@@ -111,7 +110,7 @@ runic downgrade 1975e # roll back to a specific revision (prefix is enough)
|
|
|
111
110
|
|
|
112
111
|
### Baselining an existing graph
|
|
113
112
|
|
|
114
|
-
Bring an
|
|
113
|
+
Bring an existing graph under runic control without re-running anything:
|
|
115
114
|
|
|
116
115
|
```bash
|
|
117
116
|
runic baseline -m "baseline" # introspect, generate root revision, stamp it
|
|
@@ -133,9 +132,12 @@ from runic.migrate.adapters import create_adapter
|
|
|
133
132
|
|
|
134
133
|
init(Path("runic/"))
|
|
135
134
|
|
|
135
|
+
# Any supported backend — swap the adapter name and kwargs
|
|
136
136
|
adapter = create_adapter(
|
|
137
137
|
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
|
|
138
138
|
)
|
|
139
|
+
# adapter = create_adapter("neo4j", host="localhost", port=7687, database="neo4j", username="neo4j", password="secret")
|
|
140
|
+
|
|
139
141
|
runic = Runic(adapter, script_location=Path("runic/"))
|
|
140
142
|
runic.migrate.upgrade("head")
|
|
141
143
|
|
|
@@ -181,23 +183,28 @@ class Author(Node, labels=["Author"]):
|
|
|
181
183
|
|
|
182
184
|
### Session-based CRUD
|
|
183
185
|
|
|
186
|
+
`Session` accepts any `GraphDriver`. Use `create_driver()` to build one for your backend:
|
|
187
|
+
|
|
184
188
|
```python
|
|
185
|
-
from runic.orm import Session, Repository
|
|
189
|
+
from runic.orm import Session, Repository, create_driver
|
|
186
190
|
|
|
187
|
-
|
|
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:
|
|
188
195
|
session.add_all([
|
|
189
196
|
User(id="alice", email="alice@example.com", name="Alice"),
|
|
190
197
|
User(id="bob", email="bob@example.com", name="Bob"),
|
|
191
198
|
])
|
|
192
199
|
session.commit()
|
|
193
200
|
|
|
194
|
-
with Session(
|
|
201
|
+
with Session(driver) as session:
|
|
195
202
|
repo = Repository(session, User)
|
|
196
203
|
alice = session.get(User, "alice")
|
|
197
204
|
alice.name = "Alice Smith" # change tracking — no explicit dirty flag
|
|
198
205
|
session.commit()
|
|
199
206
|
|
|
200
|
-
with Session(
|
|
207
|
+
with Session(driver) as session:
|
|
201
208
|
user = session.get(User, "bob")
|
|
202
209
|
session.delete(user)
|
|
203
210
|
session.commit()
|
|
@@ -207,12 +214,12 @@ with Session(graph) as session:
|
|
|
207
214
|
|
|
208
215
|
```python
|
|
209
216
|
# Lazy load (default) — triggers a query on first access
|
|
210
|
-
with Session(
|
|
217
|
+
with Session(driver) as session:
|
|
211
218
|
author = session.get(Author, "alice")
|
|
212
219
|
posts = author.posts # query executed here
|
|
213
220
|
|
|
214
221
|
# Eager load — single round-trip
|
|
215
|
-
with Session(
|
|
222
|
+
with Session(driver) as session:
|
|
216
223
|
author = session.get(Author, "alice", fetch=["posts"])
|
|
217
224
|
posts = author.posts # already loaded, no extra query
|
|
218
225
|
```
|
|
@@ -220,12 +227,12 @@ with Session(graph) as session:
|
|
|
220
227
|
### Pagination and custom queries
|
|
221
228
|
|
|
222
229
|
```python
|
|
223
|
-
from runic.orm import
|
|
230
|
+
from runic.orm import Repository
|
|
224
231
|
|
|
225
|
-
with Session(
|
|
232
|
+
with Session(driver) as session:
|
|
226
233
|
repo = Repository(session, User)
|
|
227
|
-
|
|
228
|
-
|
|
234
|
+
first_page = repo.find_all(skip=0, limit=20)
|
|
235
|
+
next_page = repo.find_all(skip=20, limit=20)
|
|
229
236
|
```
|
|
230
237
|
|
|
231
238
|
Extend `Repository` to add typed Cypher helpers:
|
|
@@ -240,6 +247,28 @@ class UserRepository(Repository[User]):
|
|
|
240
247
|
)
|
|
241
248
|
```
|
|
242
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
|
+
|
|
243
272
|
### Schema management
|
|
244
273
|
|
|
245
274
|
Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
|
|
@@ -256,14 +285,14 @@ class Place(Node, labels=["Place"]):
|
|
|
256
285
|
lon: float = Field(index=True)
|
|
257
286
|
|
|
258
287
|
|
|
259
|
-
schema = SchemaManager(
|
|
288
|
+
schema = SchemaManager(driver)
|
|
260
289
|
schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
|
|
261
290
|
|
|
262
291
|
result = schema.validate_schema([Place])
|
|
263
292
|
print("valid:", result.is_valid)
|
|
264
293
|
```
|
|
265
294
|
|
|
266
|
-
### Native
|
|
295
|
+
### Native graph types
|
|
267
296
|
|
|
268
297
|
`Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
|
|
269
298
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "runic-py"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "OGM and schema migrations for
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "OGM and schema migrations for Graph Databases."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
7
7
|
license = { text = "MIT" }
|
|
@@ -18,10 +18,21 @@ classifiers = [
|
|
|
18
18
|
dependencies = [
|
|
19
19
|
"typer>=0.26.0,<1.0.0",
|
|
20
20
|
"rich>=15.0.0,<16.0.0",
|
|
21
|
-
"falkordb>=1.6.0,<2.0.0",
|
|
22
21
|
"mako>=1.3.0,<2.0.0",
|
|
23
22
|
]
|
|
24
23
|
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
falkordb = ["falkordb>=1.6.0,<2.0.0"]
|
|
26
|
+
neo4j = ["neo4j>=6.2.0"]
|
|
27
|
+
memgraph = ["neo4j>=6.2.0"]
|
|
28
|
+
arcadedb = ["neo4j>=6.2.0"]
|
|
29
|
+
age = ["psycopg[binary]>=3.3.4"]
|
|
30
|
+
all = [
|
|
31
|
+
"falkordb>=1.6.0,<2.0.0",
|
|
32
|
+
"neo4j>=6.2.0",
|
|
33
|
+
"psycopg[binary]>=3.3.4",
|
|
34
|
+
]
|
|
35
|
+
|
|
25
36
|
[dependency-groups]
|
|
26
37
|
dev = [
|
|
27
38
|
"bump-my-version==1.3.0",
|
|
@@ -38,13 +49,17 @@ dev = [
|
|
|
38
49
|
"sphinx-copybutton==0.5.2",
|
|
39
50
|
"sphinx-design==0.7.0",
|
|
40
51
|
"ty>=0.0.44",
|
|
52
|
+
"python-dotenv>=1.2.2",
|
|
53
|
+
"falkordb>=1.6.0,<2.0.0",
|
|
54
|
+
"neo4j>=6.2.0",
|
|
55
|
+
"psycopg[binary]>=3.3.4",
|
|
41
56
|
]
|
|
42
57
|
|
|
43
58
|
[project.scripts]
|
|
44
59
|
runic = "runic.migrate.cli:app"
|
|
45
60
|
|
|
46
61
|
[project.urls]
|
|
47
|
-
Homepage = "https://runic-py.readthedocs.io/latest/"
|
|
62
|
+
Homepage = "https://runic-py.readthedocs.io/en/latest/"
|
|
48
63
|
Repository = "https://github.com/jenreh/runic"
|
|
49
64
|
|
|
50
65
|
[tool.hatch.build.targets.wheel]
|
|
@@ -76,7 +91,7 @@ exclude = [
|
|
|
76
91
|
|
|
77
92
|
[tool.bumpversion]
|
|
78
93
|
commit = false
|
|
79
|
-
current_version = "0.
|
|
94
|
+
current_version = "0.3.0"
|
|
80
95
|
tag = false
|
|
81
96
|
allow-dirty = false
|
|
82
97
|
|
|
@@ -230,8 +245,9 @@ cache_dir = ".cache/pytest"
|
|
|
230
245
|
testpaths = ["tests"]
|
|
231
246
|
asyncio_mode = "auto"
|
|
232
247
|
markers = [
|
|
233
|
-
"
|
|
234
|
-
"
|
|
248
|
+
"integration: requires a live graph backend (embedded FalkorDB via redislite by default; other backends via RUNIC_TEST_BACKENDS env var + docker-compose.test.yml)",
|
|
249
|
+
"requires_multi_label: test requires multi-label node support (not available on Apache AGE)",
|
|
250
|
+
"requires_geo_update: test requires SET n.prop = point() support (not available on ArcadeDB via Bolt)",
|
|
235
251
|
]
|
|
236
252
|
|
|
237
253
|
[tool.ty.environment]
|
|
@@ -245,12 +261,16 @@ exclude = [
|
|
|
245
261
|
".venv",
|
|
246
262
|
"build",
|
|
247
263
|
"dist",
|
|
264
|
+
"examples",
|
|
248
265
|
]
|
|
249
266
|
|
|
250
267
|
[tool.ty.rules]
|
|
251
268
|
unresolved-import = "warn"
|
|
252
269
|
missing-argument = "error"
|
|
253
270
|
|
|
271
|
+
[tool.mypy]
|
|
272
|
+
cache_dir = ".cache/mypy"
|
|
273
|
+
|
|
254
274
|
[tool.coverage.run]
|
|
255
275
|
branch = true
|
|
256
276
|
source = ["app", "runic"]
|
|
@@ -8,19 +8,29 @@ from runic.migrate.manifest import (
|
|
|
8
8
|
UniqueConstraint,
|
|
9
9
|
VectorIndex,
|
|
10
10
|
)
|
|
11
|
+
from runic.migrate.schema import (
|
|
12
|
+
IndexManager,
|
|
13
|
+
SchemaInfo,
|
|
14
|
+
SchemaManager,
|
|
15
|
+
ValidationResult,
|
|
16
|
+
)
|
|
11
17
|
from runic.migrate.script import AmbiguousRevision, RevisionNotFound
|
|
12
18
|
from runic.migrate.service import init
|
|
13
19
|
|
|
14
20
|
__all__ = [
|
|
15
21
|
"AmbiguousRevision",
|
|
16
22
|
"FulltextIndex",
|
|
23
|
+
"IndexManager",
|
|
17
24
|
"IrreversibleMigrationError",
|
|
18
25
|
"MandatoryConstraint",
|
|
19
26
|
"RangeIndex",
|
|
20
27
|
"RevisionNotFound",
|
|
21
28
|
"Runic",
|
|
29
|
+
"SchemaInfo",
|
|
30
|
+
"SchemaManager",
|
|
22
31
|
"SchemaManifest",
|
|
23
32
|
"UniqueConstraint",
|
|
33
|
+
"ValidationResult",
|
|
24
34
|
"VectorIndex",
|
|
25
35
|
"context",
|
|
26
36
|
"init",
|