Metadata-Version: 2.4
Name: runic-py
Version: 0.4.6
Summary: Graph schema migrations and OGM for Cypher-based graph databases.
Project-URL: Homepage, https://runic.rehpoehler.de
Project-URL: Repository, https://github.com/jenreh/runic
Author: Jens Rehpöhler
License: MIT
License-File: LICENSE.md
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Database
Requires-Python: >=3.14
Requires-Dist: mako<2.0.0,>=1.3.0
Requires-Dist: pillow>=12.3.0
Requires-Dist: shellingham<2.0.0,>=1.5.0
Requires-Dist: typer<0.28.0,>=0.24.0
Provides-Extra: age
Requires-Dist: psycopg<4.0.0,>=3.3.4; extra == 'age'
Provides-Extra: age-binary
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.4; extra == 'age-binary'
Provides-Extra: all
Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'all'
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'all'
Requires-Dist: psycopg[binary]<4.0.0,>=3.3.0; extra == 'all'
Provides-Extra: arcadedb
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'arcadedb'
Provides-Extra: falkordb
Requires-Dist: falkordb<2.0.0,>=1.6.0; extra == 'falkordb'
Provides-Extra: graphrag
Requires-Dist: pydantic-ai-slim[openai]>=2.0.0; extra == 'graphrag'
Requires-Dist: pydantic-settings>=2.14.0; extra == 'graphrag'
Requires-Dist: pymupdf>=1.27.1; extra == 'graphrag'
Provides-Extra: memgraph
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'memgraph'
Provides-Extra: neo4j
Requires-Dist: neo4j<7.0.0,>=6.2.0; extra == 'neo4j'
Description-Content-Type: text/markdown

<div align="center">
  <img src="https://raw.githubusercontent.com/jenreh/runic/refs/heads/main/docs/public/runic.svg" width="240" alt="Runic logo">

# Runic

**A type-safe OGM for Cypher graph databases.<br>
Define your models once, run them on any backend.**

