toolgraph 0.0.1__py3-none-any.whl
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.
- toolgraph/__init__.py +3 -0
- toolgraph/artifact_safety.py +97 -0
- toolgraph/artifacts.py +122 -0
- toolgraph/cli.py +951 -0
- toolgraph/config.py +106 -0
- toolgraph/control_plan.py +494 -0
- toolgraph/crawler/__init__.py +0 -0
- toolgraph/crawler/client.py +120 -0
- toolgraph/crawler/crawl.py +103 -0
- toolgraph/crawler/transports.py +60 -0
- toolgraph/graph/__init__.py +0 -0
- toolgraph/graph/driver.py +275 -0
- toolgraph/graph/loader.py +280 -0
- toolgraph/graph/queries.py +984 -0
- toolgraph/graph/schema.py +166 -0
- toolgraph/graph/selector.py +423 -0
- toolgraph/graph/store.py +57 -0
- toolgraph/manifest/__init__.py +0 -0
- toolgraph/manifest/ingest.py +542 -0
- toolgraph/manifest/parser.py +26 -0
- toolgraph/models.py +192 -0
- toolgraph/policy_bundle.py +212 -0
- toolgraph/policy_review.py +97 -0
- toolgraph/preflight.py +77 -0
- toolgraph/quickstart/governance.yaml +28 -0
- toolgraph/quickstart/policy_gateway_server.py +31 -0
- toolgraph/quickstart/servers.yaml +5 -0
- toolgraph/redaction.py +82 -0
- toolgraph/review_candidates.py +579 -0
- toolgraph/server/__init__.py +0 -0
- toolgraph/server/app.py +306 -0
- toolgraph/uris.py +64 -0
- toolgraph/yaml_loader.py +68 -0
- toolgraph-0.0.1.dist-info/METADATA +674 -0
- toolgraph-0.0.1.dist-info/RECORD +38 -0
- toolgraph-0.0.1.dist-info/WHEEL +4 -0
- toolgraph-0.0.1.dist-info/entry_points.txt +2 -0
- toolgraph-0.0.1.dist-info/licenses/LICENSE +191 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Enumerate one MCP server's factual capabilities into a CrawlResult."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from toolgraph.crawler.transports import open_session
|
|
6
|
+
from toolgraph.models import CrawlResult, ResourceRecord, ServerSpec, ToolRecord
|
|
7
|
+
from toolgraph.redaction import endpoint_label
|
|
8
|
+
from toolgraph.uris import normalize_resource_uri
|
|
9
|
+
|
|
10
|
+
# A crawled server controls its own cursors, so an unbounded drain loop lets a
|
|
11
|
+
# buggy or malicious server that keeps returning a cursor pin the crawler and
|
|
12
|
+
# grow memory without limit. The per-server wall-clock timeout only applies on
|
|
13
|
+
# the CLI path (crawl_all), not to library callers of crawl_server. Real
|
|
14
|
+
# catalogs are a handful of pages; hitting this cap means the server is broken.
|
|
15
|
+
MAX_LIST_PAGES = 1000
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PaginationLimitError(RuntimeError):
|
|
19
|
+
"""A server kept returning continuation cursors past MAX_LIST_PAGES."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _annotation_fields(tool) -> dict:
|
|
23
|
+
"""Tool annotations as ToolRecord fields (ADR-0006).
|
|
24
|
+
|
|
25
|
+
Annotations are optional in the protocol; absent ones stay None so the
|
|
26
|
+
loader can clear node properties the server stopped sending (SET to null
|
|
27
|
+
removes the property — same reconciliation lifecycle as description).
|
|
28
|
+
"""
|
|
29
|
+
ann = getattr(tool, "annotations", None)
|
|
30
|
+
if ann is None:
|
|
31
|
+
return {}
|
|
32
|
+
return {
|
|
33
|
+
"read_only_hint": ann.readOnlyHint,
|
|
34
|
+
"destructive_hint": ann.destructiveHint,
|
|
35
|
+
"idempotent_hint": ann.idempotentHint,
|
|
36
|
+
"open_world_hint": ann.openWorldHint,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _list_all_tools(session) -> list[ToolRecord]:
|
|
41
|
+
"""Drain ``list_tools`` pages until ``nextCursor`` is exhausted.
|
|
42
|
+
|
|
43
|
+
Without pagination, a server with more than one page silently drops
|
|
44
|
+
tools on later pages — governance refs to them would then reject at
|
|
45
|
+
ingest and reachability analysis would miss the tools entirely (Codex
|
|
46
|
+
PR #1 review caught this).
|
|
47
|
+
"""
|
|
48
|
+
out: list[ToolRecord] = []
|
|
49
|
+
cursor: str | None = None
|
|
50
|
+
for _ in range(MAX_LIST_PAGES):
|
|
51
|
+
listed = await session.list_tools(cursor=cursor)
|
|
52
|
+
out.extend(
|
|
53
|
+
ToolRecord(
|
|
54
|
+
name=t.name,
|
|
55
|
+
description=t.description,
|
|
56
|
+
input_schema=t.inputSchema,
|
|
57
|
+
**_annotation_fields(t),
|
|
58
|
+
)
|
|
59
|
+
for t in listed.tools
|
|
60
|
+
)
|
|
61
|
+
cursor = getattr(listed, "nextCursor", None)
|
|
62
|
+
if cursor is None:
|
|
63
|
+
# Stop ONLY on None: an empty-string cursor is a valid opaque
|
|
64
|
+
# continuation token in MCP and the SDK distinguishes "" from
|
|
65
|
+
# None when sending the next request. `if not cursor` would
|
|
66
|
+
# silently truncate after page 1 against such a server.
|
|
67
|
+
return out
|
|
68
|
+
raise PaginationLimitError(
|
|
69
|
+
f"list_tools returned a continuation cursor on {MAX_LIST_PAGES}"
|
|
70
|
+
" consecutive pages — refusing to drain further"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _list_all_resources(session) -> list[ResourceRecord]:
|
|
75
|
+
"""Drain ``list_resources`` pages until ``nextCursor`` is exhausted."""
|
|
76
|
+
out: list[ResourceRecord] = []
|
|
77
|
+
cursor: str | None = None
|
|
78
|
+
for _ in range(MAX_LIST_PAGES):
|
|
79
|
+
listed = await session.list_resources(cursor=cursor)
|
|
80
|
+
out.extend(
|
|
81
|
+
ResourceRecord(
|
|
82
|
+
uri=normalize_resource_uri(str(r.uri)),
|
|
83
|
+
name=r.name,
|
|
84
|
+
mime_type=r.mimeType,
|
|
85
|
+
description=r.description,
|
|
86
|
+
)
|
|
87
|
+
for r in listed.resources
|
|
88
|
+
)
|
|
89
|
+
cursor = getattr(listed, "nextCursor", None)
|
|
90
|
+
if cursor is None:
|
|
91
|
+
# Stop ONLY on None: an empty-string cursor is a valid opaque
|
|
92
|
+
# continuation token in MCP and the SDK distinguishes "" from
|
|
93
|
+
# None when sending the next request. `if not cursor` would
|
|
94
|
+
# silently truncate after page 1 against such a server.
|
|
95
|
+
return out
|
|
96
|
+
raise PaginationLimitError(
|
|
97
|
+
f"list_resources returned a continuation cursor on {MAX_LIST_PAGES}"
|
|
98
|
+
" consecutive pages — refusing to drain further"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def crawl_server(spec: ServerSpec) -> CrawlResult:
|
|
103
|
+
"""Connect, initialize, and list tools/resources the server actually advertises."""
|
|
104
|
+
async with open_session(spec) as session:
|
|
105
|
+
init = await session.initialize()
|
|
106
|
+
caps = init.capabilities
|
|
107
|
+
|
|
108
|
+
tools = await _list_all_tools(session) if caps.tools is not None else []
|
|
109
|
+
resources = (
|
|
110
|
+
await _list_all_resources(session) if caps.resources is not None else []
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return CrawlResult(
|
|
114
|
+
server_name=spec.name or init.serverInfo.name,
|
|
115
|
+
server_version=init.serverInfo.version,
|
|
116
|
+
transport=spec.transport,
|
|
117
|
+
endpoint=endpoint_label(spec),
|
|
118
|
+
tools=tools,
|
|
119
|
+
resources=resources,
|
|
120
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Crawl many servers; each server fails independently (partial success)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from toolgraph.crawler.client import crawl_server
|
|
9
|
+
from toolgraph.models import CrawlResult, ServerSpec, ServersConfig
|
|
10
|
+
from toolgraph.redaction import endpoint_label, redact_text
|
|
11
|
+
from toolgraph.yaml_loader import load_yaml_mapping
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_servers_config(path: Path) -> list[ServerSpec]:
|
|
15
|
+
data = load_yaml_mapping(path)
|
|
16
|
+
return ServersConfig.model_validate(data).servers
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def spec_label(spec: ServerSpec) -> str:
|
|
20
|
+
"""Display label for a spec — NOT a graph identity (see fleet_keep_names)."""
|
|
21
|
+
return spec.name or endpoint_label(spec)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEFAULT_TIMEOUT = 30.0
|
|
25
|
+
DEFAULT_CONCURRENCY = 5
|
|
26
|
+
|
|
27
|
+
# A failure carries its ServerSpec, not a display label: fleet reconciliation
|
|
28
|
+
# must know whether the failed spec had a ``name`` override, and inferring
|
|
29
|
+
# that from a label is unsound (an unnamed spec's command can collide with
|
|
30
|
+
# another spec's name — Codex review of this branch caught it).
|
|
31
|
+
CrawlFailure = tuple[ServerSpec, str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _crawl_one(
|
|
35
|
+
spec: ServerSpec, timeout: float | None, sem: asyncio.Semaphore
|
|
36
|
+
) -> tuple[CrawlResult | None, CrawlFailure | None]:
|
|
37
|
+
async with sem:
|
|
38
|
+
try:
|
|
39
|
+
return await asyncio.wait_for(crawl_server(spec), timeout), None
|
|
40
|
+
except TimeoutError: # a hanging server must not block the crawl
|
|
41
|
+
return None, (spec, f"timeout after {timeout}s")
|
|
42
|
+
except Exception as exc: # noqa: BLE001 - one bad server must not abort the crawl
|
|
43
|
+
return None, (spec, f"{type(exc).__name__}: {redact_text(exc)}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def crawl_all_async(
|
|
47
|
+
specs: list[ServerSpec],
|
|
48
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
49
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
50
|
+
) -> tuple[list[CrawlResult], list[CrawlFailure]]:
|
|
51
|
+
sem = asyncio.Semaphore(concurrency)
|
|
52
|
+
outcomes = await asyncio.gather(*(_crawl_one(s, timeout, sem) for s in specs))
|
|
53
|
+
results = [r for r, _ in outcomes if r is not None]
|
|
54
|
+
failures = [f for _, f in outcomes if f is not None]
|
|
55
|
+
return results, failures
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def crawl_all(
|
|
59
|
+
specs: list[ServerSpec],
|
|
60
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
61
|
+
concurrency: int = DEFAULT_CONCURRENCY,
|
|
62
|
+
) -> tuple[list[CrawlResult], list[CrawlFailure]]:
|
|
63
|
+
return asyncio.run(crawl_all_async(specs, timeout, concurrency))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fleet_keep_names(
|
|
67
|
+
specs: list[ServerSpec],
|
|
68
|
+
results: list[CrawlResult],
|
|
69
|
+
failures: list[CrawlFailure],
|
|
70
|
+
) -> tuple[set[str], list[str]]:
|
|
71
|
+
"""Graph names that must survive fleet reconciliation, plus blockers.
|
|
72
|
+
|
|
73
|
+
A failed spec with a ``name`` override is protected by that name. A failed
|
|
74
|
+
spec WITHOUT one cannot be mapped to a graph identity (node names come
|
|
75
|
+
from ``serverInfo.name`` at crawl time, which a failed crawl never saw) —
|
|
76
|
+
its label is returned as a blocker and the caller must skip the prune:
|
|
77
|
+
a server that failed to crawl is not a server that was removed.
|
|
78
|
+
"""
|
|
79
|
+
keep = {s.name for s in specs if s.name} | {r.server_name for r in results}
|
|
80
|
+
blockers = [spec_label(spec) for spec, _ in failures if not spec.name]
|
|
81
|
+
return keep, blockers
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def duplicate_name_overrides(specs: list[ServerSpec]) -> set[str]:
|
|
85
|
+
"""``name:`` overrides claimed by more than one spec — a config error."""
|
|
86
|
+
names = [s.name for s in specs if s.name]
|
|
87
|
+
return {n for n in names if names.count(n) > 1}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def duplicate_identities(
|
|
91
|
+
results: list[CrawlResult], failures: list[CrawlFailure]
|
|
92
|
+
) -> set[str]:
|
|
93
|
+
"""Graph names claimed by more than one server this pass.
|
|
94
|
+
|
|
95
|
+
Two specs resolving to the same ``server_name`` would silently MERGE into
|
|
96
|
+
one MCPServer node and last-load-wins reconcile each other's tools away —
|
|
97
|
+
and give fleet reconciliation an ambiguous identity. The caller must
|
|
98
|
+
reject the pass before any graph mutation. Failed named specs count too:
|
|
99
|
+
an unnamed server self-reporting a failed spec's name is the same
|
|
100
|
+
collision, one crawl later.
|
|
101
|
+
"""
|
|
102
|
+
names = [r.server_name for r in results] + [s.name for s, _ in failures if s.name]
|
|
103
|
+
return {n for n in names if names.count(n) > 1}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Open an MCP ClientSession over the right transport for a ServerSpec.
|
|
2
|
+
|
|
3
|
+
Normalizes the transport differences the SDK exposes: stdio yields a 2-tuple
|
|
4
|
+
``(read, write)`` while streamable-http yields a 3-tuple
|
|
5
|
+
``(read, write, get_session_id)``. SSE is supported only as a deprecated fallback.
|
|
6
|
+
|
|
7
|
+
Uses the preferred ``streamable_http_client``; since it takes headers via a
|
|
8
|
+
custom httpx client rather than a ``headers=`` kwarg, we build one with the
|
|
9
|
+
SDK's ``create_mcp_http_client`` when auth headers are supplied.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
16
|
+
|
|
17
|
+
from mcp import ClientSession, StdioServerParameters
|
|
18
|
+
from mcp.client.sse import sse_client
|
|
19
|
+
from mcp.client.stdio import get_default_environment, stdio_client
|
|
20
|
+
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
|
|
21
|
+
|
|
22
|
+
from toolgraph.models import ServerSpec
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@asynccontextmanager
|
|
26
|
+
async def open_session(spec: ServerSpec) -> AsyncIterator[ClientSession]:
|
|
27
|
+
async with AsyncExitStack() as stack:
|
|
28
|
+
if spec.transport == "stdio":
|
|
29
|
+
if not spec.command:
|
|
30
|
+
raise ValueError(f"stdio server {spec.name!r} needs a 'command'")
|
|
31
|
+
# Merge over the SDK's safe default env so PATH etc. survive when the
|
|
32
|
+
# user supplies only secrets (e.g. API keys).
|
|
33
|
+
env = {**get_default_environment(), **spec.env} if spec.env else None
|
|
34
|
+
params = StdioServerParameters(command=spec.command, args=spec.args, env=env)
|
|
35
|
+
read, write = await stack.enter_async_context(stdio_client(params))
|
|
36
|
+
|
|
37
|
+
elif spec.transport == "streamable-http":
|
|
38
|
+
if not spec.url:
|
|
39
|
+
raise ValueError(f"streamable-http server {spec.name!r} needs a 'url'")
|
|
40
|
+
http_client = None
|
|
41
|
+
if spec.headers:
|
|
42
|
+
http_client = await stack.enter_async_context(
|
|
43
|
+
create_mcp_http_client(headers=spec.headers)
|
|
44
|
+
)
|
|
45
|
+
read, write, _get_session_id = await stack.enter_async_context(
|
|
46
|
+
streamable_http_client(spec.url, http_client=http_client)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
elif spec.transport == "sse":
|
|
50
|
+
if not spec.url:
|
|
51
|
+
raise ValueError(f"sse server {spec.name!r} needs a 'url'")
|
|
52
|
+
read, write = await stack.enter_async_context(
|
|
53
|
+
sse_client(spec.url, headers=spec.headers or None)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
else: # pragma: no cover - guarded by the Literal type
|
|
57
|
+
raise ValueError(f"unknown transport {spec.transport!r}")
|
|
58
|
+
|
|
59
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
60
|
+
yield session
|
|
File without changes
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Backend lifecycle and transaction/session compatibility helpers.
|
|
2
|
+
|
|
3
|
+
The public module seam is intentionally small while graph services migrate to
|
|
4
|
+
domain-level ports. Neo4j remains the compatibility default; Ladybug is an
|
|
5
|
+
explicit embedded backend and never runs in the gateway call path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from neo4j import Driver, GraphDatabase, Session
|
|
18
|
+
from neo4j.exceptions import (
|
|
19
|
+
ConnectionAcquisitionTimeoutError,
|
|
20
|
+
DatabaseUnavailable,
|
|
21
|
+
ServiceUnavailable,
|
|
22
|
+
SessionExpired,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from toolgraph import config
|
|
26
|
+
|
|
27
|
+
_driver: Driver | None = None
|
|
28
|
+
_ladybug_database: Any | None = None
|
|
29
|
+
# Serializes lazy backend construction. The streamable-HTTP MCP server can run
|
|
30
|
+
# two first-requests concurrently; an unguarded check-then-set would leak one
|
|
31
|
+
# Neo4j driver, or open a second Ladybug Database that trips the file lock and
|
|
32
|
+
# misreports a spurious BACKEND_LOCKED.
|
|
33
|
+
_backend_lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BackendConfigurationError(RuntimeError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BackendUnavailableError(RuntimeError):
|
|
41
|
+
"""A known transient backend outage, raised at the backend boundary.
|
|
42
|
+
|
|
43
|
+
Callers above the driver classify on this type alone — the native
|
|
44
|
+
neo4j/Ladybug taxonomy stays private to this module.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BackendLockedError(BackendUnavailableError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_NATIVE_UNAVAILABLE_ERRORS = (
|
|
53
|
+
ServiceUnavailable,
|
|
54
|
+
SessionExpired,
|
|
55
|
+
ConnectionAcquisitionTimeoutError,
|
|
56
|
+
DatabaseUnavailable,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@contextmanager
|
|
61
|
+
def _translating_backend_errors() -> Iterator[None]:
|
|
62
|
+
"""Re-raise the narrow native outage taxonomy as ``BackendUnavailableError``.
|
|
63
|
+
|
|
64
|
+
Deliberately narrower than "any backend exception": invalid configuration,
|
|
65
|
+
authentication, and Cypher/client errors must stay loud contract errors.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
yield
|
|
69
|
+
except _NATIVE_UNAVAILABLE_ERRORS as exc:
|
|
70
|
+
raise BackendUnavailableError(str(exc)) from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_backend_unavailable(exc: BaseException) -> bool:
|
|
74
|
+
"""Whether *exc* was caused by a backend outage typed at the driver seam.
|
|
75
|
+
|
|
76
|
+
FastMCP wraps tool exceptions, so walk the chain. ``__context__`` is
|
|
77
|
+
followed only when Python did not suppress it: a deliberate
|
|
78
|
+
``raise ... from None`` while handling an outage is the author saying the
|
|
79
|
+
outer error is the real one, and must not be reclassified as retryable.
|
|
80
|
+
"""
|
|
81
|
+
current: BaseException | None = exc
|
|
82
|
+
seen: set[int] = set()
|
|
83
|
+
while current is not None and id(current) not in seen:
|
|
84
|
+
if isinstance(current, BackendUnavailableError):
|
|
85
|
+
return True
|
|
86
|
+
seen.add(id(current))
|
|
87
|
+
if current.__cause__ is not None:
|
|
88
|
+
current = current.__cause__
|
|
89
|
+
elif not current.__suppress_context__:
|
|
90
|
+
current = current.__context__
|
|
91
|
+
else:
|
|
92
|
+
current = None
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _LadybugResult:
|
|
97
|
+
def __init__(self, result: Any) -> None:
|
|
98
|
+
self._rows: list[dict[str, Any]] = result.rows_as_dict().get_all()
|
|
99
|
+
|
|
100
|
+
def __iter__(self):
|
|
101
|
+
return iter(self._rows)
|
|
102
|
+
|
|
103
|
+
def single(self) -> dict[str, Any] | None:
|
|
104
|
+
if not self._rows:
|
|
105
|
+
return None
|
|
106
|
+
if len(self._rows) > 1:
|
|
107
|
+
raise RuntimeError(f"expected at most one row, received {len(self._rows)}")
|
|
108
|
+
return self._rows[0]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
112
|
+
_DATETIME_CALL = re.compile(r"(?<![A-Za-z0-9_])datetime\s*\(\s*\)")
|
|
113
|
+
_TYPE_CALL = re.compile(rf"(?<![A-Za-z0-9_])type\s*\(\s*({_IDENTIFIER})\s*\)")
|
|
114
|
+
_LABELS_HEAD = re.compile(
|
|
115
|
+
rf"(?<![A-Za-z0-9_])labels\s*\(\s*({_IDENTIFIER})\s*\)\s*\[\s*0\s*\]"
|
|
116
|
+
)
|
|
117
|
+
_UNSUPPORTED_NEO4J_EXPRESSION = re.compile(
|
|
118
|
+
r"(?<![A-Za-z0-9_])(?:datetime|type|labels)\s*\("
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _ladybug_query(query: str) -> str:
|
|
123
|
+
"""Translate only the explicitly supported Neo4j expression shapes.
|
|
124
|
+
|
|
125
|
+
This is intentionally a narrow dialect contract, not a Cypher parser.
|
|
126
|
+
Identifier boundaries prevent incidental substrings from being rewritten;
|
|
127
|
+
a remaining Neo4j-only function fails fast instead of reaching Ladybug as
|
|
128
|
+
a subtly different query.
|
|
129
|
+
"""
|
|
130
|
+
translated = _DATETIME_CALL.sub("current_timestamp()", query)
|
|
131
|
+
translated = _TYPE_CALL.sub(r"label(\1)", translated)
|
|
132
|
+
translated = _LABELS_HEAD.sub(r"label(\1)", translated)
|
|
133
|
+
for field in (
|
|
134
|
+
"read_only_hint",
|
|
135
|
+
"destructive_hint",
|
|
136
|
+
"idempotent_hint",
|
|
137
|
+
"open_world_hint",
|
|
138
|
+
):
|
|
139
|
+
translated = re.sub(
|
|
140
|
+
rf"\btool\.{field}\s*=\s*t\.{field}\b",
|
|
141
|
+
f"tool.{field}=CAST(t.{field} AS BOOLEAN)",
|
|
142
|
+
translated,
|
|
143
|
+
)
|
|
144
|
+
if match := _UNSUPPORTED_NEO4J_EXPRESSION.search(translated):
|
|
145
|
+
raise BackendConfigurationError(
|
|
146
|
+
f"unsupported Neo4j expression for Ladybug near {match.group(0)!r}"
|
|
147
|
+
)
|
|
148
|
+
return translated
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class LadybugSession:
|
|
152
|
+
"""Minimal transaction facade used during the domain-port migration."""
|
|
153
|
+
|
|
154
|
+
def __init__(self, database: Any) -> None:
|
|
155
|
+
import ladybug as lb
|
|
156
|
+
|
|
157
|
+
self._connection = lb.Connection(database)
|
|
158
|
+
|
|
159
|
+
def run(self, query: str, **parameters: Any) -> _LadybugResult:
|
|
160
|
+
result = self._connection.execute(_ladybug_query(query), parameters)
|
|
161
|
+
return _LadybugResult(result)
|
|
162
|
+
|
|
163
|
+
def execute_write(self, fn, *args):
|
|
164
|
+
self._connection.execute("BEGIN TRANSACTION")
|
|
165
|
+
try:
|
|
166
|
+
value = fn(self, *args)
|
|
167
|
+
self._connection.execute("COMMIT")
|
|
168
|
+
return value
|
|
169
|
+
except BaseException:
|
|
170
|
+
# Ladybug aborts the explicit transaction immediately on some
|
|
171
|
+
# binder/runtime errors; in that case there is nothing left to
|
|
172
|
+
# roll back. Preserve the original exception.
|
|
173
|
+
try:
|
|
174
|
+
self._connection.execute("ROLLBACK")
|
|
175
|
+
except RuntimeError as rollback_error:
|
|
176
|
+
if "No active transaction" not in str(rollback_error):
|
|
177
|
+
raise
|
|
178
|
+
raise
|
|
179
|
+
|
|
180
|
+
def close(self) -> None:
|
|
181
|
+
self._connection.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def backend_name() -> str:
|
|
185
|
+
backend = config.settings.backend.lower().strip()
|
|
186
|
+
if backend not in {"neo4j", "ladybug"}:
|
|
187
|
+
raise BackendConfigurationError(
|
|
188
|
+
f"unknown TOOLGRAPH_BACKEND {backend!r}; expected 'neo4j' or 'ladybug'"
|
|
189
|
+
)
|
|
190
|
+
return backend
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_driver() -> Driver:
|
|
194
|
+
"""Return the process-wide Neo4j driver."""
|
|
195
|
+
if backend_name() != "neo4j":
|
|
196
|
+
raise BackendConfigurationError("Neo4j driver requested while backend=ladybug")
|
|
197
|
+
global _driver
|
|
198
|
+
if _driver is None:
|
|
199
|
+
with _backend_lock:
|
|
200
|
+
if _driver is None:
|
|
201
|
+
_driver = GraphDatabase.driver(
|
|
202
|
+
config.settings.neo4j_uri,
|
|
203
|
+
auth=(config.settings.neo4j_user, config.settings.neo4j_password),
|
|
204
|
+
)
|
|
205
|
+
return _driver
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _get_ladybug_database() -> Any:
|
|
209
|
+
global _ladybug_database
|
|
210
|
+
if _ladybug_database is not None:
|
|
211
|
+
return _ladybug_database
|
|
212
|
+
try:
|
|
213
|
+
import ladybug as lb
|
|
214
|
+
except ImportError as exc: # pragma: no cover - environment dependent
|
|
215
|
+
raise BackendConfigurationError(
|
|
216
|
+
"backend=ladybug requires the 'ladybug' extra: "
|
|
217
|
+
"install with `uv sync --extra ladybug` or `pip install toolgraph[ladybug]`"
|
|
218
|
+
) from exc
|
|
219
|
+
with _backend_lock:
|
|
220
|
+
if _ladybug_database is not None:
|
|
221
|
+
return _ladybug_database
|
|
222
|
+
path = Path(config.settings.db_path)
|
|
223
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
try:
|
|
225
|
+
_ladybug_database = lb.Database(path)
|
|
226
|
+
except RuntimeError as exc:
|
|
227
|
+
message = str(exc)
|
|
228
|
+
if "lock" in message.lower() or "another process" in message.lower():
|
|
229
|
+
raise BackendLockedError(
|
|
230
|
+
f"BACKEND_LOCKED: Ladybug database {path} already has an owner"
|
|
231
|
+
) from exc
|
|
232
|
+
raise
|
|
233
|
+
return _ladybug_database
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def verify_connectivity() -> None:
|
|
237
|
+
if backend_name() == "neo4j":
|
|
238
|
+
with _translating_backend_errors():
|
|
239
|
+
get_driver().verify_connectivity()
|
|
240
|
+
return
|
|
241
|
+
with session() as current:
|
|
242
|
+
current.run("RETURN 1 AS ok").single()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@contextmanager
|
|
246
|
+
def session() -> Iterator[Session | LadybugSession]:
|
|
247
|
+
"""Yield a backend session, typing outage failures at this seam.
|
|
248
|
+
|
|
249
|
+
The translation wraps the ``yield`` too: a driver outage that surfaces
|
|
250
|
+
mid-query is thrown back in here by the caller's ``with`` block, so one
|
|
251
|
+
boundary covers acquisition and execution alike.
|
|
252
|
+
"""
|
|
253
|
+
with _translating_backend_errors():
|
|
254
|
+
if backend_name() == "neo4j":
|
|
255
|
+
with get_driver().session(
|
|
256
|
+
database=config.settings.neo4j_database
|
|
257
|
+
) as current:
|
|
258
|
+
yield current
|
|
259
|
+
return
|
|
260
|
+
current = LadybugSession(_get_ladybug_database())
|
|
261
|
+
try:
|
|
262
|
+
yield current
|
|
263
|
+
finally:
|
|
264
|
+
current.close()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def close_driver() -> None:
|
|
268
|
+
global _driver, _ladybug_database
|
|
269
|
+
with _backend_lock:
|
|
270
|
+
if _driver is not None:
|
|
271
|
+
_driver.close()
|
|
272
|
+
_driver = None
|
|
273
|
+
if _ladybug_database is not None:
|
|
274
|
+
_ladybug_database.close()
|
|
275
|
+
_ladybug_database = None
|