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.
- sonnet_graph-0.1.0/PKG-INFO +75 -0
- sonnet_graph-0.1.0/README.md +55 -0
- sonnet_graph-0.1.0/pyproject.toml +50 -0
- sonnet_graph-0.1.0/setup.cfg +4 -0
- sonnet_graph-0.1.0/src/sonnet_graph/__init__.py +51 -0
- sonnet_graph-0.1.0/src/sonnet_graph/checks/__init__.py +11 -0
- sonnet_graph-0.1.0/src/sonnet_graph/checks/graphdb_health_check.py +35 -0
- sonnet_graph-0.1.0/src/sonnet_graph/checks/graphdb_initialization.py +28 -0
- sonnet_graph-0.1.0/src/sonnet_graph/checks/stages.py +13 -0
- sonnet_graph-0.1.0/src/sonnet_graph/context.py +151 -0
- sonnet_graph-0.1.0/src/sonnet_graph/dependencies.py +53 -0
- sonnet_graph-0.1.0/src/sonnet_graph/extension.py +72 -0
- sonnet_graph-0.1.0/src/sonnet_graph.egg-info/PKG-INFO +75 -0
- sonnet_graph-0.1.0/src/sonnet_graph.egg-info/SOURCES.txt +18 -0
- sonnet_graph-0.1.0/src/sonnet_graph.egg-info/dependency_links.txt +1 -0
- sonnet_graph-0.1.0/src/sonnet_graph.egg-info/requires.txt +8 -0
- sonnet_graph-0.1.0/src/sonnet_graph.egg-info/top_level.txt +1 -0
- sonnet_graph-0.1.0/tests/test_checks.py +123 -0
- sonnet_graph-0.1.0/tests/test_context.py +193 -0
- sonnet_graph-0.1.0/tests/test_extension.py +66 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|