runic-py 0.3.6__tar.gz → 0.3.7__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.3.7/PKG-INFO +337 -0
- runic_py-0.3.7/README.md +300 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/pyproject.toml +3 -3
- runic_py-0.3.7/runic/py.typed +0 -0
- runic_py-0.3.6/PKG-INFO +0 -361
- runic_py-0.3.6/README.md +0 -324
- {runic_py-0.3.6 → runic_py-0.3.7}/.gitignore +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/LICENSE.md +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/cypher.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/_base.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/age.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/arcadedb.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/falkordb.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/memgraph.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/neo4j.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/autogen.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/checksum.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/cli.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/config.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/context.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/exceptions.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/introspect.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/manifest.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/operations.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/schema.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/script.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/service.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/templates/env.py.mako +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/templates/script.py.mako +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/testing.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/version.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/descriptors.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/metadata.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/models.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/types.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/age.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/arcadedb.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/bolt.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/factory.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/falkordb.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/memgraph.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/neo4j.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/exceptions.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/mapper/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/mapper/mapper.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/mapper/relationship_loader.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/mapper/relationship_writer.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/operations.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/_compiler.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/_decoder.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/builder.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/expressions.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/specialised.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/traversal.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/_base.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/async_repository.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/cypher.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/protocol.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/repository.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/schema/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/schema/index_manager.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/__init__.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/_base.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/async_session.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/connection_pool.py +0 -0
- {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/session.py +0 -0
runic_py-0.3.7/PKG-INFO
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runic-py
|
|
3
|
+
Version: 0.3.7
|
|
4
|
+
Summary: Graph schema migrations and OGM for Cypher-based graph databases.
|
|
5
|
+
Project-URL: Homepage, https://runic.rehpoehler.de
|
|
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: shellingham<2.0.0,>=1.5.0
|
|
19
|
+
Requires-Dist: typer<1.0.0,>=0.26.0
|
|
20
|
+
Provides-Extra: age
|
|
21
|
+
Requires-Dist: psycopg<4.0.0,>=3.3.4; extra == 'age'
|
|
22
|
+
Provides-Extra: age-binary
|
|
23
|
+
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.4; extra == 'age-binary'
|
|
24
|
+
Provides-Extra: all
|
|
25
|
+
Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'all'
|
|
26
|
+
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'all'
|
|
27
|
+
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.4; extra == 'all'
|
|
28
|
+
Provides-Extra: arcadedb
|
|
29
|
+
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'arcadedb'
|
|
30
|
+
Provides-Extra: falkordb
|
|
31
|
+
Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'falkordb'
|
|
32
|
+
Provides-Extra: memgraph
|
|
33
|
+
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'memgraph'
|
|
34
|
+
Provides-Extra: neo4j
|
|
35
|
+
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'neo4j'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
<div align="center">
|
|
39
|
+
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/public/runic.svg" width="240" alt="Runic logo">
|
|
40
|
+
|
|
41
|
+
# Runic
|
|
42
|
+
|
|
43
|
+
**A type-safe OGM for Cypher graph databases.<br>
|
|
44
|
+
Define your models once, run them on any backend.**
|
|
45
|
+
|
|
46
|
+

|
|
47
|
+
[](https://pypi.org/project/runic-py/)
|
|
48
|
+
[](https://www.python.org)
|
|
49
|
+
[](LICENSE.md)
|
|
50
|
+
|
|
51
|
+
[Why Runic](#why-runic) • [The OGM](#the-ogm) • [Migrations](#migrations) • [Installation](#installation) • [Docs](https://runic.rehpoehler.de)
|
|
52
|
+
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
Runic maps Python classes to graph nodes and edges. You declare typed `Node` and `Edge` models
|
|
58
|
+
and get change tracking, lazy and eager relationships, a composable query API, and schema
|
|
59
|
+
migrations — on top of a pluggable driver layer that runs the same model code on FalkorDB,
|
|
60
|
+
Neo4j, Memgraph, ArcadeDB, and Apache AGE.
|
|
61
|
+
|
|
62
|
+
## Why Runic
|
|
63
|
+
|
|
64
|
+
- **Backend-agnostic.** One model definition runs on five backends. Switching from FalkorDB to
|
|
65
|
+
Neo4j means changing the arguments to `create_driver()`; your models, queries, and application
|
|
66
|
+
code don't change.
|
|
67
|
+
- **Typed models, no metaclass magic.** `Node` and `Edge` are plain classes with typed `Field`
|
|
68
|
+
descriptors — IDE autocomplete works, and `Author.name == "Alice"` builds a query predicate.
|
|
69
|
+
- **Change tracking.** Mutate an object and call `commit()`; the unit-of-work session computes
|
|
70
|
+
the diff and writes only what changed. No manual dirty flags, no hand-written `SET` clauses.
|
|
71
|
+
- **First-class relationships.** Declare `Relation` fields for `INCOMING`/`OUTGOING` edges,
|
|
72
|
+
with edge-property models, and choose lazy loading or single-round-trip eager fetch.
|
|
73
|
+
- **Native graph types.** `Vector` (vecf32), `GeoLocation` (point), interned strings, and
|
|
74
|
+
automatic converters for `datetime` and `Enum` — stored without writing serialization code.
|
|
75
|
+
- **Migrations included.** A migration tool with versioned revisions, a CLI, and rollback
|
|
76
|
+
snapshots. Revision state lives inside the graph, so there's no external state table.
|
|
77
|
+
- **Sync and async.** `Session`/`Repository` and `AsyncSession`/`AsyncRepository` share one API.
|
|
78
|
+
|
|
79
|
+
## The OGM
|
|
80
|
+
|
|
81
|
+
The two examples below build on one domain — `Author`, `Article`, and an `AUTHORED` edge —
|
|
82
|
+
and cover the features you'll reach for most.
|
|
83
|
+
|
|
84
|
+
### 1. Model a domain, persist it, and wire up relationships
|
|
85
|
+
|
|
86
|
+
Declare typed `Node` and `Edge` classes. Constraints and indexes go inline on `Field`; native
|
|
87
|
+
graph types (`Vector`, `GeoLocation`, `datetime`, `Enum`) get their converters assigned
|
|
88
|
+
automatically; and `Relation` declares a traversal with an optional edge-property model.
|
|
89
|
+
`SchemaManager` reconciles those declarations with the live graph, a `Session` handles writes
|
|
90
|
+
with automatic change tracking, and `session.relate()` creates the edges between nodes.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from datetime import UTC, datetime
|
|
94
|
+
from enum import StrEnum
|
|
95
|
+
|
|
96
|
+
from runic.ogm import (
|
|
97
|
+
Edge,
|
|
98
|
+
Field,
|
|
99
|
+
GeoLocation,
|
|
100
|
+
Node,
|
|
101
|
+
Relation,
|
|
102
|
+
SchemaManager,
|
|
103
|
+
Session,
|
|
104
|
+
Vector,
|
|
105
|
+
create_driver,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Status(StrEnum):
|
|
110
|
+
DRAFT = "draft"
|
|
111
|
+
PUBLISHED = "published"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Article(Node, labels=["Article"]):
|
|
115
|
+
id: str = Field(primary_key=True)
|
|
116
|
+
title: str = Field(index_type="FULLTEXT") # fulltext search: FalkorDB/Neo4j/Memgraph
|
|
117
|
+
category: str = Field(interned=True) # intern() dedup — FalkorDB only, no-op elsewhere
|
|
118
|
+
status: Status = Status.DRAFT # EnumConverter auto-assigned
|
|
119
|
+
published_at: datetime | None = None # DatetimeConverter auto-assigned
|
|
120
|
+
embedding: Vector | None = None # KNN via vecf32() on FalkorDB; Neo4j/Memgraph
|
|
121
|
+
# need a pre-created VECTOR INDEX
|
|
122
|
+
origin: GeoLocation | None = None # point(); updates unsupported on ArcadeDB
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AuthoredEdge(Edge, type="AUTHORED"):
|
|
126
|
+
created_at: datetime
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Author(Node, labels=["Author"]):
|
|
130
|
+
id: str = Field(primary_key=True)
|
|
131
|
+
email: str = Field(unique=True)
|
|
132
|
+
name: str
|
|
133
|
+
articles: list[Article] = Relation(
|
|
134
|
+
relationship="AUTHORED",
|
|
135
|
+
direction="OUTGOING",
|
|
136
|
+
target="Article",
|
|
137
|
+
edge_model=AuthoredEdge,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Pick a backend here — nothing else in this file changes.
|
|
142
|
+
driver = create_driver("falkordb", host="localhost", port=6379, graph="blog")
|
|
143
|
+
|
|
144
|
+
# Reconcile declared indexes/constraints with the live graph.
|
|
145
|
+
schema = SchemaManager(driver)
|
|
146
|
+
schema.sync_schema([Author, Article], drop_extra=False) # create missing; keep extras
|
|
147
|
+
|
|
148
|
+
with Session(driver) as session:
|
|
149
|
+
alice = Author(id="alice", email="alice@example.com", name="Alice")
|
|
150
|
+
intro = Article(id="a1", title="Graphs 101", category="intro", status=Status.PUBLISHED)
|
|
151
|
+
session.add_all([alice, intro])
|
|
152
|
+
session.commit()
|
|
153
|
+
|
|
154
|
+
# Create the AUTHORED edge, writing properties onto the relationship itself.
|
|
155
|
+
# relate() is MERGE-based: idempotent, and re-calling updates the edge props.
|
|
156
|
+
session.relate(alice, Author.articles, intro, edge=AuthoredEdge(created_at=datetime.now(UTC)))
|
|
157
|
+
session.commit()
|
|
158
|
+
|
|
159
|
+
with Session(driver) as session:
|
|
160
|
+
alice = session.get(Author, "alice")
|
|
161
|
+
alice.name = "Alice Smith" # tracked automatically — no explicit dirty flag
|
|
162
|
+
session.commit() # only the diff is written
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 2. Query and traverse the graph
|
|
166
|
+
|
|
167
|
+
Read data back with composable, type-safe statements, multi-hop traversals, paginated
|
|
168
|
+
repositories, or your own Cypher. `select()` builds a statement independently of any session,
|
|
169
|
+
so you can assemble it from conditional filters and reuse it across sessions. `.traverse()`
|
|
170
|
+
walks a single relationship; `.repeat()` walks one to any depth for real-world graph queries.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from runic.ogm import Repository, Session, select
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Compose a query dynamically, then run it three ways.
|
|
177
|
+
stmt = select(Article).where(Article.status == Status.PUBLISHED)
|
|
178
|
+
if category:
|
|
179
|
+
stmt = stmt.where(Article.category == category)
|
|
180
|
+
|
|
181
|
+
with Session(driver) as session:
|
|
182
|
+
articles: list[Article] = session.scalars(stmt) # list[Article]
|
|
183
|
+
latest: Article | None = session.scalar(stmt) # Article | None
|
|
184
|
+
n: int = session.count(stmt) # int
|
|
185
|
+
|
|
186
|
+
# Single-hop traversal with an edge-property filter — published articles
|
|
187
|
+
# Alice authored after a cutoff date.
|
|
188
|
+
recent = (
|
|
189
|
+
session.query(Author)
|
|
190
|
+
.alias("a")
|
|
191
|
+
.where(Author.id == "alice")
|
|
192
|
+
.traverse(Author.articles, edge_alias="e")
|
|
193
|
+
.alias("art")
|
|
194
|
+
.where(AuthoredEdge.created_at >= cutoff, on="e")
|
|
195
|
+
.where(Article.status == Status.PUBLISHED, on="art")
|
|
196
|
+
.return_target("art")
|
|
197
|
+
.all()
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Variable-length traversal — every article reachable within 3 AUTHORED hops
|
|
201
|
+
# (e.g. co-authorship chains). max_hops=None means unbounded.
|
|
202
|
+
network = (
|
|
203
|
+
session.query(Author)
|
|
204
|
+
.alias("a")
|
|
205
|
+
.where(Author.id == "alice")
|
|
206
|
+
.repeat(Author.articles, min_hops=1, max_hops=3)
|
|
207
|
+
.alias("reached")
|
|
208
|
+
.all()
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Paginate through a repository.
|
|
212
|
+
page = Repository(session, Article).find_all(skip=0, limit=20)
|
|
213
|
+
|
|
214
|
+
# Or load relationships off an entity: lazy by default, eager on request.
|
|
215
|
+
author = session.get(Author, "alice")
|
|
216
|
+
author.articles # lazy — queried on first access
|
|
217
|
+
eager = session.get(Author, "alice", fetch=["articles"])
|
|
218
|
+
eager.articles # already loaded, no extra query
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Subclass Repository to drop down to typed Cypher when you need it.
|
|
222
|
+
class ArticleRepository(Repository[Article]):
|
|
223
|
+
def by_author_email(self, email: str) -> list[Article]:
|
|
224
|
+
return self.cypher(
|
|
225
|
+
"MATCH (:Author {email: $email})-[:AUTHORED]->(a:Article) RETURN a",
|
|
226
|
+
{"email": email},
|
|
227
|
+
returns=Article,
|
|
228
|
+
)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
> [!TIP]
|
|
232
|
+
> Every pattern above has an async twin. `AsyncSession`, `AsyncRepository`, and
|
|
233
|
+
> `AsyncConnectionManager` share the same API for async-first applications.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Migrations
|
|
238
|
+
|
|
239
|
+
`runic.migrate` is a migration tool with a CLI for versioned schema evolution. It stores
|
|
240
|
+
revision state inside the graph, so there's no external state table to manage.
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
runic init
|
|
244
|
+
runic revision -m "create user index"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Edit the generated file in `runic/versions/`:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
revision = "1975ea83b712"
|
|
251
|
+
down_revision = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def upgrade(op) -> None:
|
|
255
|
+
op.create_range_index("User", "email")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def downgrade(op) -> None:
|
|
259
|
+
op.drop_range_index("User", "email")
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Apply or roll back:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
runic upgrade # apply all pending revisions
|
|
266
|
+
runic downgrade # roll back one step
|
|
267
|
+
runic downgrade 1975e # roll back to a specific revision (prefix is enough)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Baseline an existing graph** without re-running anything — introspect, generate a root
|
|
271
|
+
revision, and stamp it. The generated revision rebuilds the full schema on an empty graph,
|
|
272
|
+
so it's safe to replay for CI, cloning, or new tenants:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
runic baseline -m "baseline" # introspect, generate root revision, stamp it
|
|
276
|
+
runic current # verify it is now tracked
|
|
277
|
+
runic upgrade head # rebuild full schema on a fresh graph
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Programmatic SDK** — drive migrations from code, against any backend:
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
from pathlib import Path
|
|
284
|
+
from runic import Runic, init
|
|
285
|
+
from runic.migrate.adapters import create_adapter
|
|
286
|
+
|
|
287
|
+
init(Path("runic/"))
|
|
288
|
+
|
|
289
|
+
adapter = create_adapter(
|
|
290
|
+
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
|
|
291
|
+
)
|
|
292
|
+
# adapter = create_adapter("neo4j", host="localhost", port=7687,
|
|
293
|
+
# database="neo4j", username="neo4j", password="secret")
|
|
294
|
+
|
|
295
|
+
runic = Runic(adapter, script_location=Path("runic/"))
|
|
296
|
+
runic.migrate.upgrade("head")
|
|
297
|
+
print("current:", runic.migrate.current())
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Installation
|
|
303
|
+
|
|
304
|
+
Install the core package plus the extra for your backend. The core has **no graph-driver
|
|
305
|
+
dependency** — you only pull in what you use.
|
|
306
|
+
|
|
307
|
+
| Backend | Extra | Driver installed |
|
|
308
|
+
| --- | --- | --- |
|
|
309
|
+
| FalkorDB | `falkordb` | `falkordb` |
|
|
310
|
+
| Neo4j | `neo4j` | `neo4j` |
|
|
311
|
+
| Memgraph | `memgraph` | `neo4j` (Bolt) |
|
|
312
|
+
| ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
|
|
313
|
+
| Apache AGE | `age` | `psycopg[binary]` |
|
|
314
|
+
| All backends | `all` | all of the above |
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
uv add "runic-py[falkordb]" # FalkorDB
|
|
318
|
+
uv add "runic-py[neo4j]" # Neo4j
|
|
319
|
+
uv add "runic-py[memgraph]" # Memgraph (Bolt)
|
|
320
|
+
uv add "runic-py[arcadedb]" # ArcadeDB (Bolt)
|
|
321
|
+
uv add "runic-py[age]" # Apache AGE (PostgreSQL extension)
|
|
322
|
+
uv add "runic-py[all]" # everything
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
> [!NOTE]
|
|
326
|
+
> Runic requires Python 3.14+.
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Documentation
|
|
331
|
+
|
|
332
|
+
Full conceptual overview, async usage, advanced CLI flags, and the complete API reference live
|
|
333
|
+
at the **[Runic Documentation](https://runic.rehpoehler.de)**.
|
|
334
|
+
|
|
335
|
+
## License
|
|
336
|
+
|
|
337
|
+
Released under the [MIT License](LICENSE.md).
|
runic_py-0.3.7/README.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/public/runic.svg" width="240" alt="Runic logo">
|
|
3
|
+
|
|
4
|
+
# Runic
|
|
5
|
+
|
|
6
|
+
**A type-safe OGM for Cypher graph databases.<br>
|
|
7
|
+
Define your models once, run them on any backend.**
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
[](https://pypi.org/project/runic-py/)
|
|
11
|
+
[](https://www.python.org)
|
|
12
|
+
[](LICENSE.md)
|
|
13
|
+
|
|
14
|
+
[Why Runic](#why-runic) • [The OGM](#the-ogm) • [Migrations](#migrations) • [Installation](#installation) • [Docs](https://runic.rehpoehler.de)
|
|
15
|
+
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
Runic maps Python classes to graph nodes and edges. You declare typed `Node` and `Edge` models
|
|
21
|
+
and get change tracking, lazy and eager relationships, a composable query API, and schema
|
|
22
|
+
migrations — on top of a pluggable driver layer that runs the same model code on FalkorDB,
|
|
23
|
+
Neo4j, Memgraph, ArcadeDB, and Apache AGE.
|
|
24
|
+
|
|
25
|
+
## Why Runic
|
|
26
|
+
|
|
27
|
+
- **Backend-agnostic.** One model definition runs on five backends. Switching from FalkorDB to
|
|
28
|
+
Neo4j means changing the arguments to `create_driver()`; your models, queries, and application
|
|
29
|
+
code don't change.
|
|
30
|
+
- **Typed models, no metaclass magic.** `Node` and `Edge` are plain classes with typed `Field`
|
|
31
|
+
descriptors — IDE autocomplete works, and `Author.name == "Alice"` builds a query predicate.
|
|
32
|
+
- **Change tracking.** Mutate an object and call `commit()`; the unit-of-work session computes
|
|
33
|
+
the diff and writes only what changed. No manual dirty flags, no hand-written `SET` clauses.
|
|
34
|
+
- **First-class relationships.** Declare `Relation` fields for `INCOMING`/`OUTGOING` edges,
|
|
35
|
+
with edge-property models, and choose lazy loading or single-round-trip eager fetch.
|
|
36
|
+
- **Native graph types.** `Vector` (vecf32), `GeoLocation` (point), interned strings, and
|
|
37
|
+
automatic converters for `datetime` and `Enum` — stored without writing serialization code.
|
|
38
|
+
- **Migrations included.** A migration tool with versioned revisions, a CLI, and rollback
|
|
39
|
+
snapshots. Revision state lives inside the graph, so there's no external state table.
|
|
40
|
+
- **Sync and async.** `Session`/`Repository` and `AsyncSession`/`AsyncRepository` share one API.
|
|
41
|
+
|
|
42
|
+
## The OGM
|
|
43
|
+
|
|
44
|
+
The two examples below build on one domain — `Author`, `Article`, and an `AUTHORED` edge —
|
|
45
|
+
and cover the features you'll reach for most.
|
|
46
|
+
|
|
47
|
+
### 1. Model a domain, persist it, and wire up relationships
|
|
48
|
+
|
|
49
|
+
Declare typed `Node` and `Edge` classes. Constraints and indexes go inline on `Field`; native
|
|
50
|
+
graph types (`Vector`, `GeoLocation`, `datetime`, `Enum`) get their converters assigned
|
|
51
|
+
automatically; and `Relation` declares a traversal with an optional edge-property model.
|
|
52
|
+
`SchemaManager` reconciles those declarations with the live graph, a `Session` handles writes
|
|
53
|
+
with automatic change tracking, and `session.relate()` creates the edges between nodes.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from datetime import UTC, datetime
|
|
57
|
+
from enum import StrEnum
|
|
58
|
+
|
|
59
|
+
from runic.ogm import (
|
|
60
|
+
Edge,
|
|
61
|
+
Field,
|
|
62
|
+
GeoLocation,
|
|
63
|
+
Node,
|
|
64
|
+
Relation,
|
|
65
|
+
SchemaManager,
|
|
66
|
+
Session,
|
|
67
|
+
Vector,
|
|
68
|
+
create_driver,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Status(StrEnum):
|
|
73
|
+
DRAFT = "draft"
|
|
74
|
+
PUBLISHED = "published"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Article(Node, labels=["Article"]):
|
|
78
|
+
id: str = Field(primary_key=True)
|
|
79
|
+
title: str = Field(index_type="FULLTEXT") # fulltext search: FalkorDB/Neo4j/Memgraph
|
|
80
|
+
category: str = Field(interned=True) # intern() dedup — FalkorDB only, no-op elsewhere
|
|
81
|
+
status: Status = Status.DRAFT # EnumConverter auto-assigned
|
|
82
|
+
published_at: datetime | None = None # DatetimeConverter auto-assigned
|
|
83
|
+
embedding: Vector | None = None # KNN via vecf32() on FalkorDB; Neo4j/Memgraph
|
|
84
|
+
# need a pre-created VECTOR INDEX
|
|
85
|
+
origin: GeoLocation | None = None # point(); updates unsupported on ArcadeDB
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AuthoredEdge(Edge, type="AUTHORED"):
|
|
89
|
+
created_at: datetime
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Author(Node, labels=["Author"]):
|
|
93
|
+
id: str = Field(primary_key=True)
|
|
94
|
+
email: str = Field(unique=True)
|
|
95
|
+
name: str
|
|
96
|
+
articles: list[Article] = Relation(
|
|
97
|
+
relationship="AUTHORED",
|
|
98
|
+
direction="OUTGOING",
|
|
99
|
+
target="Article",
|
|
100
|
+
edge_model=AuthoredEdge,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Pick a backend here — nothing else in this file changes.
|
|
105
|
+
driver = create_driver("falkordb", host="localhost", port=6379, graph="blog")
|
|
106
|
+
|
|
107
|
+
# Reconcile declared indexes/constraints with the live graph.
|
|
108
|
+
schema = SchemaManager(driver)
|
|
109
|
+
schema.sync_schema([Author, Article], drop_extra=False) # create missing; keep extras
|
|
110
|
+
|
|
111
|
+
with Session(driver) as session:
|
|
112
|
+
alice = Author(id="alice", email="alice@example.com", name="Alice")
|
|
113
|
+
intro = Article(id="a1", title="Graphs 101", category="intro", status=Status.PUBLISHED)
|
|
114
|
+
session.add_all([alice, intro])
|
|
115
|
+
session.commit()
|
|
116
|
+
|
|
117
|
+
# Create the AUTHORED edge, writing properties onto the relationship itself.
|
|
118
|
+
# relate() is MERGE-based: idempotent, and re-calling updates the edge props.
|
|
119
|
+
session.relate(alice, Author.articles, intro, edge=AuthoredEdge(created_at=datetime.now(UTC)))
|
|
120
|
+
session.commit()
|
|
121
|
+
|
|
122
|
+
with Session(driver) as session:
|
|
123
|
+
alice = session.get(Author, "alice")
|
|
124
|
+
alice.name = "Alice Smith" # tracked automatically — no explicit dirty flag
|
|
125
|
+
session.commit() # only the diff is written
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 2. Query and traverse the graph
|
|
129
|
+
|
|
130
|
+
Read data back with composable, type-safe statements, multi-hop traversals, paginated
|
|
131
|
+
repositories, or your own Cypher. `select()` builds a statement independently of any session,
|
|
132
|
+
so you can assemble it from conditional filters and reuse it across sessions. `.traverse()`
|
|
133
|
+
walks a single relationship; `.repeat()` walks one to any depth for real-world graph queries.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from runic.ogm import Repository, Session, select
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Compose a query dynamically, then run it three ways.
|
|
140
|
+
stmt = select(Article).where(Article.status == Status.PUBLISHED)
|
|
141
|
+
if category:
|
|
142
|
+
stmt = stmt.where(Article.category == category)
|
|
143
|
+
|
|
144
|
+
with Session(driver) as session:
|
|
145
|
+
articles: list[Article] = session.scalars(stmt) # list[Article]
|
|
146
|
+
latest: Article | None = session.scalar(stmt) # Article | None
|
|
147
|
+
n: int = session.count(stmt) # int
|
|
148
|
+
|
|
149
|
+
# Single-hop traversal with an edge-property filter — published articles
|
|
150
|
+
# Alice authored after a cutoff date.
|
|
151
|
+
recent = (
|
|
152
|
+
session.query(Author)
|
|
153
|
+
.alias("a")
|
|
154
|
+
.where(Author.id == "alice")
|
|
155
|
+
.traverse(Author.articles, edge_alias="e")
|
|
156
|
+
.alias("art")
|
|
157
|
+
.where(AuthoredEdge.created_at >= cutoff, on="e")
|
|
158
|
+
.where(Article.status == Status.PUBLISHED, on="art")
|
|
159
|
+
.return_target("art")
|
|
160
|
+
.all()
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Variable-length traversal — every article reachable within 3 AUTHORED hops
|
|
164
|
+
# (e.g. co-authorship chains). max_hops=None means unbounded.
|
|
165
|
+
network = (
|
|
166
|
+
session.query(Author)
|
|
167
|
+
.alias("a")
|
|
168
|
+
.where(Author.id == "alice")
|
|
169
|
+
.repeat(Author.articles, min_hops=1, max_hops=3)
|
|
170
|
+
.alias("reached")
|
|
171
|
+
.all()
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Paginate through a repository.
|
|
175
|
+
page = Repository(session, Article).find_all(skip=0, limit=20)
|
|
176
|
+
|
|
177
|
+
# Or load relationships off an entity: lazy by default, eager on request.
|
|
178
|
+
author = session.get(Author, "alice")
|
|
179
|
+
author.articles # lazy — queried on first access
|
|
180
|
+
eager = session.get(Author, "alice", fetch=["articles"])
|
|
181
|
+
eager.articles # already loaded, no extra query
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Subclass Repository to drop down to typed Cypher when you need it.
|
|
185
|
+
class ArticleRepository(Repository[Article]):
|
|
186
|
+
def by_author_email(self, email: str) -> list[Article]:
|
|
187
|
+
return self.cypher(
|
|
188
|
+
"MATCH (:Author {email: $email})-[:AUTHORED]->(a:Article) RETURN a",
|
|
189
|
+
{"email": email},
|
|
190
|
+
returns=Article,
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
> [!TIP]
|
|
195
|
+
> Every pattern above has an async twin. `AsyncSession`, `AsyncRepository`, and
|
|
196
|
+
> `AsyncConnectionManager` share the same API for async-first applications.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Migrations
|
|
201
|
+
|
|
202
|
+
`runic.migrate` is a migration tool with a CLI for versioned schema evolution. It stores
|
|
203
|
+
revision state inside the graph, so there's no external state table to manage.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
runic init
|
|
207
|
+
runic revision -m "create user index"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Edit the generated file in `runic/versions/`:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
revision = "1975ea83b712"
|
|
214
|
+
down_revision = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def upgrade(op) -> None:
|
|
218
|
+
op.create_range_index("User", "email")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def downgrade(op) -> None:
|
|
222
|
+
op.drop_range_index("User", "email")
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Apply or roll back:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
runic upgrade # apply all pending revisions
|
|
229
|
+
runic downgrade # roll back one step
|
|
230
|
+
runic downgrade 1975e # roll back to a specific revision (prefix is enough)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Baseline an existing graph** without re-running anything — introspect, generate a root
|
|
234
|
+
revision, and stamp it. The generated revision rebuilds the full schema on an empty graph,
|
|
235
|
+
so it's safe to replay for CI, cloning, or new tenants:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
runic baseline -m "baseline" # introspect, generate root revision, stamp it
|
|
239
|
+
runic current # verify it is now tracked
|
|
240
|
+
runic upgrade head # rebuild full schema on a fresh graph
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
**Programmatic SDK** — drive migrations from code, against any backend:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
from pathlib import Path
|
|
247
|
+
from runic import Runic, init
|
|
248
|
+
from runic.migrate.adapters import create_adapter
|
|
249
|
+
|
|
250
|
+
init(Path("runic/"))
|
|
251
|
+
|
|
252
|
+
adapter = create_adapter(
|
|
253
|
+
"falkordb", url="falkor://localhost:6379", graph_name="my_graph"
|
|
254
|
+
)
|
|
255
|
+
# adapter = create_adapter("neo4j", host="localhost", port=7687,
|
|
256
|
+
# database="neo4j", username="neo4j", password="secret")
|
|
257
|
+
|
|
258
|
+
runic = Runic(adapter, script_location=Path("runic/"))
|
|
259
|
+
runic.migrate.upgrade("head")
|
|
260
|
+
print("current:", runic.migrate.current())
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Installation
|
|
266
|
+
|
|
267
|
+
Install the core package plus the extra for your backend. The core has **no graph-driver
|
|
268
|
+
dependency** — you only pull in what you use.
|
|
269
|
+
|
|
270
|
+
| Backend | Extra | Driver installed |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| FalkorDB | `falkordb` | `falkordb` |
|
|
273
|
+
| Neo4j | `neo4j` | `neo4j` |
|
|
274
|
+
| Memgraph | `memgraph` | `neo4j` (Bolt) |
|
|
275
|
+
| ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
|
|
276
|
+
| Apache AGE | `age` | `psycopg[binary]` |
|
|
277
|
+
| All backends | `all` | all of the above |
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
uv add "runic-py[falkordb]" # FalkorDB
|
|
281
|
+
uv add "runic-py[neo4j]" # Neo4j
|
|
282
|
+
uv add "runic-py[memgraph]" # Memgraph (Bolt)
|
|
283
|
+
uv add "runic-py[arcadedb]" # ArcadeDB (Bolt)
|
|
284
|
+
uv add "runic-py[age]" # Apache AGE (PostgreSQL extension)
|
|
285
|
+
uv add "runic-py[all]" # everything
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
> [!NOTE]
|
|
289
|
+
> Runic requires Python 3.14+.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Documentation
|
|
294
|
+
|
|
295
|
+
Full conceptual overview, async usage, advanced CLI flags, and the complete API reference live
|
|
296
|
+
at the **[Runic Documentation](https://runic.rehpoehler.de)**.
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
Released under the [MIT License](LICENSE.md).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "runic-py"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.7"
|
|
4
4
|
description = "Graph schema migrations and OGM for Cypher-based graph databases."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -61,7 +61,7 @@ dev = [
|
|
|
61
61
|
runic = "runic.migrate.cli:app"
|
|
62
62
|
|
|
63
63
|
[project.urls]
|
|
64
|
-
Homepage = "https://runic
|
|
64
|
+
Homepage = "https://runic.rehpoehler.de"
|
|
65
65
|
Repository = "https://github.com/jenreh/runic"
|
|
66
66
|
|
|
67
67
|
[tool.hatch.build.targets.wheel]
|
|
@@ -93,7 +93,7 @@ exclude = [
|
|
|
93
93
|
|
|
94
94
|
[tool.bumpversion]
|
|
95
95
|
commit = false
|
|
96
|
-
current_version = "0.3.
|
|
96
|
+
current_version = "0.3.7"
|
|
97
97
|
tag = false
|
|
98
98
|
allow-dirty = false
|
|
99
99
|
|
|
File without changes
|