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.
Files changed (74) hide show
  1. runic_py-0.3.7/PKG-INFO +337 -0
  2. runic_py-0.3.7/README.md +300 -0
  3. {runic_py-0.3.6 → runic_py-0.3.7}/pyproject.toml +3 -3
  4. runic_py-0.3.7/runic/py.typed +0 -0
  5. runic_py-0.3.6/PKG-INFO +0 -361
  6. runic_py-0.3.6/README.md +0 -324
  7. {runic_py-0.3.6 → runic_py-0.3.7}/.gitignore +0 -0
  8. {runic_py-0.3.6 → runic_py-0.3.7}/LICENSE.md +0 -0
  9. {runic_py-0.3.6 → runic_py-0.3.7}/runic/__init__.py +0 -0
  10. {runic_py-0.3.6 → runic_py-0.3.7}/runic/cypher.py +0 -0
  11. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/__init__.py +0 -0
  12. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/__init__.py +0 -0
  13. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/_base.py +0 -0
  14. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/age.py +0 -0
  15. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/arcadedb.py +0 -0
  16. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/falkordb.py +0 -0
  17. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/memgraph.py +0 -0
  18. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/adapters/neo4j.py +0 -0
  19. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/autogen.py +0 -0
  20. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/checksum.py +0 -0
  21. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/cli.py +0 -0
  22. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/config.py +0 -0
  23. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/context.py +0 -0
  24. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/exceptions.py +0 -0
  25. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/introspect.py +0 -0
  26. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/manifest.py +0 -0
  27. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/operations.py +0 -0
  28. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/schema.py +0 -0
  29. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/script.py +0 -0
  30. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/service.py +0 -0
  31. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/templates/env.py.mako +0 -0
  32. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/templates/script.py.mako +0 -0
  33. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/testing.py +0 -0
  34. {runic_py-0.3.6 → runic_py-0.3.7}/runic/migrate/version.py +0 -0
  35. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/__init__.py +0 -0
  36. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/__init__.py +0 -0
  37. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/descriptors.py +0 -0
  38. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/metadata.py +0 -0
  39. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/models.py +0 -0
  40. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/core/types.py +0 -0
  41. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/__init__.py +0 -0
  42. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/age.py +0 -0
  43. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/arcadedb.py +0 -0
  44. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/bolt.py +0 -0
  45. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/factory.py +0 -0
  46. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/falkordb.py +0 -0
  47. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/memgraph.py +0 -0
  48. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/driver/neo4j.py +0 -0
  49. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/exceptions.py +0 -0
  50. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/mapper/__init__.py +0 -0
  51. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/mapper/mapper.py +0 -0
  52. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/mapper/relationship_loader.py +0 -0
  53. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/mapper/relationship_writer.py +0 -0
  54. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/operations.py +0 -0
  55. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/__init__.py +0 -0
  56. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/_compiler.py +0 -0
  57. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/_decoder.py +0 -0
  58. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/builder.py +0 -0
  59. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/expressions.py +0 -0
  60. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/specialised.py +0 -0
  61. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/query/traversal.py +0 -0
  62. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/__init__.py +0 -0
  63. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/_base.py +0 -0
  64. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/async_repository.py +0 -0
  65. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/cypher.py +0 -0
  66. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/protocol.py +0 -0
  67. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/repository/repository.py +0 -0
  68. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/schema/__init__.py +0 -0
  69. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/schema/index_manager.py +0 -0
  70. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/__init__.py +0 -0
  71. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/_base.py +0 -0
  72. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/async_session.py +0 -0
  73. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/connection_pool.py +0 -0
  74. {runic_py-0.3.6 → runic_py-0.3.7}/runic/ogm/session/session.py +0 -0
@@ -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
+ ![Version](https://img.shields.io/badge/version-0.3.7-blue)
47
+ [![PyPI](https://img.shields.io/pypi/v/runic-py.svg)](https://pypi.org/project/runic-py/)
48
+ [![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
49
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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).
@@ -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
+ ![Version](https://img.shields.io/badge/version-0.3.7-blue)
10
+ [![PyPI](https://img.shields.io/pypi/v/runic-py.svg)](https://pypi.org/project/runic-py/)
11
+ [![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
12
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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.6"
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-py.readthedocs.io/en/latest/"
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.6"
96
+ current_version = "0.3.7"
97
97
  tag = false
98
98
  allow-dirty = false
99
99
 
File without changes