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.
- {runic_py-0.2.0 → runic_py-0.3.0}/.gitignore +1 -0
- runic_py-0.3.0/PKG-INFO +357 -0
- runic_py-0.3.0/README.md +323 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/pyproject.toml +27 -7
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/__init__.py +10 -0
- {runic_py-0.2.0 → 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.0 → 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.0 → runic_py-0.3.0}/runic/migrate/cli.py +29 -20
- {runic_py-0.2.0 → 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.0 → runic_py-0.3.0}/runic/migrate/testing.py +17 -6
- runic_py-0.3.0/runic/orm/__init__.py +163 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/descriptors.py +125 -1
- {runic_py-0.2.0 → 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.3.0/runic/orm/mapper/__init__.py +5 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/mapper/mapper.py +175 -61
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/mapper/relationship_loader.py +26 -15
- runic_py-0.3.0/runic/orm/mapper/relationship_writer.py +161 -0
- runic_py-0.3.0/runic/orm/operations.py +88 -0
- runic_py-0.3.0/runic/orm/query/__init__.py +102 -0
- runic_py-0.3.0/runic/orm/query/builder.py +1114 -0
- runic_py-0.3.0/runic/orm/query/expressions.py +349 -0
- runic_py-0.3.0/runic/orm/query/specialised.py +350 -0
- runic_py-0.3.0/runic/orm/query/traversal.py +144 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/__init__.py +1 -4
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/async_repository.py +35 -24
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/cypher.py +7 -22
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/protocol.py +12 -8
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/repository/repository.py +48 -28
- 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.3.0/runic/orm/session/async_session.py +601 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/session/connection_pool.py +14 -13
- runic_py-0.3.0/runic/orm/session/session.py +741 -0
- runic_py-0.2.0/PKG-INFO +0 -192
- runic_py-0.2.0/README.md +0 -171
- runic_py-0.2.0/runic/orm/__init__.py +0 -82
- runic_py-0.2.0/runic/orm/repository/pagination.py +0 -105
- runic_py-0.2.0/runic/orm/schema/__init__.py +0 -11
- runic_py-0.2.0/runic/orm/schema/index_manager.py +0 -195
- runic_py-0.2.0/runic/orm/schema/schema_manager.py +0 -173
- runic_py-0.2.0/runic/orm/session/__init__.py +0 -0
- runic_py-0.2.0/runic/orm/session/async_session.py +0 -340
- runic_py-0.2.0/runic/orm/session/session.py +0 -385
- {runic_py-0.2.0 → runic_py-0.3.0}/LICENSE.md +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/autogen.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/checksum.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/config.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/context.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/exceptions.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/introspect.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/manifest.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/script.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/service.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/templates/env.py.mako +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/templates/script.py.mako +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/migrate/version.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/__init__.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/metadata.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/core/models.py +0 -0
- {runic_py-0.2.0 → runic_py-0.3.0}/runic/orm/exceptions.py +0 -0
- {runic_py-0.2.0/runic/orm/mapper → runic_py-0.3.0/runic/orm/session}/__init__.py +0 -0
runic_py-0.3.0/PKG-INFO
ADDED
|
@@ -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
|
+

|
|
43
|
+
[](https://www.python.org)
|
|
44
|
+
[](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/).
|
runic_py-0.3.0/README.md
ADDED
|
@@ -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
|
+

|
|
9
|
+
[](https://www.python.org)
|
|
10
|
+
[](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/).
|