runic-py 0.2.2__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {runic_py-0.2.2 → runic_py-0.3.2}/.gitignore +8 -0
  2. runic_py-0.2.2/README.md → runic_py-0.3.2/PKG-INFO +114 -27
  3. runic_py-0.2.2/PKG-INFO → runic_py-0.3.2/README.md +78 -48
  4. {runic_py-0.2.2 → runic_py-0.3.2}/pyproject.toml +45 -16
  5. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/__init__.py +10 -0
  6. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/adapters/__init__.py +58 -2
  7. runic_py-0.3.2/runic/migrate/adapters/_base.py +247 -0
  8. runic_py-0.3.2/runic/migrate/adapters/age.py +186 -0
  9. runic_py-0.3.2/runic/migrate/adapters/arcadedb.py +177 -0
  10. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/adapters/falkordb.py +222 -35
  11. runic_py-0.3.2/runic/migrate/adapters/memgraph.py +218 -0
  12. runic_py-0.3.2/runic/migrate/adapters/neo4j.py +234 -0
  13. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/cli.py +21 -18
  14. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/context.py +37 -4
  15. runic_py-0.3.2/runic/migrate/operations.py +140 -0
  16. runic_py-0.3.2/runic/migrate/schema.py +339 -0
  17. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/script.py +3 -3
  18. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/testing.py +17 -6
  19. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/__init__.py +45 -10
  20. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/descriptors.py +16 -30
  21. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/models.py +11 -22
  22. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/types.py +3 -0
  23. runic_py-0.3.2/runic/orm/driver/__init__.py +138 -0
  24. runic_py-0.3.2/runic/orm/driver/age.py +582 -0
  25. runic_py-0.3.2/runic/orm/driver/arcadedb.py +98 -0
  26. runic_py-0.3.2/runic/orm/driver/bolt.py +214 -0
  27. runic_py-0.3.2/runic/orm/driver/factory.py +108 -0
  28. runic_py-0.3.2/runic/orm/driver/falkordb.py +174 -0
  29. runic_py-0.3.2/runic/orm/driver/memgraph.py +133 -0
  30. runic_py-0.3.2/runic/orm/driver/neo4j.py +130 -0
  31. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/mapper/mapper.py +105 -66
  32. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/mapper/relationship_loader.py +26 -15
  33. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/mapper/relationship_writer.py +29 -13
  34. runic_py-0.3.2/runic/orm/operations.py +88 -0
  35. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/query/__init__.py +46 -7
  36. runic_py-0.3.2/runic/orm/query/_compiler.py +210 -0
  37. runic_py-0.3.2/runic/orm/query/_decoder.py +115 -0
  38. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/query/builder.py +63 -713
  39. runic_py-0.3.2/runic/orm/query/specialised.py +350 -0
  40. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/__init__.py +1 -4
  41. runic_py-0.3.2/runic/orm/repository/_base.py +49 -0
  42. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/async_repository.py +30 -59
  43. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/cypher.py +7 -22
  44. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/protocol.py +12 -8
  45. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/repository/repository.py +35 -64
  46. runic_py-0.3.2/runic/orm/schema/__init__.py +8 -0
  47. runic_py-0.3.2/runic/orm/schema/index_manager.py +56 -0
  48. runic_py-0.3.2/runic/orm/session/_base.py +213 -0
  49. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/session/async_session.py +143 -161
  50. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/session/connection_pool.py +14 -13
  51. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/session/session.py +178 -184
  52. runic_py-0.2.2/runic/migrate/operations.py +0 -173
  53. runic_py-0.2.2/runic/orm/repository/pagination.py +0 -105
  54. runic_py-0.2.2/runic/orm/schema/__init__.py +0 -11
  55. runic_py-0.2.2/runic/orm/schema/index_manager.py +0 -195
  56. runic_py-0.2.2/runic/orm/schema/schema_manager.py +0 -173
  57. {runic_py-0.2.2 → runic_py-0.3.2}/LICENSE.md +0 -0
  58. {runic_py-0.2.2 → runic_py-0.3.2}/runic/__init__.py +0 -0
  59. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/autogen.py +0 -0
  60. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/checksum.py +0 -0
  61. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/config.py +0 -0
  62. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/exceptions.py +0 -0
  63. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/introspect.py +0 -0
  64. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/manifest.py +0 -0
  65. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/service.py +0 -0
  66. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/templates/env.py.mako +0 -0
  67. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/templates/script.py.mako +0 -0
  68. {runic_py-0.2.2 → runic_py-0.3.2}/runic/migrate/version.py +0 -0
  69. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/__init__.py +0 -0
  70. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/core/metadata.py +0 -0
  71. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/exceptions.py +0 -0
  72. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/mapper/__init__.py +0 -0
  73. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/query/expressions.py +0 -0
  74. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/query/traversal.py +0 -0
  75. {runic_py-0.2.2 → runic_py-0.3.2}/runic/orm/session/__init__.py +0 -0
