feat: add FalkorDB export backend (#1175)

Adds FalkorDB as a sibling option to the existing Neo4j sink, selected via
`graphify export falkordb [--push redis://localhost:6379]`.

- New push_to_falkordb() in graphify/export.py mirrors push_to_neo4j; FalkorDB
  is OpenCypher-compatible so the MERGE/SET upsert queries are identical.
- export falkordb subcommand wired in graphify/__main__.py (cypher.txt when no
  --push, direct push otherwise). Auth is optional; target graph defaults to
  "graphify".
- falkordb optional extra in pyproject.toml (and in the all extra).
- Tests: CLI cypher generation (CI-safe) + real-FalkorDB integration tests that
  skip when no instance is reachable.
- README extras table + command reference and CHANGELOG updated.
This commit is contained in:
Gal Shubeli
2026-06-07 15:03:49 +03:00
committed by Gal Shubeli
parent a8dbbe59cf
commit 7aa300709f
7 changed files with 223 additions and 3 deletions
+1
View File
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.8.33 (2026-06-06)
- Feat: FalkorDB export backend — sibling to Neo4j, selected via `graphify export falkordb [--push redis://localhost:6379]`. FalkorDB is OpenCypher-compatible, so the MERGE/SET upsert queries match the Neo4j path; auth is optional and the target graph defaults to `graphify`. Install with `uv tool install "graphifyy[falkordb]"` (#1175).
- Feat: install banner — `graphify install` now prints an amber knowledge-graph brain in the terminal (TTY-only, silent in CI/pipes, never raises).
- Fix: Python `from pkg import submod` package-form imports now resolve to a file-level `imports_from` edge to the submodule file when it exists on disk. Previously these imports produced zero edges, leaving test files as disconnected islands in the graph (up to 66% of test nodes in some corpora). The fix lives in the symbol-resolution post-pass which has filesystem access (#1146).
- Fix: builtin type-annotation nodes (`str`, `int`, `bool`, `float`, `bytes`, `MagicMock`, `Mock`, `AsyncMock`, etc.) no longer appear as graph nodes or accumulate edges. They were being created via the annotation walker whenever used as parameter or return types, inflating degree counts ~25% and displacing real abstractions from god-node rankings. A new `_PYTHON_ANNOTATION_NOISE` filter suppresses them at extraction time; `god_nodes` also filters them as a defense for pre-existing graphs (#1147).
+3
View File
@@ -162,6 +162,7 @@ Install only what you need:
| `video` | Video/audio transcription (faster-whisper + yt-dlp) | `uv tool install "graphifyy[video]"` |
| `mcp` | MCP stdio server | `uv tool install "graphifyy[mcp]"` |
| `neo4j` | Neo4j push support | `uv tool install "graphifyy[neo4j]"` |
| `falkordb` | FalkorDB push support | `uv tool install "graphifyy[falkordb]"` |
| `svg` | SVG graph export | `uv tool install "graphifyy[svg]"` |
| `leiden` | Leiden community detection (Python < 3.13 only) | `uv tool install "graphifyy[leiden]"` |
| `ollama` | Ollama local inference | `uv tool install "graphifyy[ollama]"` |
@@ -496,6 +497,8 @@ graphify install # overwrites the skill file
/graphify ./raw --graphml # export for Gephi / yEd
/graphify ./raw --neo4j # generate cypher.txt for Neo4j
/graphify ./raw --neo4j-push bolt://localhost:7687
graphify export falkordb # generate cypher.txt (FalkorDB is OpenCypher-compatible)
graphify export falkordb --push redis://localhost:6379 # push directly to a running FalkorDB
/graphify ./raw --watch # auto-sync as files change
/graphify ./raw --mcp # start MCP stdio server
+18 -2
View File
@@ -3370,7 +3370,7 @@ def main() -> None:
elif cmd == "export":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j"):
if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j", "falkordb"):
print("Usage: graphify export <format>", file=sys.stderr)
print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr)
print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr)
@@ -3381,6 +3381,8 @@ def main() -> None:
print(" graphml [--graph PATH]", file=sys.stderr)
print(" neo4j [--graph PATH] [--push URI] [--user U] [--password P]", file=sys.stderr)
print(" (or set NEO4J_PASSWORD instead of --password to keep it off argv)", file=sys.stderr)
print(" falkordb [--graph PATH] [--push URI] [--user U] [--password P]", file=sys.stderr)
print(" (or set FALKORDB_PASSWORD instead of --password to keep it off argv)", file=sys.stderr)
sys.exit(1)
# Parse shared args
@@ -3407,7 +3409,9 @@ def main() -> None:
# F-031: prefer the NEO4J_PASSWORD env var so the password never
# appears on argv (visible in `ps` output / shell history). The
# explicit --password flag still overrides it for compatibility.
neo4j_password: str | None = os.environ.get("NEO4J_PASSWORD") or None
neo4j_password: str | None = (
os.environ.get("NEO4J_PASSWORD") or os.environ.get("FALKORDB_PASSWORD") or None
)
i = 0
while i < len(args):
a = args[i]
@@ -3626,6 +3630,18 @@ def main() -> None:
_to_cypher(G, str(out_dir / "cypher.txt"))
print(f"cypher.txt written - import with: cypher-shell < {out_dir}/cypher.txt")
elif subcmd == "falkordb":
if neo4j_uri:
from graphify.export import push_to_falkordb as _push
result = _push(G, uri=neo4j_uri, user=neo4j_user,
password=neo4j_password, communities=communities)
print(f"Pushed to FalkorDB: {result['nodes']} nodes, {result['edges']} edges")
else:
from graphify.export import to_cypher as _to_cypher
_to_cypher(G, str(out_dir / "cypher.txt"))
print(f"cypher.txt written - FalkorDB is OpenCypher-compatible; "
f"import with: redis-cli -x GRAPH.QUERY graphify < {out_dir}/cypher.txt")
elif cmd == "benchmark":
from graphify.benchmark import run_benchmark, print_benchmark
+95
View File
@@ -1309,6 +1309,101 @@ def push_to_neo4j(
return {"nodes": nodes_pushed, "edges": edges_pushed}
def push_to_falkordb(
G: nx.Graph,
uri: str,
user: str | None = None,
password: str | None = None,
communities: dict[int, list[str]] | None = None,
graph_name: str = "graphify",
) -> dict[str, int]:
"""Push graph directly to a running FalkorDB instance via the Python SDK.
Requires: pip install falkordb
FalkorDB is OpenCypher-compatible, so the MERGE/SET upsert queries are
identical to push_to_neo4j. Differences from the Neo4j path:
- connects with FalkorDB(host, port, username, password) instead of a bolt
driver; the URI is parsed for host/port (default port 6379, scheme
defaults to redis:// when omitted, e.g. "localhost:6379").
- a named graph is selected via db.select_graph(graph_name) (default
"graphify"); FalkorDB keys each graph by name in the same instance.
- queries run via graph.query(cypher, params) - there is no session object.
- auth is optional (FalkorDB runs without credentials by default), so user
and password may be None.
- no APOC: the Neo4j path does not use APOC either, so nothing to port.
Uses MERGE so re-running is safe - nodes and edges are upserted, not
duplicated. Returns a dict with counts of nodes and edges pushed.
"""
try:
from falkordb import FalkorDB
except ImportError as e:
raise ImportError(
"falkordb SDK not installed. Run: pip install falkordb"
) from e
from urllib.parse import urlparse
node_community = _node_community_map(communities) if communities else {}
def _safe_rel(relation: str) -> str:
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
def _safe_label(label: str) -> str:
"""Sanitize a FalkorDB node label to prevent Cypher injection."""
sanitized = re.sub(r"[^A-Za-z0-9_]", "", label)
return sanitized if sanitized else "Entity"
parsed = urlparse(uri if "://" in uri else f"redis://{uri}")
# FalkorDB auth is optional. Only send credentials when a password is
# provided; otherwise connect anonymously and ignore any bolt-style default
# username (e.g. Neo4j's "neo4j"), which FalkorDB rejects as an unknown ACL
# user. Credentials embedded in the URI take precedence over the args.
connect_user = parsed.username or (user if password else None)
connect_password = parsed.password or (password or None)
db = FalkorDB(
host=parsed.hostname or "localhost",
port=parsed.port or 6379,
username=connect_user,
password=connect_password,
)
graph = db.select_graph(graph_name)
nodes_pushed = 0
edges_pushed = 0
for node_id, data in G.nodes(data=True):
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
props["id"] = node_id
cid = node_community.get(node_id)
if cid is not None:
props["community"] = cid
ftype = _safe_label(data.get("file_type", "Entity").capitalize())
graph.query(
f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
{"id": node_id, "props": props},
)
nodes_pushed += 1
for u, v, data in G.edges(data=True):
rel = _safe_rel(data.get("relation", "RELATED_TO"))
props = {
k: v for k, v in data.items()
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
}
graph.query(
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
{"src": u, "tgt": v, "props": props},
)
edges_pushed += 1
return {"nodes": nodes_pushed, "edges": edges_pushed}
def to_graphml(
G: nx.Graph,
communities: dict[int, list[str]],
+2 -1
View File
@@ -50,6 +50,7 @@ Issues = "https://github.com/safishamsi/graphify/issues"
[project.optional-dependencies]
mcp = ["mcp"]
neo4j = ["neo4j"]
falkordb = ["falkordb"]
pdf = ["pypdf", "markdownify"]
watch = ["watchdog"]
svg = ["matplotlib", "numpy>=2.0; python_version >= '3.13'"]
@@ -71,7 +72,7 @@ sql = ["tree-sitter-sql"]
# avoids breaking the default `uv tool install graphifyy` for everyone (#1104).
dm = ["tree-sitter-dm"]
terraform = ["tree-sitter-hcl"]
all = ["mcp", "neo4j", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl"]
all = ["mcp", "neo4j", "falkordb", "pypdf", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl"]
[project.scripts]
graphify = "graphify.__main__:main"
+13
View File
@@ -154,6 +154,19 @@ def test_export_neo4j_creates_cypher(tmp_path):
assert "MERGE" in content or "CREATE" in content
# ── graphify export falkordb (cypher) ────────────────────────────────────────
def test_export_falkordb_creates_cypher(tmp_path):
_make_graph(tmp_path)
r = _run(["export", "falkordb"], tmp_path)
assert r.returncode == 0, r.stderr
cypher = tmp_path / "graphify-out" / "cypher.txt"
assert cypher.exists()
assert cypher.stat().st_size > 0
content = cypher.read_text()
assert "MERGE" in content or "CREATE" in content
# ── graphify query ───────────────────────────────────────────────────────────
def test_query_returns_output(tmp_path):
+91
View File
@@ -0,0 +1,91 @@
"""Integration test for push_to_falkordb against a real FalkorDB instance.
Runs for real against `falkordb/falkordb:latest`:
docker run -d -p 6379:6379 falkordb/falkordb:latest
uv run pytest tests/test_falkordb_integration.py -q
The test auto-skips when the `falkordb` SDK is not installed or no FalkorDB is
reachable, so it is a no-op in the default CI (which runs no external services).
Host/port are overridable via FALKORDB_HOST / FALKORDB_PORT.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
falkordb = pytest.importorskip("falkordb")
FIXTURES = Path(__file__).parent / "fixtures"
HOST = os.environ.get("FALKORDB_HOST", "localhost")
PORT = int(os.environ.get("FALKORDB_PORT", "6379"))
GRAPH_NAME = "graphify_test"
def _connect():
"""Return a connected FalkorDB client, or skip if none is reachable."""
try:
db = falkordb.FalkorDB(host=HOST, port=PORT)
db.connection.ping()
return db
except Exception as e: # pragma: no cover - depends on local environment
pytest.skip(f"no FalkorDB reachable at {HOST}:{PORT} ({e})")
@pytest.fixture()
def db():
client = _connect()
# Start from a clean slate and clean up afterwards.
try:
client.select_graph(GRAPH_NAME).delete()
except Exception:
pass
yield client
try:
client.select_graph(GRAPH_NAME).delete()
except Exception:
pass
def test_push_to_falkordb_creates_expected_graph(db):
from graphify.build import build_from_json
from graphify.export import push_to_falkordb
extraction = json.loads((FIXTURES / "extraction.json").read_text())
G = build_from_json(extraction)
result = push_to_falkordb(
G, uri=f"{HOST}:{PORT}", graph_name=GRAPH_NAME
)
assert result["nodes"] == G.number_of_nodes()
assert result["edges"] == G.number_of_edges()
graph = db.select_graph(GRAPH_NAME)
node_count = graph.query("MATCH (n) RETURN count(n)").result_set[0][0]
edge_count = graph.query("MATCH ()-[r]->() RETURN count(r)").result_set[0][0]
assert node_count == G.number_of_nodes()
assert edge_count == G.number_of_edges()
def test_push_to_falkordb_is_idempotent(db):
"""MERGE-based push is safe to re-run - counts must not grow."""
from graphify.build import build_from_json
from graphify.export import push_to_falkordb
extraction = json.loads((FIXTURES / "extraction.json").read_text())
G = build_from_json(extraction)
push_to_falkordb(G, uri=f"{HOST}:{PORT}", graph_name=GRAPH_NAME)
push_to_falkordb(G, uri=f"{HOST}:{PORT}", graph_name=GRAPH_NAME)
graph = db.select_graph(GRAPH_NAME)
node_count = graph.query("MATCH (n) RETURN count(n)").result_set[0][0]
edge_count = graph.query("MATCH ()-[r]->() RETURN count(r)").result_set[0][0]
assert node_count == G.number_of_nodes()
assert edge_count == G.number_of_edges()