runic-py 0.2.2__tar.gz → 0.3.2__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.2}/.gitignore +8 -0
- runic_py-0.2.2/README.md → runic_py-0.3.2/PKG-INFO +114 -27
- runic_py-0.2.2/PKG-INFO → runic_py-0.3.2/README.md +78 -48
- {runic_py-0.2.2 → runic_py-0.3.2}/pyproject.toml +45 -16
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/__init__.py +10 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/adapters/__init__.py +58 -2
- runic_py-0.3.2/runic/migrate/adapters/_base.py +247 -0
- runic_py-0.3.2/runic/migrate/adapters/age.py +186 -0
- runic_py-0.3.2/runic/migrate/adapters/arcadedb.py +177 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/adapters/falkordb.py +222 -35
- runic_py-0.3.2/runic/migrate/adapters/memgraph.py +218 -0
- runic_py-0.3.2/runic/migrate/adapters/neo4j.py +234 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/cli.py +21 -18
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/context.py +37 -4
- runic_py-0.3.2/runic/migrate/operations.py +140 -0
- runic_py-0.3.2/runic/migrate/schema.py +339 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/script.py +3 -3
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/testing.py +17 -6
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/__init__.py +45 -10
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/descriptors.py +16 -30
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/models.py +11 -22
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/types.py +3 -0
- runic_py-0.3.2/runic/orm/driver/__init__.py +138 -0
- runic_py-0.3.2/runic/orm/driver/age.py +582 -0
- runic_py-0.3.2/runic/orm/driver/arcadedb.py +98 -0
- runic_py-0.3.2/runic/orm/driver/bolt.py +214 -0
- runic_py-0.3.2/runic/orm/driver/factory.py +108 -0
- runic_py-0.3.2/runic/orm/driver/falkordb.py +174 -0
- runic_py-0.3.2/runic/orm/driver/memgraph.py +133 -0
- runic_py-0.3.2/runic/orm/driver/neo4j.py +130 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/mapper/mapper.py +105 -66
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/mapper/relationship_loader.py +26 -15
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/mapper/relationship_writer.py +29 -13
- runic_py-0.3.2/runic/orm/operations.py +88 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/query/__init__.py +46 -7
- runic_py-0.3.2/runic/orm/query/_compiler.py +210 -0
- runic_py-0.3.2/runic/orm/query/_decoder.py +115 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/query/builder.py +63 -713
- runic_py-0.3.2/runic/orm/query/specialised.py +350 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/__init__.py +1 -4
- runic_py-0.3.2/runic/orm/repository/_base.py +49 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/async_repository.py +30 -59
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/cypher.py +7 -22
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/protocol.py +12 -8
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/repository.py +35 -64
- runic_py-0.3.2/runic/orm/schema/__init__.py +8 -0
- runic_py-0.3.2/runic/orm/schema/index_manager.py +56 -0
- runic_py-0.3.2/runic/orm/session/_base.py +213 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/session/async_session.py +143 -161
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/session/connection_pool.py +14 -13
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/session/session.py +178 -184
- runic_py-0.2.2/runic/migrate/operations.py +0 -173
- 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.2}/LICENSE.md +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/__init__.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/autogen.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/checksum.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/config.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/exceptions.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/introspect.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/manifest.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/service.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/templates/env.py.mako +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/templates/script.py.mako +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/version.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/__init__.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/metadata.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/exceptions.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/mapper/__init__.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/query/expressions.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/query/traversal.py +0 -0
- {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/session/__init__.py +0 -0
|
@@ -115,3 +115,11 @@ wheels/
|
|
|
115
115
|
.task/
|
|
116
116
|
.runic
|
|
117
117
|
runic_test/
|
|
118
|
+
.claude/ralph-loop.local.md
|
|
119
|
+
|
|
120
|
+
# DeepEval run caches (tests/evals suite runs from an isolated venv)
|
|
121
|
+
.deepeval/
|
|
122
|
+
.deepeval-cache.json
|
|
123
|
+
.deepeval_telemetry.txt
|
|
124
|
+
.latest_run_full.json
|
|
125
|
+
.latest_test_run.json
|
|
@@ -1,11 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runic-py
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Graph schema migrations and ORM for Cypher-based 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 :: Database
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Requires-Dist: mako<2.0.0,>=1.3.0
|
|
17
|
+
Requires-Dist: rich<16.0.0,>=14.0.0
|
|
18
|
+
Requires-Dist: typer<1.0.0,>=0.26.0
|
|
19
|
+
Provides-Extra: age
|
|
20
|
+
Requires-Dist: psycopg<4.0.0,>=3.3.4; extra == 'age'
|
|
21
|
+
Provides-Extra: age-binary
|
|
22
|
+
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.4; extra == 'age-binary'
|
|
23
|
+
Provides-Extra: all
|
|
24
|
+
Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'all'
|
|
25
|
+
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'all'
|
|
26
|
+
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.4; extra == 'all'
|
|
27
|
+
Provides-Extra: arcadedb
|
|
28
|
+
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'arcadedb'
|
|
29
|
+
Provides-Extra: falkordb
|
|
30
|
+
Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'falkordb'
|
|
31
|
+
Provides-Extra: memgraph
|
|
32
|
+
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'memgraph'
|
|
33
|
+
Provides-Extra: neo4j
|
|
34
|
+
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'neo4j'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
1
37
|
<div align="center">
|
|
2
38
|
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
|
|
3
39
|
|
|
4
40
|
# Runic
|
|
5
41
|
|
|
6
|
-
**Graph schema migrations and ORM for
|
|
42
|
+
**Graph schema migrations and ORM for Cypher-based graph databases.**
|
|
7
43
|
|
|
8
|
-

|
|
45
|
+
[](https://pypi.org/project/runic-py/)
|
|
9
46
|
[](https://www.python.org)
|
|
10
47
|
[](LICENSE.md)
|
|
11
48
|
|
|
@@ -15,44 +52,64 @@
|
|
|
15
52
|
|
|
16
53
|
---
|
|
17
54
|
|
|
18
|
-
**Runic** is a Python toolkit for
|
|
55
|
+
**Runic** is a Python toolkit for Cypher-based graph databases that covers two layers:
|
|
19
56
|
|
|
20
57
|
- **`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
|
|
58
|
+
- **`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
59
|
|
|
23
60
|
## Features
|
|
24
61
|
|
|
25
62
|
### Migration CLI
|
|
26
63
|
|
|
27
64
|
- **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
|
|
28
|
-
- **Graph-Native** — Migration state stored inside dedicated graph nodes
|
|
65
|
+
- **Graph-Native** — Migration state stored inside dedicated graph nodes; no external state table needed.
|
|
29
66
|
- **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
|
|
30
67
|
- **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
|
|
31
|
-
- **Rollback Snapshots** —
|
|
68
|
+
- **Rollback Snapshots** — Optional snapshot-based rollback for high-risk, non-reversible migrations.
|
|
32
69
|
|
|
33
70
|
### Graph ORM
|
|
34
71
|
|
|
35
72
|
- **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
|
|
36
|
-
- **
|
|
73
|
+
- **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.
|
|
74
|
+
- **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and offset reads.
|
|
37
75
|
- **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
|
|
38
76
|
- **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
|
|
39
|
-
- **Native
|
|
77
|
+
- **Native Graph Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
|
|
40
78
|
- **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
|
|
41
79
|
|
|
42
80
|
## Installation
|
|
43
81
|
|
|
44
|
-
|
|
45
|
-
uv pip install runic
|
|
46
|
-
```
|
|
82
|
+
Install the core package, then add the optional extra for your database backend:
|
|
47
83
|
|
|
48
|
-
|
|
84
|
+
| Backend | Extra | Package installed |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| FalkorDB | `falkordb` | `falkordb` |
|
|
87
|
+
| Neo4j | `neo4j` | `neo4j` |
|
|
88
|
+
| Memgraph | `memgraph` | `neo4j` (Bolt) |
|
|
89
|
+
| ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
|
|
90
|
+
| Apache AGE | `age` | `psycopg[binary]` |
|
|
91
|
+
| All backends | `all` | all of the above |
|
|
49
92
|
|
|
50
93
|
```bash
|
|
51
|
-
|
|
94
|
+
# FalkorDB
|
|
95
|
+
uv add "runic-py[falkordb]"
|
|
96
|
+
|
|
97
|
+
# Neo4j
|
|
98
|
+
uv add "runic-py[neo4j]"
|
|
99
|
+
|
|
100
|
+
# Memgraph or ArcadeDB (both use the Neo4j Bolt driver)
|
|
101
|
+
uv add "runic-py[memgraph]"
|
|
102
|
+
uv add "runic-py[arcadedb]"
|
|
103
|
+
|
|
104
|
+
# Apache AGE (PostgreSQL extension)
|
|
105
|
+
uv add "runic-py[age]"
|
|
106
|
+
|
|
107
|
+
# All backends at once
|
|
108
|
+
uv add "runic-py[all]"
|
|
52
109
|
```
|
|
53
110
|
|
|
54
111
|
> [!NOTE]
|
|
55
|
-
> Runic requires Python 3.14
|
|
112
|
+
> Runic requires Python 3.14+. The core package has no graph-driver dependency — install only the extra for your backend.
|
|
56
113
|
|
|
57
114
|
---
|
|
58
115
|
|
|
@@ -90,7 +147,7 @@ runic downgrade 1975e # roll back to a specific revision (prefix is enough)
|
|
|
90
147
|
|
|
91
148
|
### Baselining an existing graph
|
|
92
149
|
|
|
93
|
-
Bring an
|
|
150
|
+
Bring an existing graph under runic control without re-running anything:
|
|
94
151
|
|
|
95
152
|
```bash
|
|
96
153
|
runic baseline -m "baseline" # introspect, generate root revision, stamp it
|
|
@@ -112,9 +169,12 @@ from runic.migrate.adapters import create_adapter
|
|
|
112
169
|
|
|
113
170
|
init(Path("runic/"))
|
|
114
171
|
|
|
172
|
+
# Any supported backend — swap the adapter name and kwargs
|
|
115
173
|
adapter = create_adapter(
|
|
116
174
|
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
|
|
117
175
|
)
|
|
176
|
+
# adapter = create_adapter("neo4j", host="localhost", port=7687, database="neo4j", username="neo4j", password="secret")
|
|
177
|
+
|
|
118
178
|
runic = Runic(adapter, script_location=Path("runic/"))
|
|
119
179
|
runic.migrate.upgrade("head")
|
|
120
180
|
|
|
@@ -160,23 +220,28 @@ class Author(Node, labels=["Author"]):
|
|
|
160
220
|
|
|
161
221
|
### Session-based CRUD
|
|
162
222
|
|
|
223
|
+
`Session` accepts any `GraphDriver`. Use `create_driver()` to build one for your backend:
|
|
224
|
+
|
|
163
225
|
```python
|
|
164
|
-
from runic.orm import Session, Repository
|
|
226
|
+
from runic.orm import Session, Repository, create_driver
|
|
165
227
|
|
|
166
|
-
|
|
228
|
+
# Pick your backend — the rest of the code is identical
|
|
229
|
+
driver = create_driver("falkordb", host="localhost", port=6379, graph="myapp")
|
|
230
|
+
|
|
231
|
+
with Session(driver) as session:
|
|
167
232
|
session.add_all([
|
|
168
233
|
User(id="alice", email="alice@example.com", name="Alice"),
|
|
169
234
|
User(id="bob", email="bob@example.com", name="Bob"),
|
|
170
235
|
])
|
|
171
236
|
session.commit()
|
|
172
237
|
|
|
173
|
-
with Session(
|
|
238
|
+
with Session(driver) as session:
|
|
174
239
|
repo = Repository(session, User)
|
|
175
240
|
alice = session.get(User, "alice")
|
|
176
241
|
alice.name = "Alice Smith" # change tracking — no explicit dirty flag
|
|
177
242
|
session.commit()
|
|
178
243
|
|
|
179
|
-
with Session(
|
|
244
|
+
with Session(driver) as session:
|
|
180
245
|
user = session.get(User, "bob")
|
|
181
246
|
session.delete(user)
|
|
182
247
|
session.commit()
|
|
@@ -186,12 +251,12 @@ with Session(graph) as session:
|
|
|
186
251
|
|
|
187
252
|
```python
|
|
188
253
|
# Lazy load (default) — triggers a query on first access
|
|
189
|
-
with Session(
|
|
254
|
+
with Session(driver) as session:
|
|
190
255
|
author = session.get(Author, "alice")
|
|
191
256
|
posts = author.posts # query executed here
|
|
192
257
|
|
|
193
258
|
# Eager load — single round-trip
|
|
194
|
-
with Session(
|
|
259
|
+
with Session(driver) as session:
|
|
195
260
|
author = session.get(Author, "alice", fetch=["posts"])
|
|
196
261
|
posts = author.posts # already loaded, no extra query
|
|
197
262
|
```
|
|
@@ -199,12 +264,12 @@ with Session(graph) as session:
|
|
|
199
264
|
### Pagination and custom queries
|
|
200
265
|
|
|
201
266
|
```python
|
|
202
|
-
from runic.orm import
|
|
267
|
+
from runic.orm import Repository
|
|
203
268
|
|
|
204
|
-
with Session(
|
|
269
|
+
with Session(driver) as session:
|
|
205
270
|
repo = Repository(session, User)
|
|
206
|
-
|
|
207
|
-
|
|
271
|
+
first_page = repo.find_all(skip=0, limit=20)
|
|
272
|
+
next_page = repo.find_all(skip=20, limit=20)
|
|
208
273
|
```
|
|
209
274
|
|
|
210
275
|
Extend `Repository` to add typed Cypher helpers:
|
|
@@ -219,6 +284,28 @@ class UserRepository(Repository[User]):
|
|
|
219
284
|
)
|
|
220
285
|
```
|
|
221
286
|
|
|
287
|
+
### Composable queries
|
|
288
|
+
|
|
289
|
+
`select()` creates a query statement independently of any session, enabling
|
|
290
|
+
dynamic query composition (e.g. conditional UI filters) before execution:
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
from runic.orm import select
|
|
294
|
+
|
|
295
|
+
stmt = select(User).where(User.active == True)
|
|
296
|
+
if min_age > 0:
|
|
297
|
+
stmt = stmt.where(User.age >= min_age)
|
|
298
|
+
|
|
299
|
+
with Session(driver) as session:
|
|
300
|
+
users: list[User] = session.scalars(stmt) # list[User]
|
|
301
|
+
user: User | None = session.scalar(stmt) # User | None
|
|
302
|
+
n: int = session.count(stmt) # int
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
The same `stmt` is reusable — pass it to multiple sessions or execute it
|
|
306
|
+
multiple times. The older `session.query(User).where(...).all()` pattern is
|
|
307
|
+
still fully supported.
|
|
308
|
+
|
|
222
309
|
### Schema management
|
|
223
310
|
|
|
224
311
|
Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
|
|
@@ -235,14 +322,14 @@ class Place(Node, labels=["Place"]):
|
|
|
235
322
|
lon: float = Field(index=True)
|
|
236
323
|
|
|
237
324
|
|
|
238
|
-
schema = SchemaManager(
|
|
325
|
+
schema = SchemaManager(driver)
|
|
239
326
|
schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
|
|
240
327
|
|
|
241
328
|
result = schema.validate_schema([Place])
|
|
242
329
|
print("valid:", result.is_valid)
|
|
243
330
|
```
|
|
244
331
|
|
|
245
|
-
### Native
|
|
332
|
+
### Native graph types
|
|
246
333
|
|
|
247
334
|
`Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
|
|
248
335
|
|
|
@@ -1,32 +1,12 @@
|
|
|
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
|
-

|
|
9
|
+
[](https://pypi.org/project/runic-py/)
|
|
30
10
|
[](https://www.python.org)
|
|
31
11
|
[](LICENSE.md)
|
|
32
12
|
|
|
@@ -36,44 +16,64 @@ Description-Content-Type: text/markdown
|
|
|
36
16
|
|
|
37
17
|
---
|
|
38
18
|
|
|
39
|
-
**Runic** is a Python toolkit for
|
|
19
|
+
**Runic** is a Python toolkit for Cypher-based graph databases that covers two layers:
|
|
40
20
|
|
|
41
21
|
- **`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
|
|
22
|
+
- **`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
23
|
|
|
44
24
|
## Features
|
|
45
25
|
|
|
46
26
|
### Migration CLI
|
|
47
27
|
|
|
48
28
|
- **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
|
|
49
|
-
- **Graph-Native** — Migration state stored inside dedicated graph nodes
|
|
29
|
+
- **Graph-Native** — Migration state stored inside dedicated graph nodes; no external state table needed.
|
|
50
30
|
- **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
|
|
51
31
|
- **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
|
|
52
|
-
- **Rollback Snapshots** —
|
|
32
|
+
- **Rollback Snapshots** — Optional snapshot-based rollback for high-risk, non-reversible migrations.
|
|
53
33
|
|
|
54
34
|
### Graph ORM
|
|
55
35
|
|
|
56
36
|
- **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
|
|
57
|
-
- **
|
|
37
|
+
- **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.
|
|
38
|
+
- **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and offset reads.
|
|
58
39
|
- **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
|
|
59
40
|
- **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
|
|
60
|
-
- **Native
|
|
41
|
+
- **Native Graph Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
|
|
61
42
|
- **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
|
|
62
43
|
|
|
63
44
|
## Installation
|
|
64
45
|
|
|
65
|
-
|
|
66
|
-
uv pip install runic
|
|
67
|
-
```
|
|
46
|
+
Install the core package, then add the optional extra for your database backend:
|
|
68
47
|
|
|
69
|
-
|
|
48
|
+
| Backend | Extra | Package installed |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| FalkorDB | `falkordb` | `falkordb` |
|
|
51
|
+
| Neo4j | `neo4j` | `neo4j` |
|
|
52
|
+
| Memgraph | `memgraph` | `neo4j` (Bolt) |
|
|
53
|
+
| ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
|
|
54
|
+
| Apache AGE | `age` | `psycopg[binary]` |
|
|
55
|
+
| All backends | `all` | all of the above |
|
|
70
56
|
|
|
71
57
|
```bash
|
|
72
|
-
|
|
58
|
+
# FalkorDB
|
|
59
|
+
uv add "runic-py[falkordb]"
|
|
60
|
+
|
|
61
|
+
# Neo4j
|
|
62
|
+
uv add "runic-py[neo4j]"
|
|
63
|
+
|
|
64
|
+
# Memgraph or ArcadeDB (both use the Neo4j Bolt driver)
|
|
65
|
+
uv add "runic-py[memgraph]"
|
|
66
|
+
uv add "runic-py[arcadedb]"
|
|
67
|
+
|
|
68
|
+
# Apache AGE (PostgreSQL extension)
|
|
69
|
+
uv add "runic-py[age]"
|
|
70
|
+
|
|
71
|
+
# All backends at once
|
|
72
|
+
uv add "runic-py[all]"
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
> [!NOTE]
|
|
76
|
-
> Runic requires Python 3.14
|
|
76
|
+
> Runic requires Python 3.14+. The core package has no graph-driver dependency — install only the extra for your backend.
|
|
77
77
|
|
|
78
78
|
---
|
|
79
79
|
|
|
@@ -111,7 +111,7 @@ runic downgrade 1975e # roll back to a specific revision (prefix is enough)
|
|
|
111
111
|
|
|
112
112
|
### Baselining an existing graph
|
|
113
113
|
|
|
114
|
-
Bring an
|
|
114
|
+
Bring an existing graph under runic control without re-running anything:
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
117
|
runic baseline -m "baseline" # introspect, generate root revision, stamp it
|
|
@@ -133,9 +133,12 @@ from runic.migrate.adapters import create_adapter
|
|
|
133
133
|
|
|
134
134
|
init(Path("runic/"))
|
|
135
135
|
|
|
136
|
+
# Any supported backend — swap the adapter name and kwargs
|
|
136
137
|
adapter = create_adapter(
|
|
137
138
|
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
|
|
138
139
|
)
|
|
140
|
+
# adapter = create_adapter("neo4j", host="localhost", port=7687, database="neo4j", username="neo4j", password="secret")
|
|
141
|
+
|
|
139
142
|
runic = Runic(adapter, script_location=Path("runic/"))
|
|
140
143
|
runic.migrate.upgrade("head")
|
|
141
144
|
|
|
@@ -181,23 +184,28 @@ class Author(Node, labels=["Author"]):
|
|
|
181
184
|
|
|
182
185
|
### Session-based CRUD
|
|
183
186
|
|
|
187
|
+
`Session` accepts any `GraphDriver`. Use `create_driver()` to build one for your backend:
|
|
188
|
+
|
|
184
189
|
```python
|
|
185
|
-
from runic.orm import Session, Repository
|
|
190
|
+
from runic.orm import Session, Repository, create_driver
|
|
186
191
|
|
|
187
|
-
|
|
192
|
+
# Pick your backend — the rest of the code is identical
|
|
193
|
+
driver = create_driver("falkordb", host="localhost", port=6379, graph="myapp")
|
|
194
|
+
|
|
195
|
+
with Session(driver) as session:
|
|
188
196
|
session.add_all([
|
|
189
197
|
User(id="alice", email="alice@example.com", name="Alice"),
|
|
190
198
|
User(id="bob", email="bob@example.com", name="Bob"),
|
|
191
199
|
])
|
|
192
200
|
session.commit()
|
|
193
201
|
|
|
194
|
-
with Session(
|
|
202
|
+
with Session(driver) as session:
|
|
195
203
|
repo = Repository(session, User)
|
|
196
204
|
alice = session.get(User, "alice")
|
|
197
205
|
alice.name = "Alice Smith" # change tracking — no explicit dirty flag
|
|
198
206
|
session.commit()
|
|
199
207
|
|
|
200
|
-
with Session(
|
|
208
|
+
with Session(driver) as session:
|
|
201
209
|
user = session.get(User, "bob")
|
|
202
210
|
session.delete(user)
|
|
203
211
|
session.commit()
|
|
@@ -207,12 +215,12 @@ with Session(graph) as session:
|
|
|
207
215
|
|
|
208
216
|
```python
|
|
209
217
|
# Lazy load (default) — triggers a query on first access
|
|
210
|
-
with Session(
|
|
218
|
+
with Session(driver) as session:
|
|
211
219
|
author = session.get(Author, "alice")
|
|
212
220
|
posts = author.posts # query executed here
|
|
213
221
|
|
|
214
222
|
# Eager load — single round-trip
|
|
215
|
-
with Session(
|
|
223
|
+
with Session(driver) as session:
|
|
216
224
|
author = session.get(Author, "alice", fetch=["posts"])
|
|
217
225
|
posts = author.posts # already loaded, no extra query
|
|
218
226
|
```
|
|
@@ -220,12 +228,12 @@ with Session(graph) as session:
|
|
|
220
228
|
### Pagination and custom queries
|
|
221
229
|
|
|
222
230
|
```python
|
|
223
|
-
from runic.orm import
|
|
231
|
+
from runic.orm import Repository
|
|
224
232
|
|
|
225
|
-
with Session(
|
|
233
|
+
with Session(driver) as session:
|
|
226
234
|
repo = Repository(session, User)
|
|
227
|
-
|
|
228
|
-
|
|
235
|
+
first_page = repo.find_all(skip=0, limit=20)
|
|
236
|
+
next_page = repo.find_all(skip=20, limit=20)
|
|
229
237
|
```
|
|
230
238
|
|
|
231
239
|
Extend `Repository` to add typed Cypher helpers:
|
|
@@ -240,6 +248,28 @@ class UserRepository(Repository[User]):
|
|
|
240
248
|
)
|
|
241
249
|
```
|
|
242
250
|
|
|
251
|
+
### Composable queries
|
|
252
|
+
|
|
253
|
+
`select()` creates a query statement independently of any session, enabling
|
|
254
|
+
dynamic query composition (e.g. conditional UI filters) before execution:
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from runic.orm import select
|
|
258
|
+
|
|
259
|
+
stmt = select(User).where(User.active == True)
|
|
260
|
+
if min_age > 0:
|
|
261
|
+
stmt = stmt.where(User.age >= min_age)
|
|
262
|
+
|
|
263
|
+
with Session(driver) as session:
|
|
264
|
+
users: list[User] = session.scalars(stmt) # list[User]
|
|
265
|
+
user: User | None = session.scalar(stmt) # User | None
|
|
266
|
+
n: int = session.count(stmt) # int
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The same `stmt` is reusable — pass it to multiple sessions or execute it
|
|
270
|
+
multiple times. The older `session.query(User).where(...).all()` pattern is
|
|
271
|
+
still fully supported.
|
|
272
|
+
|
|
243
273
|
### Schema management
|
|
244
274
|
|
|
245
275
|
Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
|
|
@@ -256,14 +286,14 @@ class Place(Node, labels=["Place"]):
|
|
|
256
286
|
lon: float = Field(index=True)
|
|
257
287
|
|
|
258
288
|
|
|
259
|
-
schema = SchemaManager(
|
|
289
|
+
schema = SchemaManager(driver)
|
|
260
290
|
schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
|
|
261
291
|
|
|
262
292
|
result = schema.validate_schema([Place])
|
|
263
293
|
print("valid:", result.is_valid)
|
|
264
294
|
```
|
|
265
295
|
|
|
266
|
-
### Native
|
|
296
|
+
### Native graph types
|
|
267
297
|
|
|
268
298
|
`Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
|
|
269
299
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "runic-py"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.3.2"
|
|
4
|
+
description = "Graph schema migrations and ORM for Cypher-based graph databases."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
7
7
|
license = { text = "MIT" }
|
|
@@ -12,39 +12,55 @@ classifiers = [
|
|
|
12
12
|
"License :: OSI Approved :: MIT License",
|
|
13
13
|
"Programming Language :: Python :: 3",
|
|
14
14
|
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
-
"Topic ::
|
|
15
|
+
"Topic :: Database",
|
|
16
16
|
]
|
|
17
17
|
|
|
18
18
|
dependencies = [
|
|
19
19
|
"typer>=0.26.0,<1.0.0",
|
|
20
|
-
"rich>=
|
|
21
|
-
"falkordb>=1.6.0,<2.0.0",
|
|
20
|
+
"rich>=14.0.0,<16.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,<7.0.0"]
|
|
27
|
+
memgraph = ["neo4j>=6.2.0,<7.0.0"]
|
|
28
|
+
arcadedb = ["neo4j>=6.2.0,<7.0.0"]
|
|
29
|
+
age = ["psycopg>=3.3.4,<4.0.0"]
|
|
30
|
+
age-binary = ["psycopg[binary]>=3.3.4,<4.0.0"]
|
|
31
|
+
all = [
|
|
32
|
+
"falkordb>=1.6.0,<2.0.0",
|
|
33
|
+
"neo4j>=6.2.0,<7.0.0",
|
|
34
|
+
"psycopg[binary]>=3.3.4,<4.0.0",
|
|
35
|
+
]
|
|
25
36
|
[dependency-groups]
|
|
26
37
|
dev = [
|
|
27
38
|
"bump-my-version==1.3.0",
|
|
39
|
+
"deepeval==4.0.5",
|
|
28
40
|
"detect-secrets==1.5.0",
|
|
41
|
+
"falkordb>=1.6.1",
|
|
42
|
+
"falkordblite==0.10.0",
|
|
43
|
+
"furo==2025.12.19",
|
|
44
|
+
"neo4j==6.2.0",
|
|
29
45
|
"pre-commit==4.6.0",
|
|
30
|
-
"
|
|
46
|
+
"psycopg[binary]==3.3.4",
|
|
31
47
|
"pytest-asyncio==1.4.0",
|
|
32
48
|
"pytest-cov==7.1.0",
|
|
33
49
|
"pytest-httpserver==1.1.5",
|
|
50
|
+
"pytest==9.0.3",
|
|
51
|
+
"python-dotenv==1.2.2",
|
|
34
52
|
"ruff==0.15.16",
|
|
35
|
-
"falkordblite==0.10.0",
|
|
36
|
-
"sphinx==9.1.0",
|
|
37
|
-
"furo==2025.12.19",
|
|
38
53
|
"sphinx-copybutton==0.5.2",
|
|
39
54
|
"sphinx-design==0.7.0",
|
|
40
|
-
"
|
|
55
|
+
"sphinx==9.1.0",
|
|
56
|
+
"ty==0.0.44",
|
|
41
57
|
]
|
|
42
58
|
|
|
43
59
|
[project.scripts]
|
|
44
60
|
runic = "runic.migrate.cli:app"
|
|
45
61
|
|
|
46
62
|
[project.urls]
|
|
47
|
-
Homepage = "https://runic-py.readthedocs.io/latest/"
|
|
63
|
+
Homepage = "https://runic-py.readthedocs.io/en/latest/"
|
|
48
64
|
Repository = "https://github.com/jenreh/runic"
|
|
49
65
|
|
|
50
66
|
[tool.hatch.build.targets.wheel]
|
|
@@ -76,7 +92,7 @@ exclude = [
|
|
|
76
92
|
|
|
77
93
|
[tool.bumpversion]
|
|
78
94
|
commit = false
|
|
79
|
-
current_version = "0.
|
|
95
|
+
current_version = "0.3.2"
|
|
80
96
|
tag = false
|
|
81
97
|
allow-dirty = false
|
|
82
98
|
|
|
@@ -92,8 +108,13 @@ replace = ''
|
|
|
92
108
|
|
|
93
109
|
[[tool.bumpversion.files]]
|
|
94
110
|
filename = "docs/source/index.rst"
|
|
95
|
-
search = '
|
|
96
|
-
replace = '
|
|
111
|
+
search = '.. |Github| image:: https://img.shields.io/badge/release-{current_version}-yellow?logo=github'
|
|
112
|
+
replace = '.. |Github| image:: https://img.shields.io/badge/release-{new_version}-yellow?logo=github'
|
|
113
|
+
|
|
114
|
+
[[tool.bumpversion.files]]
|
|
115
|
+
filename = "docs/source/index.rst"
|
|
116
|
+
search = ' :target: https://github.com/jenreh/runic/tree/v{current_version}'
|
|
117
|
+
replace = ' :target: https://github.com/jenreh/runic/tree/v{new_version}'
|
|
97
118
|
|
|
98
119
|
[[tool.bumpversion.files]]
|
|
99
120
|
filename = "docs/source/conf.py"
|
|
@@ -228,10 +249,14 @@ known-local-folder = ["sonos"]
|
|
|
228
249
|
[tool.pytest.ini_options]
|
|
229
250
|
cache_dir = ".cache/pytest"
|
|
230
251
|
testpaths = ["tests"]
|
|
252
|
+
# Evals (tests/evals) are excluded from the default suite; run explicitly with
|
|
253
|
+
# `uv run pytest tests/evals`.
|
|
254
|
+
addopts = "--ignore=tests/evals"
|
|
231
255
|
asyncio_mode = "auto"
|
|
232
256
|
markers = [
|
|
233
|
-
"
|
|
234
|
-
"
|
|
257
|
+
"integration: requires a live graph backend (embedded FalkorDB via redislite by default; other backends via RUNIC_TEST_BACKENDS env var + docker-compose.test.yml)",
|
|
258
|
+
"requires_multi_label: test requires multi-label node support (not available on Apache AGE)",
|
|
259
|
+
"requires_geo_update: test requires SET n.prop = point() support (not available on ArcadeDB via Bolt)",
|
|
235
260
|
]
|
|
236
261
|
|
|
237
262
|
[tool.ty.environment]
|
|
@@ -245,12 +270,16 @@ exclude = [
|
|
|
245
270
|
".venv",
|
|
246
271
|
"build",
|
|
247
272
|
"dist",
|
|
273
|
+
"examples",
|
|
248
274
|
]
|
|
249
275
|
|
|
250
276
|
[tool.ty.rules]
|
|
251
277
|
unresolved-import = "warn"
|
|
252
278
|
missing-argument = "error"
|
|
253
279
|
|
|
280
|
+
[tool.mypy]
|
|
281
|
+
cache_dir = ".cache/mypy"
|
|
282
|
+
|
|
254
283
|
[tool.coverage.run]
|
|
255
284
|
branch = true
|
|
256
285
|
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",
|