@@ -115,3 +115,11 @@ wheels/
115
115
  .task/
116
116
  .runic
117
117
  runic_test/
118
+ .claude/ralph-loop.local.md
119
+
120
+ # DeepEval run caches (tests/evals suite runs from an isolated venv)
121
+ .deepeval/
122
+ .deepeval-cache.json
123
+ .deepeval_telemetry.txt
124
+ .latest_run_full.json
125
+ .latest_test_run.json
@@ -1,11 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: runic-py
3
+ Version: 0.3.2
4
+ Summary: Graph schema migrations and ORM for Cypher-based graph databases.
5
+ Project-URL: Homepage, https://runic-py.readthedocs.io/en/latest/
6
+ Project-URL: Repository, https://github.com/jenreh/runic
7
+ Author: Jens Rehpöhler
8
+ License: MIT
9
+ License-File: LICENSE.md
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Database
15
+ Requires-Python: >=3.14
16
+ Requires-Dist: mako<2.0.0,>=1.3.0
17
+ Requires-Dist: rich<16.0.0,>=14.0.0
18
+ Requires-Dist: typer<1.0.0,>=0.26.0
19
+ Provides-Extra: age
20
+ Requires-Dist: psycopg<4.0.0,>=3.3.4; extra == 'age'
21
+ Provides-Extra: age-binary
22
+ Requires-Dist: psycopg[binary]<4.0.0,>=3.3.4; extra == 'age-binary'
23
+ Provides-Extra: all
24
+ Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'all'
25
+ Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'all'
26
+ Requires-Dist: psycopg[binary]<4.0.0,>=3.3.4; extra == 'all'
27
+ Provides-Extra: arcadedb
28
+ Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'arcadedb'
29
+ Provides-Extra: falkordb
30
+ Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'falkordb'
31
+ Provides-Extra: memgraph
32
+ Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'memgraph'
33
+ Provides-Extra: neo4j
34
+ Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'neo4j'
35
+ Description-Content-Type: text/markdown
36
+
1
37
  <div align="center">
2
38
  <img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
3
39
 
4
40
  # Runic
5
41
 
6
- **Graph schema migrations and ORM for FalkorDB.**
42
+ **Graph schema migrations and ORM for Cypher-based graph databases.**
7
43
 
