sonnet-graph 0.1.0__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.
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonnet-graph
3
+ Version: 0.1.0
4
+ Summary: Graph database integration (cypher-graphdb) for sonnet-server applications
5
+ Author-email: Wolfgang Miller <wolfgang.miller@petrarca-labs.com>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Python: <4.0,>=3.14
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: sonnet-server>=0.1.11
14
+ Requires-Dist: cypher-graphdb>=0.2.7
15
+ Requires-Dist: loguru>=0.7.3
16
+ Provides-Extra: dev
17
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
18
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
19
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
20
+
21
+ # sonnet-graph
22
+
23
+ Graph database integration (cypher-graphdb) for sonnet-server applications.
24
+
25
+ Provides reusable infrastructure for sonnet-server apps that need a
26
+ cypher-graphdb connection pool:
27
+
28
+ - **`GraphDBExtension`** -- sonnet-server extension managing pool lifecycle
29
+ (startup/shutdown), with optional mode for apps where graph is not required.
30
+ - **Pool context** -- `borrow_graphdb()` context manager and `get_current_graphdb()`
31
+ ambient session accessor via `ContextVar`.
32
+ - **Readiness checks** -- `GraphdbStage` with initialization and health checks
33
+ for the sonnet-server readiness pipeline.
34
+ - **FastAPI dependencies** -- `get_graphdb_session()` async generator for
35
+ router-level ambient sessions.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install sonnet-graph
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```python
46
+ from sonnet_graph import GraphDBExtension, borrow_graphdb, get_current_graphdb
47
+
48
+ # In app.py -- register the extension
49
+ _extension_registry = create_extension_registry(
50
+ DatabaseExtension(),
51
+ GraphDBExtension(), # required (raises on missing CGDB_* settings)
52
+ GraphDBExtension(optional=True), # or optional (skips if unconfigured)
53
+ ...
54
+ )
55
+
56
+ # In service code -- borrow a connection
57
+ with borrow_graphdb() as db:
58
+ db.execute("MATCH (n) RETURN count(n)")
59
+
60
+ # In service code -- ambient session (within borrow_graphdb scope)
61
+ db = get_current_graphdb()
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ Graph connection is configured via cypher-graphdb's own environment variables
67
+ (not duplicated in consumer settings):
68
+
69
+ | Variable | Description |
70
+ |---|---|
71
+ | `CGDB_BACKEND` | Backend type: `age` or `memgraph` |
72
+ | `CGDB_CINFO` | Connection string / DSN |
73
+ | `CGDB_GRAPH` | Graph name |
74
+ | `CGDB_READ_ONLY` | Read-only mode (default: `false`) |
75
+ | `CGDB_CREATE_GRAPH_IF_NOT_EXISTS` | Auto-create graph, AGE only (default: `false`) |
@@ -0,0 +1,55 @@
1
+ # sonnet-graph
2
+
3
+ Graph database integration (cypher-graphdb) for sonnet-server applications.
4
+
5
+ Provides reusable infrastructure for sonnet-server apps that need a
6
+ cypher-graphdb connection pool:
7
+
8
+ - **`GraphDBExtension`** -- sonnet-server extension managing pool lifecycle
9
+ (startup/shutdown), with optional mode for apps where graph is not required.
10
+ - **Pool context** -- `borrow_graphdb()` context manager and `get_current_graphdb()`
11
+ ambient session accessor via `ContextVar`.
12
+ - **Readiness checks** -- `GraphdbStage` with initialization and health checks
13
+ for the sonnet-server readiness pipeline.
14
+ - **FastAPI dependencies** -- `get_graphdb_session()` async generator for
15
+ router-level ambient sessions.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install sonnet-graph
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ from sonnet_graph import GraphDBExtension, borrow_graphdb, get_current_graphdb
27
+
28
+ # In app.py -- register the extension
29
+ _extension_registry = create_extension_registry(
30
+ DatabaseExtension(),
31
+ GraphDBExtension(), # required (raises on missing CGDB_* settings)
32
+ GraphDBExtension(optional=True), # or optional (skips if unconfigured)
33
+ ...
34
+ )
35
+
36
+ # In service code -- borrow a connection
37
+ with borrow_graphdb() as db:
38
+ db.execute("MATCH (n) RETURN count(n)")
39
+
40
+ # In service code -- ambient session (within borrow_graphdb scope)
41
+ db = get_current_graphdb()
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ Graph connection is configured via cypher-graphdb's own environment variables
47
+ (not duplicated in consumer settings):
48
+
49
+ | Variable | Description |
50
+ |---|---|
51
+ | `CGDB_BACKEND` | Backend type: `age` or `memgraph` |
52
+ | `CGDB_CINFO` | Connection string / DSN |
53
+ | `CGDB_GRAPH` | Graph name |
54
+ | `CGDB_READ_ONLY` | Read-only mode (default: `false`) |
55
+ | `CGDB_CREATE_GRAPH_IF_NOT_EXISTS` | Auto-create graph, AGE only (default: `false`) |
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sonnet-graph"
7
+ version = "0.1.0"
8
+ authors = [
9
+ { name = "Wolfgang Miller", email = "wolfgang.miller@petrarca-labs.com" },
10
+ ]
11
+ description = "Graph database integration (cypher-graphdb) for sonnet-server applications"
12
+ readme = "README.md"
13
+ license = "Apache-2.0"
14
+ requires-python = ">=3.14,<4.0"
15
+ classifiers = [
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.14",
20
+ ]
21
+ dependencies = [
22
+ "sonnet-server>=0.1.11",
23
+ "cypher-graphdb>=0.2.7",
24
+ "loguru>=0.7.3",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "ruff>=0.3.0",
30
+ "pytest>=7.0.0",
31
+ "pytest-asyncio>=0.23.0",
32
+ ]
33
+
34
+ [tool.uv.sources]
35
+ sonnet-server = { workspace = true }
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.pytest.ini_options]
41
+ markers = [
42
+ "unit: marks tests as unit tests (default)",
43
+ "integration: marks tests as integration tests",
44
+ ]
45
+ testpaths = ["tests"]
46
+ addopts = [
47
+ "-m unit",
48
+ "--strict-markers",
49
+ ]
50
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,51 @@
1
+ """sonnet-graph -- Graph database integration for sonnet-server applications.
2
+
3
+ Public API:
4
+
5
+ Extension (register in app.py):
6
+ GraphDBExtension -- pool lifecycle, readiness checks
7
+
8
+ Pool context:
9
+ borrow_graphdb -- context manager: acquire connection + set ContextVar
10
+ get_current_graphdb -- read ambient CypherGraphDB from ContextVar
11
+ init_graphdb_pool -- lazily create the pool singleton
12
+ dispose_graphdb_pool -- close and discard the pool
13
+ is_pool_initialized -- check if pool exists
14
+ graphdb_configured -- check if CGDB_* settings are present
15
+
16
+ FastAPI dependencies:
17
+ get_graphdb_session -- async generator for router-level ambient session
18
+ get_graphdb -- sync generator yielding CypherGraphDB explicitly
19
+
20
+ Readiness pipeline (auto-registered by GraphDBExtension):
21
+ GraphdbStage -- pipeline stage marker (order=40)
22
+ """
23
+
24
+ from sonnet_graph.checks.stages import GraphdbStage
25
+ from sonnet_graph.context import (
26
+ borrow_graphdb,
27
+ dispose_graphdb_pool,
28
+ get_current_graphdb,
29
+ graphdb_configured,
30
+ init_graphdb_pool,
31
+ is_pool_initialized,
32
+ )
33
+ from sonnet_graph.dependencies import get_graphdb, get_graphdb_session
34
+ from sonnet_graph.extension import GraphDBExtension
35
+
36
+ __all__ = [
37
+ # Extension
38
+ "GraphDBExtension",
39
+ # Pool context
40
+ "borrow_graphdb",
41
+ "dispose_graphdb_pool",
42
+ "get_current_graphdb",
43
+ "graphdb_configured",
44
+ "init_graphdb_pool",
45
+ "is_pool_initialized",
46
+ # FastAPI dependencies
47
+ "get_graphdb",
48
+ "get_graphdb_session",
49
+ # Readiness pipeline
50
+ "GraphdbStage",
51
+ ]
@@ -0,0 +1,11 @@
1
+ """Graph DB readiness checks for the sonnet-graph package.
2
+
3
+ Importing this package registers ``GraphdbStage`` in the global stage
4
+ registry (via the ``@readiness_stage`` decorator in ``stages.py``).
5
+
6
+ Check classes are registered lazily -- they are imported by
7
+ ``GraphDBExtension.on_startup()`` so they only appear in the pipeline
8
+ when the extension is active.
9
+ """
10
+
11
+ from sonnet_graph.checks.stages import GraphdbStage # noqa: F401 -- registers stage
@@ -0,0 +1,35 @@
1
+ """GraphDB connection health check for readiness pipeline."""
2
+
3
+ from loguru import logger
4
+
5
+ from sonnet_graph.checks.stages import GraphdbStage
6
+ from sonnet_graph.context import borrow_graphdb
7
+ from sonnet_server.readiness_pipeline import ReadinessCheck, ReadinessCheckResult, readiness_check
8
+
9
+
10
+ @readiness_check(stage=GraphdbStage, is_critical=True, run_once=False, order=1)
11
+ class GraphDBHealthCheck(ReadinessCheck):
12
+ """Verifies a live connection to the graph database."""
13
+
14
+ def __init__(self) -> None:
15
+ super().__init__("graphdb_health_check")
16
+
17
+ def _execute(self) -> ReadinessCheckResult:
18
+ logger.info("Checking GraphDB connection health")
19
+ try:
20
+ with borrow_graphdb() as cdb:
21
+ if not cdb.check_connection():
22
+ return self.failed(
23
+ "CypherGraphDB connection check failed",
24
+ {"graph": getattr(cdb, "graph_name", None)},
25
+ )
26
+ return self.success(
27
+ "CypherGraphDB connection is healthy",
28
+ {
29
+ "id": getattr(cdb, "id", None),
30
+ "graph": getattr(cdb, "graph_name", None),
31
+ },
32
+ )
33
+ except (OSError, RuntimeError, ConnectionError, TimeoutError) as e:
34
+ logger.error("GraphDB health check failed: {}", e)
35
+ return self.failed(f"GraphDB health check failed: {e}", {"error": str(e)})
@@ -0,0 +1,28 @@
1
+ """GraphDB pool initialization check for readiness pipeline."""
2
+
3
+ from loguru import logger
4
+
5
+ from sonnet_graph.checks.stages import GraphdbStage
6
+ from sonnet_graph.context import init_graphdb_pool, is_pool_initialized
7
+ from sonnet_server.readiness_pipeline import ReadinessCheck, ReadinessCheckResult, readiness_check
8
+
9
+
10
+ @readiness_check(stage=GraphdbStage, is_critical=True, run_once=True, order=0)
11
+ class GraphDBInitializationCheck(ReadinessCheck):
12
+ """Ensures the CypherGraphDB connection pool is initialized."""
13
+
14
+ def __init__(self) -> None:
15
+ super().__init__("graphdb_initialization")
16
+
17
+ def _execute(self) -> ReadinessCheckResult:
18
+ logger.info("Checking GraphDB pool initialization")
19
+ try:
20
+ if not is_pool_initialized():
21
+ init_graphdb_pool()
22
+ return self.success("CypherGraphDB pool initialized", {"initialized": True})
23
+ except (OSError, RuntimeError, ConnectionError, TimeoutError) as e:
24
+ logger.error("CypherGraphDB initialization failed: {}", e)
25
+ return self.failed(
26
+ f"CypherGraphDB initialization failed: {e}",
27
+ {"error": str(e), "type": type(e).__name__, "initialized": False},
28
+ )
@@ -0,0 +1,13 @@
1
+ """Graph database readiness pipeline stage.
2
+
3
+ Registered at import time via the @readiness_stage decorator.
4
+ Order 40 places it after database (1), messaging (2), cache (3)
5
+ but before application-specific stages (50+).
6
+ """
7
+
8
+ from sonnet_server.readiness_pipeline import readiness_stage
9
+
10
+
11
+ @readiness_stage(order=40, description="Graph database checks", is_critical=False, fail_fast=True)
12
+ class GraphdbStage:
13
+ """CypherGraphDB connection pool initialization and health."""
@@ -0,0 +1,151 @@
1
+ """Graph DB connection pool lifecycle and ambient session management.
2
+
3
+ Provides:
4
+ - ``init_graphdb_pool()`` -- lazily initialise the connection pool
5
+ - ``dispose_graphdb_pool()`` -- close and discard the pool
6
+ - ``borrow_graphdb()`` -- context manager: acquire connection, set ContextVar
7
+ - ``get_current_graphdb()`` -- read the ambient CypherGraphDB from ContextVar
8
+ - ``is_pool_initialized()`` -- check if the pool has been created
9
+ - ``graphdb_configured()`` -- check if CGDB_* settings are present
10
+
11
+ Configuration comes from cypher-graphdb's own settings
12
+ (CGDB_BACKEND, CGDB_CINFO, CGDB_GRAPH) -- not duplicated here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Iterator
18
+ from contextlib import contextmanager, suppress
19
+ from contextvars import ContextVar
20
+
21
+ from cypher_graphdb import CypherGraphDB
22
+ from cypher_graphdb.dbpool import CypherGraphDBPool
23
+ from cypher_graphdb.settings import Settings as CypherSettings
24
+ from cypher_graphdb.settings import get_settings as get_cypher_settings
25
+ from loguru import logger
26
+
27
+ _current_graphdb: ContextVar[CypherGraphDB | None] = ContextVar("_current_graphdb", default=None)
28
+
29
+ # Held as a module-level variable so dispose_graphdb_pool() can close it
30
+ # without risk of accidentally recreating it.
31
+ _pool_instance: CypherGraphDBPool | None = None
32
+
33
+
34
+ def graphdb_configured(settings: CypherSettings | None = None) -> bool:
35
+ """Return True if the required CGDB_* environment variables are set."""
36
+ s = settings or get_cypher_settings()
37
+ return bool(s.backend and s.cinfo and s.graph)
38
+
39
+
40
+ def get_current_graphdb() -> CypherGraphDB:
41
+ """Return the ambient CypherGraphDB session.
42
+
43
+ Must be called within a ``borrow_graphdb()`` context.
44
+
45
+ Raises:
46
+ RuntimeError: If no active session exists.
47
+ """
48
+ cdb = _current_graphdb.get()
49
+ if cdb is None:
50
+ raise RuntimeError("No active CypherGraphDB session -- call within borrow_graphdb() context")
51
+ return cdb
52
+
53
+
54
+ @contextmanager
55
+ def borrow_graphdb(block: bool = True, timeout: float = 10.0) -> Iterator[CypherGraphDB]:
56
+ """Acquire a CypherGraphDB from the pool and set the ambient ContextVar.
57
+
58
+ The connection is returned to the pool when the context exits.
59
+
60
+ Args:
61
+ block: Whether to block if no connections are available.
62
+ timeout: Maximum seconds to wait for a connection.
63
+ """
64
+ pool = _get_or_create_pool()
65
+ with pool.acquire(block=block, timeout=timeout) as cdb:
66
+ token = _current_graphdb.set(cdb)
67
+ try:
68
+ yield cdb
69
+ finally:
70
+ _current_graphdb.reset(token)
71
+
72
+
73
+ def init_graphdb_pool(*, pool_size: int = 3) -> bool:
74
+ """Initialize the graph DB connection pool.
75
+
76
+ Must be called before the first ``borrow_graphdb()`` to configure
77
+ pool_size. Safe to call multiple times -- idempotent once created.
78
+
79
+ Returns True if the pool was created, False if CGDB_* settings are missing.
80
+ """
81
+ global _pool_instance
82
+ if not graphdb_configured():
83
+ return False
84
+ if _pool_instance is None:
85
+ _pool_instance = _create_pool(pool_size)
86
+ logger.info("GraphDB pool initialized")
87
+ return True
88
+
89
+
90
+ def dispose_graphdb_pool() -> None:
91
+ """Shut down the pool and release all connections."""
92
+ global _pool_instance
93
+ if _pool_instance is not None:
94
+ with suppress(RuntimeError, ConnectionError, OSError):
95
+ _pool_instance.close()
96
+ _pool_instance = None
97
+ logger.info("GraphDB pool disposed")
98
+
99
+
100
+ def is_pool_initialized() -> bool:
101
+ """Return True if the pool has been created."""
102
+ return _pool_instance is not None
103
+
104
+
105
+ # -- internal ----------------------------------------------------------------
106
+
107
+
108
+ def _get_or_create_pool() -> CypherGraphDBPool:
109
+ """Return the existing pool or create one with default pool_size=3.
110
+
111
+ Called by ``borrow_graphdb()`` when the pool was not pre-initialized
112
+ via ``init_graphdb_pool()``. Falls back to pool_size=3.
113
+ """
114
+ global _pool_instance
115
+ if _pool_instance is None:
116
+ _pool_instance = _create_pool(pool_size=3)
117
+ return _pool_instance
118
+
119
+
120
+ def _create_pool(pool_size: int) -> CypherGraphDBPool:
121
+ """Create and return a new CypherGraphDBPool."""
122
+ settings = get_cypher_settings()
123
+ if not settings.backend:
124
+ raise RuntimeError("Graph backend not configured. Set CGDB_BACKEND.")
125
+ if not settings.cinfo:
126
+ raise RuntimeError("Graph connection not configured. Set CGDB_CINFO.")
127
+ if not settings.graph:
128
+ raise RuntimeError("Graph name not configured. Set CGDB_GRAPH.")
129
+
130
+ connect_params = {
131
+ "cinfo": settings.cinfo,
132
+ "graph_name": settings.graph,
133
+ "read_only": settings.read_only,
134
+ "create_graph": settings.create_graph,
135
+ }
136
+ # 0 means "no timeout" -- don't pass it (leave backend at its own default).
137
+ if settings.query_timeout_s:
138
+ connect_params["query_timeout_s"] = settings.query_timeout_s
139
+ pool = CypherGraphDBPool(
140
+ backend=settings.backend,
141
+ connect_params=connect_params,
142
+ pool_size=pool_size,
143
+ auto_connect=True,
144
+ )
145
+ logger.debug(
146
+ "GraphDB pool created: backend={}, graph={}, pool_size={}",
147
+ settings.backend,
148
+ settings.graph,
149
+ pool_size,
150
+ )
151
+ return pool
@@ -0,0 +1,53 @@
1
+ """FastAPI dependencies for graph database access.
2
+
3
+ Provides two dependency patterns:
4
+
5
+ 1. **Ambient session** (recommended) -- use ``graphdb_router()`` from
6
+ sonnet-server's ``api.dependencies`` module, or add
7
+ ``get_graphdb_session`` as a router-level dependency. Services then
8
+ call ``get_current_graphdb()`` without parameters.
9
+
10
+ 2. **Explicit parameter** -- use ``Depends(get_graphdb)`` on individual
11
+ endpoints to receive a ``CypherGraphDB`` instance directly.
12
+
13
+ Example::
14
+
15
+ from fastapi import APIRouter, Depends
16
+ from sonnet_graph.dependencies import get_graphdb_session
17
+ from sonnet_graph.context import get_current_graphdb
18
+
19
+ router = APIRouter(dependencies=[Depends(get_graphdb_session)])
20
+
21
+ @router.get("/nodes")
22
+ def list_nodes():
23
+ db = get_current_graphdb()
24
+ return db.execute("MATCH (n) RETURN n LIMIT 10")
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from collections.abc import AsyncIterator, Iterator
30
+
31
+ from cypher_graphdb import CypherGraphDB
32
+
33
+ from sonnet_graph.context import borrow_graphdb
34
+
35
+
36
+ async def get_graphdb_session() -> AsyncIterator[None]:
37
+ """Router-level dependency establishing an ambient graphdb session.
38
+
39
+ Sets the ContextVar so services can call ``get_current_graphdb()``
40
+ without receiving CypherGraphDB as a parameter.
41
+ """
42
+ with borrow_graphdb():
43
+ yield
44
+
45
+
46
+ def get_graphdb() -> Iterator[CypherGraphDB]:
47
+ """Endpoint-level dependency yielding a CypherGraphDB instance explicitly.
48
+
49
+ Prefer the ambient session pattern (``get_graphdb_session`` + ``get_current_graphdb``)
50
+ for new code.
51
+ """
52
+ with borrow_graphdb() as db:
53
+ yield db
@@ -0,0 +1,72 @@
1
+ """GraphDB infrastructure extension for sonnet-server applications.
2
+
3
+ Manages the CypherGraphDB connection pool lifecycle (startup/shutdown)
4
+ and optionally registers readiness checks.
5
+
6
+ Usage in app.py::
7
+
8
+ from sonnet_graph import GraphDBExtension
9
+
10
+ _extension_registry = create_extension_registry(
11
+ GraphDBExtension(), # required -- raises if CGDB_* missing
12
+ GraphDBExtension(optional=True), # optional -- skips if unconfigured
13
+ )
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from loguru import logger
19
+
20
+ from sonnet_server.extensions import Extension
21
+ from sonnet_server.settings import Settings
22
+
23
+
24
+ class GraphDBExtension(Extension):
25
+ """Infrastructure extension for the CypherGraphDB connection pool.
26
+
27
+ Starts unconditionally (no profile). Initialises the pool on startup
28
+ and disposes it on shutdown.
29
+
30
+ Args:
31
+ optional: If True, silently skip when CGDB_* settings are absent.
32
+ If False (default), raise RuntimeError on missing settings.
33
+ pool_size: Maximum number of pooled connections (default: 3).
34
+ """
35
+
36
+ def __init__(self, *, optional: bool = False, pool_size: int = 3) -> None:
37
+ self._optional = optional
38
+ self._pool_size = pool_size
39
+ self._active = False
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ return "graphdb"
44
+
45
+ async def on_startup(self, settings: Settings) -> None:
46
+ from sonnet_graph.context import graphdb_configured, init_graphdb_pool
47
+
48
+ if not graphdb_configured():
49
+ if self._optional:
50
+ logger.info("GraphDBExtension: CGDB_* settings not configured, skipping (optional)")
51
+ return
52
+ raise RuntimeError(
53
+ "GraphDBExtension: graph database not configured. "
54
+ "Set CGDB_BACKEND, CGDB_CINFO, and CGDB_GRAPH environment variables."
55
+ )
56
+
57
+ init_graphdb_pool(pool_size=self._pool_size)
58
+ self._active = True
59
+
60
+ # Import check modules to trigger @readiness_check decorator registration.
61
+ import sonnet_graph.checks.graphdb_health_check # noqa: F401
62
+ import sonnet_graph.checks.graphdb_initialization # noqa: F401
63
+
64
+ logger.info("GraphDBExtension: pool initialized, readiness checks registered")
65
+
66
+ async def on_shutdown(self) -> None:
67
+ if not self._active:
68
+ return
69
+ from sonnet_graph.context import dispose_graphdb_pool
70
+
71
+ dispose_graphdb_pool()
72
+ self._active = False
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: sonnet-graph
3
+ Version: 0.1.0
4
+ Summary: Graph database integration (cypher-graphdb) for sonnet-server applications
5
+ Author-email: Wolfgang Miller <wolfgang.miller@petrarca-labs.com>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Python: <4.0,>=3.14
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: sonnet-server>=0.1.11
14
+ Requires-Dist: cypher-graphdb>=0.2.7
15
+ Requires-Dist: loguru>=0.7.3
16
+ Provides-Extra: dev
17
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
18
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
19
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
20
+
21
+ # sonnet-graph
22
+
23
+ Graph database integration (cypher-graphdb) for sonnet-server applications.
24
+
25
+ Provides reusable infrastructure for sonnet-server apps that need a
26
+ cypher-graphdb connection pool:
27
+
28
+ - **`GraphDBExtension`** -- sonnet-server extension managing pool lifecycle
29
+ (startup/shutdown), with optional mode for apps where graph is not required.
30
+ - **Pool context** -- `borrow_graphdb()` context manager and `get_current_graphdb()`
31
+ ambient session accessor via `ContextVar`.
32
+ - **Readiness checks** -- `GraphdbStage` with initialization and health checks
33
+ for the sonnet-server readiness pipeline.
34
+ - **FastAPI dependencies** -- `get_graphdb_session()` async generator for
35
+ router-level ambient sessions.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install sonnet-graph
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```python
46
+ from sonnet_graph import GraphDBExtension, borrow_graphdb, get_current_graphdb
47
+
48
+ # In app.py -- register the extension
49
+ _extension_registry = create_extension_registry(
50
+ DatabaseExtension(),
51
+ GraphDBExtension(), # required (raises on missing CGDB_* settings)
52
+ GraphDBExtension(optional=True), # or optional (skips if unconfigured)
53
+ ...
54
+ )
55
+
56
+ # In service code -- borrow a connection
57
+ with borrow_graphdb() as db:
58
+ db.execute("MATCH (n) RETURN count(n)")
59
+
60
+ # In service code -- ambient session (within borrow_graphdb scope)
61
+ db = get_current_graphdb()
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ Graph connection is configured via cypher-graphdb's own environment variables
67
+ (not duplicated in consumer settings):
68
+
69
+ | Variable | Description |
70
+ |---|---|
71
+ | `CGDB_BACKEND` | Backend type: `age` or `memgraph` |
72
+ | `CGDB_CINFO` | Connection string / DSN |
73
+ | `CGDB_GRAPH` | Graph name |
74
+ | `CGDB_READ_ONLY` | Read-only mode (default: `false`) |
75
+ | `CGDB_CREATE_GRAPH_IF_NOT_EXISTS` | Auto-create graph, AGE only (default: `false`) |
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/sonnet_graph/__init__.py
4
+ src/sonnet_graph/context.py
5
+ src/sonnet_graph/dependencies.py
6
+ src/sonnet_graph/extension.py
7
+ src/sonnet_graph.egg-info/PKG-INFO
8
+ src/sonnet_graph.egg-info/SOURCES.txt
9
+ src/sonnet_graph.egg-info/dependency_links.txt
10
+ src/sonnet_graph.egg-info/requires.txt
11
+ src/sonnet_graph.egg-info/top_level.txt
12
+ src/sonnet_graph/checks/__init__.py
13
+ src/sonnet_graph/checks/graphdb_health_check.py
14
+ src/sonnet_graph/checks/graphdb_initialization.py
15
+ src/sonnet_graph/checks/stages.py
16
+ tests/test_checks.py
17
+ tests/test_context.py
18
+ tests/test_extension.py
@@ -0,0 +1,8 @@
1
+ sonnet-server>=0.1.11
2
+ cypher-graphdb>=0.2.7
3
+ loguru>=0.7.3
4
+
5
+ [dev]
6
+ ruff>=0.3.0
7
+ pytest>=7.0.0
8
+ pytest-asyncio>=0.23.0
@@ -0,0 +1 @@
1
+ sonnet_graph
@@ -0,0 +1,123 @@
1
+ """Unit tests for sonnet_graph readiness checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # GraphDBInitializationCheck
9
+ # ---------------------------------------------------------------------------
10
+
11
+
12
+ class TestGraphDBInitializationCheck:
13
+ def test_success_when_already_initialized(self):
14
+ from sonnet_graph.checks.graphdb_initialization import GraphDBInitializationCheck
15
+
16
+ check = GraphDBInitializationCheck()
17
+ with patch("sonnet_graph.checks.graphdb_initialization.is_pool_initialized", return_value=True):
18
+ result = check._execute()
19
+ assert result.status == "success"
20
+
21
+ def test_success_when_init_succeeds(self):
22
+ from sonnet_graph.checks.graphdb_initialization import GraphDBInitializationCheck
23
+
24
+ check = GraphDBInitializationCheck()
25
+ with (
26
+ patch("sonnet_graph.checks.graphdb_initialization.is_pool_initialized", return_value=False),
27
+ patch("sonnet_graph.checks.graphdb_initialization.init_graphdb_pool"),
28
+ ):
29
+ result = check._execute()
30
+ assert result.status == "success"
31
+
32
+ def test_failed_when_init_raises_runtime_error(self):
33
+ from sonnet_graph.checks.graphdb_initialization import GraphDBInitializationCheck
34
+
35
+ check = GraphDBInitializationCheck()
36
+ with (
37
+ patch("sonnet_graph.checks.graphdb_initialization.is_pool_initialized", return_value=False),
38
+ patch("sonnet_graph.checks.graphdb_initialization.init_graphdb_pool", side_effect=RuntimeError("no backend")),
39
+ ):
40
+ result = check._execute()
41
+ assert result.status == "failed"
42
+ assert "no backend" in result.message
43
+
44
+ def test_failed_when_init_raises_os_error(self):
45
+ from sonnet_graph.checks.graphdb_initialization import GraphDBInitializationCheck
46
+
47
+ check = GraphDBInitializationCheck()
48
+ with (
49
+ patch("sonnet_graph.checks.graphdb_initialization.is_pool_initialized", return_value=False),
50
+ patch("sonnet_graph.checks.graphdb_initialization.init_graphdb_pool", side_effect=OSError("conn refused")),
51
+ ):
52
+ result = check._execute()
53
+ assert result.status == "failed"
54
+
55
+ def test_failed_when_init_raises_timeout_error(self):
56
+ from sonnet_graph.checks.graphdb_initialization import GraphDBInitializationCheck
57
+
58
+ check = GraphDBInitializationCheck()
59
+ with (
60
+ patch("sonnet_graph.checks.graphdb_initialization.is_pool_initialized", return_value=False),
61
+ patch("sonnet_graph.checks.graphdb_initialization.init_graphdb_pool", side_effect=TimeoutError("timed out")),
62
+ ):
63
+ result = check._execute()
64
+ assert result.status == "failed"
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # GraphDBHealthCheck
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ class TestGraphDBHealthCheck:
73
+ def test_success_when_connection_healthy(self):
74
+ from sonnet_graph.checks.graphdb_health_check import GraphDBHealthCheck
75
+
76
+ check = GraphDBHealthCheck()
77
+ mock_cdb = MagicMock()
78
+ mock_cdb.check_connection.return_value = True
79
+ mock_cdb.id = "test-id"
80
+ mock_cdb.graph_name = "test-graph"
81
+
82
+ with patch("sonnet_graph.checks.graphdb_health_check.borrow_graphdb") as mock_borrow:
83
+ mock_borrow.return_value.__enter__ = MagicMock(return_value=mock_cdb)
84
+ mock_borrow.return_value.__exit__ = MagicMock(return_value=False)
85
+ result = check._execute()
86
+
87
+ assert result.status == "success"
88
+ assert "healthy" in result.message
89
+ assert result.details.get("id") == "test-id"
90
+ assert result.details.get("graph") == "test-graph"
91
+
92
+ def test_failed_when_check_returns_false(self):
93
+ from sonnet_graph.checks.graphdb_health_check import GraphDBHealthCheck
94
+
95
+ check = GraphDBHealthCheck()
96
+ mock_cdb = MagicMock()
97
+ mock_cdb.check_connection.return_value = False
98
+
99
+ with patch("sonnet_graph.checks.graphdb_health_check.borrow_graphdb") as mock_borrow:
100
+ mock_borrow.return_value.__enter__ = MagicMock(return_value=mock_cdb)
101
+ mock_borrow.return_value.__exit__ = MagicMock(return_value=False)
102
+ result = check._execute()
103
+
104
+ assert result.status == "failed"
105
+
106
+ def test_failed_when_borrow_raises_runtime_error(self):
107
+ from sonnet_graph.checks.graphdb_health_check import GraphDBHealthCheck
108
+
109
+ check = GraphDBHealthCheck()
110
+ with patch("sonnet_graph.checks.graphdb_health_check.borrow_graphdb", side_effect=RuntimeError("pool exhausted")):
111
+ result = check._execute()
112
+
113
+ assert result.status == "failed"
114
+ assert "pool exhausted" in result.message
115
+
116
+ def test_failed_when_borrow_raises_timeout_error(self):
117
+ from sonnet_graph.checks.graphdb_health_check import GraphDBHealthCheck
118
+
119
+ check = GraphDBHealthCheck()
120
+ with patch("sonnet_graph.checks.graphdb_health_check.borrow_graphdb", side_effect=TimeoutError("timed out")):
121
+ result = check._execute()
122
+
123
+ assert result.status == "failed"
@@ -0,0 +1,193 @@
1
+ """Unit tests for sonnet_graph.context -- pool lifecycle and ambient session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ import sonnet_graph.context as _ctx
10
+ from sonnet_graph.context import (
11
+ borrow_graphdb,
12
+ dispose_graphdb_pool,
13
+ get_current_graphdb,
14
+ graphdb_configured,
15
+ init_graphdb_pool,
16
+ is_pool_initialized,
17
+ )
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # graphdb_configured
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ class TestGraphdbConfigured:
25
+ def test_returns_false_when_no_backend(self):
26
+ settings = MagicMock(backend=None, cinfo="bolt://localhost", graph="test")
27
+ assert graphdb_configured(settings) is False
28
+
29
+ def test_returns_false_when_no_cinfo(self):
30
+ settings = MagicMock(backend="age", cinfo=None, graph="test")
31
+ assert graphdb_configured(settings) is False
32
+
33
+ def test_returns_false_when_no_graph(self):
34
+ settings = MagicMock(backend="age", cinfo="bolt://localhost", graph=None)
35
+ assert graphdb_configured(settings) is False
36
+
37
+ def test_returns_false_when_all_empty(self):
38
+ settings = MagicMock(backend="", cinfo="", graph="")
39
+ assert graphdb_configured(settings) is False
40
+
41
+ def test_returns_true_when_all_set(self):
42
+ settings = MagicMock(backend="age", cinfo="host=localhost", graph="test")
43
+ assert graphdb_configured(settings) is True
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # get_current_graphdb
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class TestGetCurrentGraphdb:
52
+ def test_raises_outside_borrow_context(self):
53
+ with pytest.raises(RuntimeError, match="No active CypherGraphDB session"):
54
+ get_current_graphdb()
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # init / dispose / is_pool_initialized
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ class TestPoolLifecycle:
63
+ @patch("sonnet_graph.context.graphdb_configured", return_value=False)
64
+ def test_init_returns_false_when_not_configured(self, _mock):
65
+ assert init_graphdb_pool() is False
66
+ assert is_pool_initialized() is False
67
+
68
+ @patch("sonnet_graph.context.get_cypher_settings")
69
+ @patch("sonnet_graph.context.CypherGraphDBPool")
70
+ def test_init_creates_pool_when_configured(self, mock_pool_cls, mock_settings):
71
+ mock_settings.return_value = MagicMock(
72
+ backend="age",
73
+ cinfo="host=localhost",
74
+ graph="test",
75
+ read_only=False,
76
+ create_graph=True,
77
+ query_timeout_s=None, # None = no timeout, omitted from connect_params
78
+ )
79
+ mock_pool_cls.return_value = MagicMock()
80
+
81
+ result = init_graphdb_pool(pool_size=5)
82
+
83
+ assert result is True
84
+ assert is_pool_initialized() is True
85
+ mock_pool_cls.assert_called_once_with(
86
+ backend="age",
87
+ connect_params={
88
+ "cinfo": "host=localhost",
89
+ "graph_name": "test",
90
+ "read_only": False,
91
+ "create_graph": True,
92
+ },
93
+ pool_size=5,
94
+ auto_connect=True,
95
+ )
96
+
97
+ @patch("sonnet_graph.context.get_cypher_settings")
98
+ @patch("sonnet_graph.context.CypherGraphDBPool")
99
+ def test_init_is_idempotent(self, mock_pool_cls, mock_settings):
100
+ """Calling init_graphdb_pool twice must not create a second pool."""
101
+ mock_settings.return_value = MagicMock(
102
+ backend="age",
103
+ cinfo="host=localhost",
104
+ graph="test",
105
+ read_only=False,
106
+ create_graph=False,
107
+ query_timeout_s=None,
108
+ )
109
+ mock_pool_cls.return_value = MagicMock()
110
+
111
+ init_graphdb_pool()
112
+ init_graphdb_pool()
113
+
114
+ mock_pool_cls.assert_called_once()
115
+
116
+ @patch("sonnet_graph.context.get_cypher_settings")
117
+ @patch("sonnet_graph.context.CypherGraphDBPool")
118
+ def test_dispose_closes_pool(self, mock_pool_cls, mock_settings):
119
+ mock_settings.return_value = MagicMock(
120
+ backend="age",
121
+ cinfo="host=localhost",
122
+ graph="test",
123
+ read_only=False,
124
+ create_graph=False,
125
+ query_timeout_s=None,
126
+ )
127
+ mock_pool = MagicMock()
128
+ mock_pool_cls.return_value = mock_pool
129
+
130
+ init_graphdb_pool()
131
+ assert is_pool_initialized() is True
132
+
133
+ dispose_graphdb_pool()
134
+ assert is_pool_initialized() is False
135
+ mock_pool.close.assert_called_once()
136
+
137
+ def test_dispose_safe_when_no_pool(self):
138
+ assert _ctx._pool_instance is None
139
+ dispose_graphdb_pool() # must not raise
140
+
141
+ @patch("sonnet_graph.context.get_cypher_settings")
142
+ @patch("sonnet_graph.context.CypherGraphDBPool")
143
+ def test_pool_size_reset_after_dispose(self, mock_pool_cls, mock_settings):
144
+ """pool_size from first init must not bleed into a second init after dispose."""
145
+ mock_settings.return_value = MagicMock(
146
+ backend="age",
147
+ cinfo="host=localhost",
148
+ graph="test",
149
+ read_only=False,
150
+ create_graph=False,
151
+ query_timeout_s=None,
152
+ )
153
+ mock_pool_cls.return_value = MagicMock()
154
+
155
+ init_graphdb_pool(pool_size=10)
156
+ dispose_graphdb_pool()
157
+ init_graphdb_pool(pool_size=3)
158
+
159
+ calls = mock_pool_cls.call_args_list
160
+ assert calls[0][1]["pool_size"] == 10
161
+ assert calls[1][1]["pool_size"] == 3
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # borrow_graphdb
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ class TestBorrowGraphdb:
170
+ @patch("sonnet_graph.context.get_cypher_settings")
171
+ @patch("sonnet_graph.context.CypherGraphDBPool")
172
+ def test_sets_and_resets_contextvar(self, mock_pool_cls, mock_settings):
173
+ mock_settings.return_value = MagicMock(
174
+ backend="age",
175
+ cinfo="host=localhost",
176
+ graph="test",
177
+ read_only=False,
178
+ create_graph=False,
179
+ query_timeout_s=None,
180
+ )
181
+ mock_cdb = MagicMock()
182
+ mock_pool = MagicMock()
183
+ mock_pool.acquire.return_value.__enter__ = MagicMock(return_value=mock_cdb)
184
+ mock_pool.acquire.return_value.__exit__ = MagicMock(return_value=False)
185
+ mock_pool_cls.return_value = mock_pool
186
+
187
+ with borrow_graphdb() as db:
188
+ assert db is mock_cdb
189
+ assert get_current_graphdb() is mock_cdb
190
+
191
+ # After exit, ContextVar must be reset
192
+ with pytest.raises(RuntimeError, match="No active CypherGraphDB session"):
193
+ get_current_graphdb()
@@ -0,0 +1,66 @@
1
+ """Unit tests for sonnet_graph.extension -- GraphDBExtension."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from sonnet_graph.extension import GraphDBExtension
10
+
11
+
12
+ class TestGraphDBExtension:
13
+ def test_name(self):
14
+ assert GraphDBExtension().name == "graphdb"
15
+
16
+ def test_profile_is_none(self):
17
+ assert GraphDBExtension().profile is None
18
+
19
+ def test_default_not_optional(self):
20
+ assert GraphDBExtension()._optional is False
21
+
22
+ def test_optional_flag(self):
23
+ assert GraphDBExtension(optional=True)._optional is True
24
+
25
+ @patch("sonnet_graph.context.graphdb_configured", return_value=False)
26
+ async def test_raises_when_not_configured_and_required(self, _mock):
27
+ ext = GraphDBExtension(optional=False)
28
+ with pytest.raises(RuntimeError, match="graph database not configured"):
29
+ await ext.on_startup(MagicMock())
30
+
31
+ @patch("sonnet_graph.context.graphdb_configured", return_value=False)
32
+ async def test_skips_when_not_configured_and_optional(self, _mock):
33
+ ext = GraphDBExtension(optional=True)
34
+ await ext.on_startup(MagicMock())
35
+ assert ext._active is False
36
+
37
+ @patch("sonnet_graph.checks.graphdb_health_check", create=True)
38
+ @patch("sonnet_graph.checks.graphdb_initialization", create=True)
39
+ @patch("sonnet_graph.context.init_graphdb_pool")
40
+ @patch("sonnet_graph.context.graphdb_configured", return_value=True)
41
+ async def test_initializes_pool_when_configured(self, _configured, mock_init, _check1, _check2):
42
+ ext = GraphDBExtension(pool_size=5)
43
+ await ext.on_startup(MagicMock())
44
+ assert ext._active is True
45
+ mock_init.assert_called_once_with(pool_size=5)
46
+
47
+ async def test_shutdown_noop_when_not_active(self):
48
+ ext = GraphDBExtension()
49
+ await ext.on_shutdown() # must not raise
50
+
51
+ @patch("sonnet_graph.context.dispose_graphdb_pool")
52
+ async def test_shutdown_disposes_pool_when_active(self, mock_dispose):
53
+ ext = GraphDBExtension()
54
+ ext._active = True
55
+ await ext.on_shutdown()
56
+ mock_dispose.assert_called_once()
57
+ assert ext._active is False
58
+
59
+ @patch("sonnet_graph.context.dispose_graphdb_pool")
60
+ async def test_shutdown_is_idempotent(self, mock_dispose):
61
+ """Calling on_shutdown twice must not double-dispose."""
62
+ ext = GraphDBExtension()
63
+ ext._active = True
64
+ await ext.on_shutdown()
65
+ await ext.on_shutdown()
66
+ mock_dispose.assert_called_once()