![Version](https://img.shields.io/badge/version-0.4.6-blue)
[![PyPI](https://img.shields.io/pypi/v/runic-py.svg)](https://pypi.org/project/runic-py/)
[![Python](https://img.shields.io/badge/python-3.14%2B-orange)](https://www.python.org)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)

[Why Runic](#why-runic) • [The OGM](#the-ogm) • [Migrations](#migrations) • [Graph-RAG](#graph-rag) • [Installation](#installation) • [Docs](https://runic.rehpoehler.de)

</div>

---

Runic maps Python classes to graph nodes and edges. You declare typed `Node` and `Edge` models
and get change tracking, lazy and eager relationships, a composable query API, and schema
migrations — on top of a pluggable driver layer that runs the same model code on FalkorDB,
Neo4j, Memgraph, ArcadeDB, and Apache AGE. A built-in [Graph-RAG SDK](#graph-rag) layers
document ingestion and cited, knowledge-graph-grounded question answering on top of the OGM.

## Why Runic

- **Backend-agnostic.** One model definition runs on five backends. Switching from FalkorDB to
  Neo4j means changing the arguments to `create_driver()`; your models, queries, and application
  code don't change.
- **Typed models, no metaclass magic.** `Node` and `Edge` are plain classes with typed `Field`
  descriptors — IDE autocomplete works, and `Author.name == "Alice"` builds a query predicate.
- **Change tracking.** Mutate an object and call `commit()`; the unit-of-work session computes
  the diff and writes only what changed. No manual dirty flags, no hand-written `SET` clauses.
- **First-class relationships.** Declare `Relation` fields for `INCOMING`/`OUTGOING` edges,
  with edge-property models, and choose lazy loading or single-round-trip eager fetch.
- **Native graph types.** `Vector` (vecf32), `GeoLocation` (point), interned strings, and
  automatic converters for `datetime` and `Enum` — stored without writing serialization code.
- **Migrations included.** A migration tool with versioned revisions, a CLI, and rollback
  snapshots. Revision state lives inside the graph, so there's no external state table.
- **Sync and async.** `Session`/`Repository` and `AsyncSession`/`AsyncRepository` share one API.

## The OGM

The two examples below build on one domain — `Author`, `Article`, and an `AUTHORED` edge —
and cover the features you'll reach for most.

### 1. Model a domain, persist it, and wire up relationships

Declare typed `Node` and `Edge` classes. Constraints and indexes go inline on `Field`; native
graph types (`Vector`, `GeoLocation`, `datetime`, `Enum`) get their converters assigned
automatically; and `Relation` declares a traversal with an optional edge-property model.
`SchemaManager` reconciles those declarations with the live graph, a `Session` handles writes
with automatic change tracking, and `session.relate()` creates the edges between nodes.

```python
from datetime import UTC, datetime
from enum import StrEnum

from runic.ogm import (
    Edge,
    Field,
    GeoLocation,
    Node,
    Relation,
    SchemaManager,
    Session,
    Vector,
    create_driver,
)


class Status(StrEnum):
    DRAFT = "draft"
    PUBLISHED = "published"


class Article(Node, labels=["Article"]):
    id: str = Field(primary_key=True)
    title: str = Field(
        index_type="FULLTEXT"
    )  # fulltext search: FalkorDB/Neo4j/Memgraph
    category: str = Field(
        interned=True
    )  # intern() dedup — FalkorDB only, no-op elsewhere
    status: Status = Status.DRAFT  # EnumConverter auto-assigned
    published_at: datetime | None = None  # DatetimeConverter auto-assigned
    embedding: Vector | None = None  # KNN via vecf32() on FalkorDB; Neo4j/Memgraph
    #   need a pre-created VECTOR INDEX
    origin: GeoLocation | None = None  # point(); updates unsupported on ArcadeDB


class AuthoredEdge(Edge, type="AUTHORED"):
    created_at: datetime


class Author(Node, labels=["Author"]):
    id: str = Field(primary_key=True)
    email: str = Field(unique=True)
    name: str
    articles: list[Article] = Relation(
        relationship="AUTHORED",
        direction="OUTGOING",
        target="Article",
        edge_model=AuthoredEdge,
    )


# Pick a backend here — nothing else in this file changes.
driver = create_driver("falkordb", host="localhost", port=6379, graph="blog")

# Reconcile declared indexes/constraints with the live graph.
schema = SchemaManager(driver)
schema.sync_schema([Author, Article], drop_extra=False)  # create missing; keep extras

with Session(driver) as session:
    alice = Author(id="alice", email="alice@example.com", name="Alice")
    intro = Article(
        id="a1", title="Graphs 101", category="intro", status=Status.PUBLISHED
    )
    session.add_all([alice, intro])
    session.commit()

    # Create the AUTHORED edge, writing properties onto the relationship itself.
    # relate() is MERGE-based: idempotent, and re-calling updates the edge props.
    session.relate(
        alice, Author.articles, intro, edge=AuthoredEdge(created_at=datetime.now(UTC))
    )
    session.commit()

with Session(driver) as session:
    alice = session.get(Author, "alice")
    alice.name = "Alice Smith"  # tracked automatically — no explicit dirty flag
    session.commit()  # only the diff is written
```

### 2. Query and traverse the graph

Read data back with composable, type-safe statements, multi-hop traversals, paginated
repositories, or your own Cypher. `select()` builds a statement independently of any session,
so you can assemble it from conditional filters and reuse it across sessions. `.traverse()`
walks a single relationship; `.repeat()` walks one to any depth for real-world graph queries.

```python
from runic.ogm import Repository, Session, select


# Compose a query dynamically, then run it three ways.
stmt = select(Article).where(Article.status == Status.PUBLISHED)
if category:
    stmt = stmt.where(Article.category == category)

with Session(driver) as session:
    articles: list[Article] = session.scalars(stmt)  # list[Article]
    latest: Article | None = session.scalar(stmt)  # Article | None
    n: int = session.count(stmt)  # int

    # Single-hop traversal with an edge-property filter — published articles
    # Alice authored after a cutoff date.
    recent = (
        session.query(Author)
        .alias("a")
        .where(Author.id == "alice")
        .traverse(Author.articles, edge_alias="e")
        .alias("art")
        .where(AuthoredEdge.created_at >= cutoff, on="e")
        .where(Article.status == Status.PUBLISHED, on="art")
        .return_target("art")
        .all()
    )

    # Variable-length traversal — every article reachable within 3 AUTHORED hops
    # (e.g. co-authorship chains). max_hops=None means unbounded.
    network = (
        session.query(Author)
        .alias("a")
        .where(Author.id == "alice")
        .repeat(Author.articles, min_hops=1, max_hops=3)
        .alias("reached")
        .all()
    )

    # Paginate through a repository.
    page = Repository(session, Article).find_all(skip=0, limit=20)

    # Or load relationships off an entity: lazy by default, eager on request.
    author = session.get(Author, "alice")
    author.articles  # lazy — queried on first access
    eager = session.get(Author, "alice", fetch=["articles"])
    eager.articles  # already loaded, no extra query


# Subclass Repository to drop down to typed Cypher when you need it.
class ArticleRepository(Repository[Article]):
    def by_author_email(self, email: str) -> list[Article]:
        return self.cypher(
            "MATCH (:Author {email: $email})-[:AUTHORED]->(a:Article) RETURN a",
            {"email": email},
            returns=Article,
        )
```

> [!TIP]
> Every pattern above has an async twin. `AsyncSession`, `AsyncRepository`, and
> `AsyncConnectionManager` share the same API for async-first applications.

---

## Migrations

`runic.migrate` is a migration tool with a CLI for versioned schema evolution. It stores
revision state inside the graph, so there's no external state table to manage.

```bash
runic init
runic revision -m "create user index"
```

Edit the generated file in `runic/versions/`:

```python
revision = "1975ea83b712"
down_revision = None


def upgrade(op) -> None:
    op.create_range_index("User", "email")


def downgrade(op) -> None:
    op.drop_range_index("User", "email")
```

Apply or roll back:

```bash
runic upgrade            # apply all pending revisions
runic downgrade          # roll back one step
runic downgrade 1975e    # roll back to a specific revision (prefix is enough)
```

**Baseline an existing graph** without re-running anything — introspect, generate a root
revision, and stamp it. The generated revision rebuilds the full schema on an empty graph,
so it's safe to replay for CI, cloning, or new tenants:

```bash
runic baseline -m "baseline"   # introspect, generate root revision, stamp it
runic current                  # verify it is now tracked
runic upgrade head             # rebuild full schema on a fresh graph
```

**Programmatic SDK** — drive migrations from code, against any backend:

```python
from pathlib import Path
from runic import Runic, init
from runic.migrate.adapters import create_adapter

init(Path("runic/"))

adapter = create_adapter(
    "falkordb", url="falkor://localhost:6379", graph_name="my_graph"
)
# adapter = create_adapter("neo4j", host="localhost", port=7687,
#                          database="neo4j", username="neo4j", password="secret")

runic = Runic(adapter, script_location=Path("runic/"))
runic.migrate.upgrade("head")
print("current:", runic.migrate.current())
```

---

## Graph-RAG

`runic.rag` turns unstructured text into a knowledge graph and answers questions over it with
citations — chunking, entity/relation extraction, embedding, storage, and hybrid (vector +
fulltext + graph-expansion) retrieval are handled for you. It builds on the same OGM and driver
layer, so it runs on any supported backend. OpenAI is the default for the LLM and embeddings;
point it at Ollama for a fully local run.

```python
from runic.ogm import create_driver
from runic.rag import GraphRAG, Ontology, RagSettings

settings = RagSettings()
driver = create_driver(
    "falkordb", host="localhost", port=6379, graph=settings.falkordb_graph
)

# with_defaults() wires the full adapter stack from your environment:
# paragraph chunker, extractor, embedder, resolver, retrievers, reranker, synthesizer.
rag = GraphRAG.with_defaults(driver, settings=settings, ontology=Ontology.default())
rag.bootstrap_schema()  # create entity types + indexes (idempotent)

# Ingest: chunk -> extract entities/relations -> embed -> resolve -> write the graph.
rag.ingest_text(
    "Ada Lovelace worked with Charles Babbage on the Analytical Engine in London.",
    source="inline-demo",
)

# mode="auto" classifies the question and picks the retrieval strategy automatically.
answer = rag.query("Who worked on the Analytical Engine?")
print(answer.text)
for citation in answer.citations:
    print(f"  - [{citation.source}] {citation.text[:80]}...")
```

Every stage is a port with a default adapter you can swap — custom ontologies, chunkers,
extractors, or retrievers — and the optional **`runic-rag-docling`** add-on plugs Docling in for
layout-aware parsing and chunking of PDFs and office documents.

```bash
uv add "runic-py[graphrag,falkordb]"   # Graph-RAG extras + a backend driver
```

> [!NOTE]
> Set `OPENAI_API_KEY` (or place it in a local `.env`) before running, or set
> `RUNIC_RAG_LLM_PROVIDER=ollama` and `RUNIC_RAG_EMBEDDING_PROVIDER=ollama` to run fully local.

---

## Installation

Install the core package plus the extra for your backend. The core has **no graph-driver
dependency** — you only pull in what you use.

| Backend | Extra | Driver installed |
| --- | --- | --- |
| FalkorDB | `falkordb` | `falkordb` |
| Neo4j | `neo4j` | `neo4j` |
| Memgraph | `memgraph` | `neo4j` (Bolt) |
| ArcadeDB | `arcadedb` | `neo4j` (Bolt) |
| Apache AGE | `age` | `psycopg[binary]` |
| All backends | `all` | all of the above |
| Graph-RAG | `graphrag` | `pydantic-ai`, `pymupdf` (combine with a backend) |

```bash
uv add "runic-py[falkordb]"   # FalkorDB
uv add "runic-py[neo4j]"      # Neo4j
uv add "runic-py[memgraph]"   # Memgraph (Bolt)
uv add "runic-py[arcadedb]"   # ArcadeDB (Bolt)
uv add "runic-py[age]"        # Apache AGE (PostgreSQL extension)
uv add "runic-py[all]"        # everything
uv add "runic-py[graphrag,falkordb]"   # Graph-RAG SDK + a backend driver
```

> [!NOTE]
> Runic requires Python 3.14+.

---

## Documentation

Full conceptual overview, async usage, advanced CLI flags, the Graph-RAG guides, and the
complete API reference live at the **[Runic Documentation](https://runic.rehpoehler.de)**.

## License

Released under the [MIT License](LICENSE.md).