8
- ![Version](https://img.shields.io/badge/version-0.2.2-blue)
44
+ ![Version](https://img.shields.io/badge/version-0.3.2-blue)
45
+ [![PyPI](https://img.shields.io/pypi/v/runic-py.svg)](https://pypi.org/project/runic-py/)
9
46
  [![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
10
47
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
11
48
 
@@ -15,44 +52,64 @@
15
52
 
16
53
  ---
17
54
 
18
- **Runic** is a Python toolkit for [FalkorDB](https://falkordb.com/) that covers two layers:
55
+ **Runic** is a Python toolkit for Cypher-based graph databases that covers two layers:
19
56
 
20
57
  - **`runic.migrate`** — Alembic-style schema migrations with revision tracking, a CLI, and rollback snapshots.
21
- - **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all using FalkorDB-native Cypher under the hood.
58
+ - **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all via a pluggable driver layer supporting FalkorDB, ArcadeDB, and any Bolt-compatible database.
22
59
 
23
60
  ## Features
24
61
 
25
62
  ### Migration CLI
26
63
 
27
64
  - **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
28
- - **Graph-Native** — Migration state stored inside dedicated graph nodes (`:_FalkorMigrateVersion`).
65
+ - **Graph-Native** — Migration state stored inside dedicated graph nodes; no external state table needed.
29
66
  - **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
30
67
  - **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
31
- - **Rollback Snapshots** — Uses `GRAPH.COPY` for high-risk, non-reversible migrations.
68
+ - **Rollback Snapshots** — Optional snapshot-based rollback for high-risk, non-reversible migrations.
32
69
 
33
70
  ### Graph ORM
34
71
 
35
72
  - **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
36
- - **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and pagination.
73
+ - **Pluggable Driver Layer** — `GraphDriver` / `GraphDialect` protocols; built-in drivers for FalkorDB, ArcadeDB (via Bolt), and any Bolt-compatible DB. Switch backends without changing model code.
74
+ - **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and offset reads.
37
75
  - **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
38
76
  - **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
39
- - **Native FalkorDB Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
77
+ - **Native Graph Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
40
78
  - **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
41
79
 
42
80
  ## Installation
43
81
 
44
- ```bash
45
- uv pip install runic
46
- ```
82
+ Install the core package, then add the optional extra for your database backend:
47
83
 
48
- Or add it to an existing project:
84
+ | Backend | Extra | Package installed |
85
+ | --- | --- | --- |
86
+ | FalkorDB | `falkordb` | `falkordb` |
87
+ | Neo4j | `neo4j` | `neo4j` |
88
+ | Memgraph | `memgraph` | `neo4j` (Bolt) |
89
+ | ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
90
+ | Apache AGE | `age` | `psycopg[binary]` |
91
+ | All backends | `all` | all of the above |
49
92
 
50
93
  ```bash
51
- uv add runic
94
+ # FalkorDB
95
+ uv add "runic-py[falkordb]"
96
+
97
+ # Neo4j
98
+ uv add "runic-py[neo4j]"
99
+
100
+ # Memgraph or ArcadeDB (both use the Neo4j Bolt driver)
101
+ uv add "runic-py[memgraph]"
102
+ uv add "runic-py[arcadedb]"
103
+
104
+ # Apache AGE (PostgreSQL extension)
105
+ uv add "runic-py[age]"
106
+
107
+ # All backends at once
108
+ uv add "runic-py[all]"
52
109
  ```
53
110
 
54
111
  > [!NOTE]
55
- > Runic requires Python 3.14+ and is optimized for the latest FalkorDB clients.
112
+ > Runic requires Python 3.14+. The core package has no graph-driver dependency — install only the extra for your backend.
56
113
 
57
114
  ---
58
115
 
@@ -90,7 +147,7 @@ runic downgrade 1975e # roll back to a specific revision (prefix is enough)
90
147
 
91
148
  ### Baselining an existing graph
92
149
 
93
- Bring an unmanaged FalkorDB graph under runic control without re-running anything:
150
+ Bring an existing graph under runic control without re-running anything:
94
151
 
95
152
  ```bash
96
153
  runic baseline -m "baseline" # introspect, generate root revision, stamp it
@@ -112,9 +169,12 @@ from runic.migrate.adapters import create_adapter
112
169
 
113
170
  init(Path("runic/"))
114
171
 
172
+ # Any supported backend — swap the adapter name and kwargs
115
173
  adapter = create_adapter(
116
174
  "falkordb", url="falkor://localhost:6379", graph_name="my_graph"
117
175
  )
176
+ # adapter = create_adapter("neo4j", host="localhost", port=7687, database="neo4j", username="neo4j", password="secret")
177
+
118
178
  runic = Runic(adapter, script_location=Path("runic/"))
119
179
  runic.migrate.upgrade("head")
120
180
 
@@ -160,23 +220,28 @@ class Author(Node, labels=["Author"]):
160
220
 
161
221
  ### Session-based CRUD
162
222
 
223
+ `Session` accepts any `GraphDriver`. Use `create_driver()` to build one for your backend:
224
+
163
225
  ```python
164
- from runic.orm import Session, Repository
226
+ from runic.orm import Session, Repository, create_driver
165
227
 
166
- with Session(graph) as session:
228
+ # Pick your backend — the rest of the code is identical
229
+ driver = create_driver("falkordb", host="localhost", port=6379, graph="myapp")
230
+
231
+ with Session(driver) as session:
167
232
  session.add_all([
168
233
  User(id="alice", email="alice@example.com", name="Alice"),
169
234
  User(id="bob", email="bob@example.com", name="Bob"),
170
235
  ])
171
236
  session.commit()
172
237
 
173
- with Session(graph) as session:
238
+ with Session(driver) as session:
174
239
  repo = Repository(session, User)
175
240
  alice = session.get(User, "alice")
176
241
  alice.name = "Alice Smith" # change tracking — no explicit dirty flag
177
242
  session.commit()
178
243
 
179
- with Session(graph) as session:
244
+ with Session(driver) as session:
180
245
  user = session.get(User, "bob")
181
246
  session.delete(user)
182
247
  session.commit()
@@ -186,12 +251,12 @@ with Session(graph) as session:
186
251
 
187
252
  ```python
188
253
  # Lazy load (default) — triggers a query on first access
189
- with Session(graph) as session:
254
+ with Session(driver) as session:
190
255
  author = session.get(Author, "alice")
191
256
  posts = author.posts # query executed here
192
257
 
193
258
  # Eager load — single round-trip
194
- with Session(graph) as session:
259
+ with Session(driver) as session:
195
260
  author = session.get(Author, "alice", fetch=["posts"])
196
261
  posts = author.posts # already loaded, no extra query
197
262
  ```
@@ -199,12 +264,12 @@ with Session(graph) as session:
199
264
  ### Pagination and custom queries
200
265
 
201
266
  ```python
202
- from runic.orm import Pageable, Repository
267
+ from runic.orm import Repository
203
268
 
204
- with Session(graph) as session:
269
+ with Session(driver) as session:
205
270
  repo = Repository(session, User)
206
- page = repo.find_all_paginated(Pageable(page=0, size=20, sort_by="name"))
207
- print(f"{len(list(page))} of {page.total_elements} total")
271
+ first_page = repo.find_all(skip=0, limit=20)
272
+ next_page = repo.find_all(skip=20, limit=20)
208
273
  ```
209
274
 
210
275
  Extend `Repository` to add typed Cypher helpers:
@@ -219,6 +284,28 @@ class UserRepository(Repository[User]):
219
284
  )
220
285
  ```
221
286
 
287
+ ### Composable queries
288
+
289
+ `select()` creates a query statement independently of any session, enabling
290
+ dynamic query composition (e.g. conditional UI filters) before execution:
291
+
292
+ ```python
293
+ from runic.orm import select
294
+
295
+ stmt = select(User).where(User.active == True)
296
+ if min_age > 0:
297
+ stmt = stmt.where(User.age >= min_age)
298
+
299
+ with Session(driver) as session:
300
+ users: list[User] = session.scalars(stmt) # list[User]
301
+ user: User | None = session.scalar(stmt) # User | None
302
+ n: int = session.count(stmt) # int
303
+ ```
304
+
305
+ The same `stmt` is reusable — pass it to multiple sessions or execute it
306
+ multiple times. The older `session.query(User).where(...).all()` pattern is
307
+ still fully supported.
308
+
222
309
  ### Schema management
223
310
 
224
311
  Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
@@ -235,14 +322,14 @@ class Place(Node, labels=["Place"]):
235
322
  lon: float = Field(index=True)
236
323
 
237
324
 
238
- schema = SchemaManager(graph)
325
+ schema = SchemaManager(driver)
239
326
  schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
240
327
 
241
328
  result = schema.validate_schema([Place])
242
329
  print("valid:", result.is_valid)
243
330
  ```
244
331
 
245
- ### Native FalkorDB types
332
+ ### Native graph types
246
333
 
247
334
  `Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
248
335
 
@@ -1,32 +1,12 @@
1
- Metadata-Version: 2.4
2
- Name: runic-py
3
- Version: 0.2.2
4
- Summary: OGM and schema migrations for FalkorDB.
5
- Project-URL: Homepage, https://runic-py.readthedocs.io/latest/
6
- Project-URL: Repository, https://github.com/jenreh/runic
7
- Author: Jens Rehpöhler
8
- License: MIT
9
- License-File: LICENSE.md
10
- Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3 :: Only
14
- Classifier: Topic :: Home Automation
15
- Requires-Python: >=3.14
16
- Requires-Dist: falkordb<2.0.0,>=1.6.0
17
- Requires-Dist: mako<2.0.0,>=1.3.0
18
- Requires-Dist: rich<16.0.0,>=15.0.0
19
- Requires-Dist: typer<1.0.0,>=0.26.0
20
- Description-Content-Type: text/markdown
21
-
22
1
  <div align="center">
23
2
  <img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/source/_static/runic.svg" width="240" alt="Runic logo">
24
3
 
25
4
  # Runic
26
5
 
27
- **Graph schema migrations and ORM for FalkorDB.**
6
+ **Graph schema migrations and ORM for Cypher-based graph databases.**
28
7
 
29
- ![Version](https://img.shields.io/badge/version-0.2.2-blue)
8
+ ![Version](https://img.shields.io/badge/version-0.3.2-blue)
9
+ [![PyPI](https://img.shields.io/pypi/v/runic-py.svg)](https://pypi.org/project/runic-py/)
30
10
  [![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
31
11
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
32
12
 
@@ -36,44 +16,64 @@ Description-Content-Type: text/markdown
36
16
 
37
17
  ---
38
18
 
39
- **Runic** is a Python toolkit for [FalkorDB](https://falkordb.com/) that covers two layers:
19
+ **Runic** is a Python toolkit for Cypher-based graph databases that covers two layers:
40
20
 
41
21
  - **`runic.migrate`** — Alembic-style schema migrations with revision tracking, a CLI, and rollback snapshots.
42
- - **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all using FalkorDB-native Cypher under the hood.
22
+ - **`runic.orm`** — A lightweight graph ORM: declare `Node` and `Edge` models, manage sessions, traverse relationships, and sync indexes — all via a pluggable driver layer supporting FalkorDB, ArcadeDB, and any Bolt-compatible database.
43
23
 
44
24
  ## Features
45
25
 
46
26
  ### Migration CLI
47
27
 
48
28
  - **Alembic-Style Workflow** — Familiar CLI verbs: `init`, `revision`, `upgrade`, `downgrade`, `current`, `baseline`.
49
- - **Graph-Native** — Migration state stored inside dedicated graph nodes (`:_FalkorMigrateVersion`).
29
+ - **Graph-Native** — Migration state stored inside dedicated graph nodes; no external state table needed.
50
30
  - **Idempotent Cypher** — Explicit, guarded migration steps; safe to replay on an empty graph.
51
31
  - **Offline & Dry Run** — Review generated Cypher scripts before running them in production.
52
- - **Rollback Snapshots** — Uses `GRAPH.COPY` for high-risk, non-reversible migrations.
32
+ - **Rollback Snapshots** — Optional snapshot-based rollback for high-risk, non-reversible migrations.
53
33
 
54
34
  ### Graph ORM
55
35
 
56
36
  - **Declarative Models** — `Node` and `Edge` subclasses with typed `Field` descriptors; no metaclass magic.
57
- - **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and pagination.
37
+ - **Pluggable Driver Layer** — `GraphDriver` / `GraphDialect` protocols; built-in drivers for FalkorDB, ArcadeDB (via Bolt), and any Bolt-compatible DB. Switch backends without changing model code.
38
+ - **Session & Repository** — Unit-of-work session with change tracking; typed `Repository` for queries and offset reads.
58
39
  - **Relationships** — `Relation` field for INCOMING / OUTGOING edges; lazy and eager loading; edge property models.
59
40
  - **Schema Management** — `IndexManager` and `SchemaManager` to create, validate, and sync RANGE, FULLTEXT, and UNIQUE indexes.
60
- - **Native FalkorDB Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
41
+ - **Native Graph Types** — First-class `Vector` (vecf32), `GeoLocation` (point), interned strings, and auto-converters for `datetime` and `Enum`.
61
42
  - **Async Support** — `AsyncSession`, `AsyncRepository`, and `AsyncConnectionManager` for async-first applications.
62
43
 
63
44
  ## Installation
64
45
 
65
- ```bash
66
- uv pip install runic
67
- ```
46
+ Install the core package, then add the optional extra for your database backend:
68
47
 
69
- Or add it to an existing project:
48
+ | Backend | Extra | Package installed |
49
+ | --- | --- | --- |
50
+ | FalkorDB | `falkordb` | `falkordb` |
51
+ | Neo4j | `neo4j` | `neo4j` |
52
+ | Memgraph | `memgraph` | `neo4j` (Bolt) |
53
+ | ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
54
+ | Apache AGE | `age` | `psycopg[binary]` |
55
+ | All backends | `all` | all of the above |
70
56
 
71
57
  ```bash
72
- uv add runic
58
+ # FalkorDB
59
+ uv add "runic-py[falkordb]"
60
+
61
+ # Neo4j
62
+ uv add "runic-py[neo4j]"
63
+
64
+ # Memgraph or ArcadeDB (both use the Neo4j Bolt driver)
65
+ uv add "runic-py[memgraph]"
66
+ uv add "runic-py[arcadedb]"
67
+
68
+ # Apache AGE (PostgreSQL extension)
69
+ uv add "runic-py[age]"
70
+
71
+ # All backends at once
72
+ uv add "runic-py[all]"
73
73
  ```
74
74
 
75
75
  > [!NOTE]
76
- > Runic requires Python 3.14+ and is optimized for the latest FalkorDB clients.
76
+ > Runic requires Python 3.14+. The core package has no graph-driver dependency — install only the extra for your backend.
77
77
 
78
78
  ---
79
79
 
@@ -111,7 +111,7 @@ runic downgrade 1975e # roll back to a specific revision (prefix is enough)
111
111
 
112
112
  ### Baselining an existing graph
113
113
 
114
- Bring an unmanaged FalkorDB graph under runic control without re-running anything:
114
+ Bring an existing graph under runic control without re-running anything:
115
115
 
116
116
  ```bash
117
117
  runic baseline -m "baseline" # introspect, generate root revision, stamp it
@@ -133,9 +133,12 @@ from runic.migrate.adapters import create_adapter
133
133
 
134
134
  init(Path("runic/"))
135
135
 
136
+ # Any supported backend — swap the adapter name and kwargs
136
137
  adapter = create_adapter(
137
138
  "falkordb", url="falkor://localhost:6379", graph_name="my_graph"
138
139
  )
140
+ # adapter = create_adapter("neo4j", host="localhost", port=7687, database="neo4j", username="neo4j", password="secret")
141
+
139
142
  runic = Runic(adapter, script_location=Path("runic/"))
140
143
  runic.migrate.upgrade("head")
141
144
 
@@ -181,23 +184,28 @@ class Author(Node, labels=["Author"]):
181
184
 
182
185
  ### Session-based CRUD
183
186
 
187
+ `Session` accepts any `GraphDriver`. Use `create_driver()` to build one for your backend:
188
+
184
189
  ```python
185
- from runic.orm import Session, Repository
190
+ from runic.orm import Session, Repository, create_driver
186
191
 
187
- with Session(graph) as session:
192
+ # Pick your backend — the rest of the code is identical
193
+ driver = create_driver("falkordb", host="localhost", port=6379, graph="myapp")
194
+
195
+ with Session(driver) as session:
188
196
  session.add_all([
189
197
  User(id="alice", email="alice@example.com", name="Alice"),
190
198
  User(id="bob", email="bob@example.com", name="Bob"),
191
199
  ])
192
200
  session.commit()
193
201
 
194
- with Session(graph) as session:
202
+ with Session(driver) as session:
195
203
  repo = Repository(session, User)
196
204
  alice = session.get(User, "alice")
197
205
  alice.name = "Alice Smith" # change tracking — no explicit dirty flag
198
206
  session.commit()
199
207
 
200
- with Session(graph) as session:
208
+ with Session(driver) as session:
201
209
  user = session.get(User, "bob")
202
210
  session.delete(user)
203
211
  session.commit()
@@ -207,12 +215,12 @@ with Session(graph) as session:
207
215
 
208
216
  ```python
209
217
  # Lazy load (default) — triggers a query on first access
210
- with Session(graph) as session:
218
+ with Session(driver) as session:
211
219
  author = session.get(Author, "alice")
212
220
  posts = author.posts # query executed here
213
221
 
214
222
  # Eager load — single round-trip
215
- with Session(graph) as session:
223
+ with Session(driver) as session:
216
224
  author = session.get(Author, "alice", fetch=["posts"])
217
225
  posts = author.posts # already loaded, no extra query
218
226
  ```
@@ -220,12 +228,12 @@ with Session(graph) as session:
220
228
  ### Pagination and custom queries
221
229
 
222
230
  ```python
223
- from runic.orm import Pageable, Repository
231
+ from runic.orm import Repository
224
232
 
225
- with Session(graph) as session:
233
+ with Session(driver) as session:
226
234
  repo = Repository(session, User)
227
- page = repo.find_all_paginated(Pageable(page=0, size=20, sort_by="name"))
228
- print(f"{len(list(page))} of {page.total_elements} total")
235
+ first_page = repo.find_all(skip=0, limit=20)
236
+ next_page = repo.find_all(skip=20, limit=20)
229
237
  ```
230
238
 
231
239
  Extend `Repository` to add typed Cypher helpers:
@@ -240,6 +248,28 @@ class UserRepository(Repository[User]):
240
248
  )
241
249
  ```
242
250
 
251
+ ### Composable queries
252
+
253
+ `select()` creates a query statement independently of any session, enabling
254
+ dynamic query composition (e.g. conditional UI filters) before execution:
255
+
256
+ ```python
257
+ from runic.orm import select
258
+
259
+ stmt = select(User).where(User.active == True)
260
+ if min_age > 0:
261
+ stmt = stmt.where(User.age >= min_age)
262
+
263
+ with Session(driver) as session:
264
+ users: list[User] = session.scalars(stmt) # list[User]
265
+ user: User | None = session.scalar(stmt) # User | None
266
+ n: int = session.count(stmt) # int
267
+ ```
268
+
269
+ The same `stmt` is reusable — pass it to multiple sessions or execute it
270
+ multiple times. The older `session.query(User).where(...).all()` pattern is
271
+ still fully supported.
272
+
243
273
  ### Schema management
244
274
 
245
275
  Declare indexes inline on `Field`, then let `SchemaManager` keep the live graph in sync:
@@ -256,14 +286,14 @@ class Place(Node, labels=["Place"]):
256
286
  lon: float = Field(index=True)
257
287
 
258
288
 
259
- schema = SchemaManager(graph)
289
+ schema = SchemaManager(driver)
260
290
  schema.sync_schema([Place], drop_extra=False) # create missing; leave extras alone
261
291
 
262
292
  result = schema.validate_schema([Place])
263
293
  print("valid:", result.is_valid)
264
294
  ```
265
295
 
266
- ### Native FalkorDB types
296
+ ### Native graph types
267
297
 
268
298
  `Vector`, `GeoLocation`, `datetime`, and `Enum` fields get their converters assigned automatically — no `converter=` argument needed:
269
299
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "runic-py"
3
- version = "0.2.2"
4
- description = "OGM and schema migrations for FalkorDB."
3
+ version = "0.3.2"
4
+ description = "Graph schema migrations and ORM for Cypher-based graph databases."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
7
7
  license = { text = "MIT" }
@@ -12,39 +12,55 @@ classifiers = [
12
12
  "License :: OSI Approved :: MIT License",
13
13
  "Programming Language :: Python :: 3",
14
14
  "Programming Language :: Python :: 3 :: Only",
15
- "Topic :: Home Automation",
15
+ "Topic :: Database",
16
16
  ]
17
17
 
18
18
  dependencies = [
19
19
  "typer>=0.26.0,<1.0.0",
20
- "rich>=15.0.0,<16.0.0",
21
- "falkordb>=1.6.0,<2.0.0",
20
+ "rich>=14.0.0,<16.0.0",
22
21
  "mako>=1.3.0,<2.0.0",
23
22
  ]
24
23
 
24
+ [project.optional-dependencies]
25
+ falkordb = ["falkordb>=1.6.0,<2.0.0"]
26
+ neo4j = ["neo4j>=6.2.0,<7.0.0"]
27
+ memgraph = ["neo4j>=6.2.0,<7.0.0"]
28
+ arcadedb = ["neo4j>=6.2.0,<7.0.0"]
29
+ age = ["psycopg>=3.3.4,<4.0.0"]
30
+ age-binary = ["psycopg[binary]>=3.3.4,<4.0.0"]
31
+ all = [
32
+ "falkordb>=1.6.0,<2.0.0",
33
+ "neo4j>=6.2.0,<7.0.0",
34
+ "psycopg[binary]>=3.3.4,<4.0.0",
35
+ ]
25
36
  [dependency-groups]
26
37
  dev = [
27
38
  "bump-my-version==1.3.0",
39
+ "deepeval==4.0.5",
28
40
  "detect-secrets==1.5.0",
41
+ "falkordb>=1.6.1",
42
+ "falkordblite==0.10.0",
43
+ "furo==2025.12.19",
44
+ "neo4j==6.2.0",
29
45
  "pre-commit==4.6.0",
30
- "pytest==9.0.3",
46
+ "psycopg[binary]==3.3.4",
31
47
  "pytest-asyncio==1.4.0",
32
48
  "pytest-cov==7.1.0",
33
49
  "pytest-httpserver==1.1.5",
50
+ "pytest==9.0.3",
51
+ "python-dotenv==1.2.2",
34
52
  "ruff==0.15.16",
35
- "falkordblite==0.10.0",
36
- "sphinx==9.1.0",
37
- "furo==2025.12.19",
38
53
  "sphinx-copybutton==0.5.2",
39
54
  "sphinx-design==0.7.0",
40
- "ty>=0.0.44",
55
+ "sphinx==9.1.0",
56
+ "ty==0.0.44",
41
57
  ]
42
58
 
43
59
  [project.scripts]
44
60
  runic = "runic.migrate.cli:app"
45
61
 
46
62
  [project.urls]
47
- Homepage = "https://runic-py.readthedocs.io/latest/"
63
+ Homepage = "https://runic-py.readthedocs.io/en/latest/"
48
64
  Repository = "https://github.com/jenreh/runic"
49
65
 
50
66
  [tool.hatch.build.targets.wheel]
@@ -76,7 +92,7 @@ exclude = [
76
92
 
77
93
  [tool.bumpversion]
78
94
  commit = false
79
- current_version = "0.2.2"
95
+ current_version = "0.3.2"
80
96
  tag = false
81
97
  allow-dirty = false
82
98
 
@@ -92,8 +108,13 @@ replace = '![Version](https://img.shields.io/badge/version-{new_version}-blue)'
92
108
 
93
109
  [[tool.bumpversion.files]]
94
110
  filename = "docs/source/index.rst"
95
- search = 'Documentation for version: {current_version}.'
96
- replace = 'Documentation for version: {new_version}.'
111
+ search = '.. |Github| image:: https://img.shields.io/badge/release-{current_version}-yellow?logo=github'
112
+ replace = '.. |Github| image:: https://img.shields.io/badge/release-{new_version}-yellow?logo=github'
113
+
114
+ [[tool.bumpversion.files]]
115
+ filename = "docs/source/index.rst"
116
+ search = ' :target: https://github.com/jenreh/runic/tree/v{current_version}'
117
+ replace = ' :target: https://github.com/jenreh/runic/tree/v{new_version}'
97
118
 
98
119
  [[tool.bumpversion.files]]
99
120
  filename = "docs/source/conf.py"
@@ -228,10 +249,14 @@ known-local-folder = ["sonos"]
228
249
  [tool.pytest.ini_options]
229
250
  cache_dir = ".cache/pytest"
230
251
  testpaths = ["tests"]
252
+ # Evals (tests/evals) are excluded from the default suite; run explicitly with
253
+ # `uv run pytest tests/evals`.
254
+ addopts = "--ignore=tests/evals"
231
255
  asyncio_mode = "auto"
232
256
  markers = [
233
- "sonos_live: requires real Sonos hardware on the network",
234
- "integration: requires falkordblite (embedded FalkorDB, no external server)",
257
+ "integration: requires a live graph backend (embedded FalkorDB via redislite by default; other backends via RUNIC_TEST_BACKENDS env var + docker-compose.test.yml)",
258
+ "requires_multi_label: test requires multi-label node support (not available on Apache AGE)",
259
+ "requires_geo_update: test requires SET n.prop = point() support (not available on ArcadeDB via Bolt)",
235
260
  ]
236
261
 
237
262
  [tool.ty.environment]
@@ -245,12 +270,16 @@ exclude = [
245
270
  ".venv",
246
271
  "build",
247
272
  "dist",
273
+ "examples",
248
274
  ]
249
275
 
250
276
  [tool.ty.rules]
251
277
  unresolved-import = "warn"
252
278
  missing-argument = "error"
253
279
 
280
+ [tool.mypy]
281
+ cache_dir = ".cache/mypy"
282
+
254
283
  [tool.coverage.run]
255
284
  branch = true
256
285
  source = ["app", "runic"]
@@ -8,19 +8,29 @@ from runic.migrate.manifest import (
8
8
  UniqueConstraint,
9
9
  VectorIndex,
10
10
  )
11
+ from runic.migrate.schema import (
12
+ IndexManager,
13
+ SchemaInfo,
14
+ SchemaManager,
15
+ ValidationResult,
16
+ )
11
17
  from runic.migrate.script import AmbiguousRevision, RevisionNotFound
12
18
  from runic.migrate.service import init
13
19
 
14
20
  __all__ = [
15
21
  "AmbiguousRevision",
16
22
  "FulltextIndex",
23
+ "IndexManager",
17
24
  "IrreversibleMigrationError",
18
25
  "MandatoryConstraint",
19
26
  "RangeIndex",
20
27
  "RevisionNotFound",
21
28
  "Runic",
29
+ "SchemaInfo",
30
+ "SchemaManager",
22
31
  "SchemaManifest",
23
32
  "UniqueConstraint",
33
+ "ValidationResult",
24
34
  "VectorIndex",
25
35
  "context",
26
36
  "init",