memgraph-toolbox 0.2.0__tar.gz → 0.2.2__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.
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/.gitignore +3 -6
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/PKG-INFO +2 -2
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/pyproject.toml +1 -1
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/api/memgraph.py +76 -32
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/api/tool.py +4 -2
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/client/mcp_prompt.py +10 -14
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/evals/coherence.py +8 -3
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/memgraph_toolbox.py +0 -2
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_async_memgraph.py +2 -2
- memgraph_toolbox-0.2.2/src/memgraph_toolbox/tests/test_merge_with_retry.py +69 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_toolbox.py +1 -5
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_tools.py +66 -1
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tools/schema.py +3 -3
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/utils/embedding.py +1 -8
- memgraph_toolbox-0.2.2/src/memgraph_toolbox/utils/serialization.py +160 -0
- memgraph_toolbox-0.2.0/src/memgraph_toolbox/utils/serialization.py +0 -64
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/LICENSE +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/README.md +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/__init__.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/api/__init__.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/api/toolbox.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/client/__init__.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/evals/__init__.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/py.typed +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/__init__.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_embedding.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_eval_coherence.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_mcp_prompt.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_memgraph_config.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tools/__init__.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tools/cypher.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/utils/logger.py +0 -0
- {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/uv.lock +0 -0
|
@@ -181,12 +181,9 @@ cython_debug/
|
|
|
181
181
|
# Local agent hook configuration
|
|
182
182
|
/.codex/
|
|
183
183
|
/.claude/settings.local.json
|
|
184
|
-
/AGENTS.md
|
|
185
|
-
/docs/agents/
|
|
186
|
-
/context-graph/CONTEXT-MAP.md
|
|
187
|
-
/context-graph/*/CONTEXT.md
|
|
188
|
-
/context-graph/docs/adr/
|
|
189
|
-
/context-graph/*/docs/adr/
|
|
190
184
|
|
|
191
185
|
# Project specific files
|
|
192
186
|
/enterprise-context/sic-agent/sic-scrapper/output/*
|
|
187
|
+
|
|
188
|
+
# deepeval writes run caches and telemetry here; build artifacts, not source.
|
|
189
|
+
.deepeval/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: memgraph-toolbox
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Memgraph toolbox library for Memgraph AI tools and utilities
|
|
5
5
|
Project-URL: Homepage, https://github.com/memgraph/ai-toolkit
|
|
6
6
|
Project-URL: Source, https://github.com/memgraph/ai-toolkit
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import time
|
|
2
3
|
from collections.abc import Mapping
|
|
3
|
-
from typing import Any
|
|
4
|
+
from typing import Any, cast
|
|
4
5
|
|
|
5
6
|
from neo4j import AsyncGraphDatabase, GraphDatabase
|
|
6
7
|
|
|
7
|
-
from ..utils.serialization import
|
|
8
|
+
from ..utils.serialization import serialize_records
|
|
8
9
|
|
|
9
10
|
MEMGRAPH_ENV_DEFAULTS = {
|
|
10
11
|
"MEMGRAPH_URL": "bolt://localhost:7687",
|
|
@@ -78,6 +79,39 @@ def memgraph_env(
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def merge_with_retry(db: Any, query: str, params: dict | None = None, *, attempts: int = 3) -> list[dict[str, Any]]:
|
|
83
|
+
"""Run *query*, a MERGE of a uniqueness-constrained node, through *db*, surviving a concurrent create.
|
|
84
|
+
|
|
85
|
+
Two transactions that MERGE the same new node both find it missing and
|
|
86
|
+
both create it; the later commit fails the uniqueness constraint. Run
|
|
87
|
+
again, its MERGE matches the node the other one committed. Writers that
|
|
88
|
+
race this way are typically one process per event (agent hooks).
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
db: Anything with ``query(query, params)``, normally a :class:`Memgraph`.
|
|
92
|
+
query: The Cypher to run; it must be safe to run more than once.
|
|
93
|
+
params: Its parameters.
|
|
94
|
+
attempts: Runs before giving up, the first included.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The rows of the run that committed.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
neo4j.exceptions.ClientError: a uniqueness violation on the last
|
|
101
|
+
attempt, or any other client error at once.
|
|
102
|
+
"""
|
|
103
|
+
from neo4j.exceptions import ClientError
|
|
104
|
+
|
|
105
|
+
for attempt in range(attempts):
|
|
106
|
+
try:
|
|
107
|
+
return db.query(query, params=params)
|
|
108
|
+
except ClientError as exc:
|
|
109
|
+
if "unique constraint" not in str(exc) or attempt == attempts - 1:
|
|
110
|
+
raise
|
|
111
|
+
time.sleep(0.05 * (attempt + 1))
|
|
112
|
+
raise ValueError(f"attempts must be at least 1, got {attempts}")
|
|
113
|
+
|
|
114
|
+
|
|
81
115
|
class Memgraph:
|
|
82
116
|
"""
|
|
83
117
|
Base Memgraph client for interacting with Memgraph database.
|
|
@@ -87,10 +121,10 @@ class Memgraph:
|
|
|
87
121
|
|
|
88
122
|
def __init__(
|
|
89
123
|
self,
|
|
90
|
-
url: str = None,
|
|
91
|
-
username: str = None,
|
|
92
|
-
password: str = None,
|
|
93
|
-
database: str = None,
|
|
124
|
+
url: str | None = None,
|
|
125
|
+
username: str | None = None,
|
|
126
|
+
password: str | None = None,
|
|
127
|
+
database: str | None = None,
|
|
94
128
|
driver_config: dict | None = None,
|
|
95
129
|
user_agent: str | None = None,
|
|
96
130
|
):
|
|
@@ -140,37 +174,43 @@ class Memgraph:
|
|
|
140
174
|
f"Could not connect to Memgraph database. Authentication failed for user '{username}'"
|
|
141
175
|
) from e
|
|
142
176
|
|
|
143
|
-
def query(self, query: str, params: dict = None) -> list[dict[str, Any]]:
|
|
177
|
+
def query(self, query: str, params: dict | None = None) -> list[dict[str, Any]]:
|
|
144
178
|
"""
|
|
145
|
-
Execute a Cypher query and return
|
|
179
|
+
Execute a Cypher query and return type-preserving result rows.
|
|
180
|
+
|
|
181
|
+
Each value keeps its type: nodes, relationships and paths become
|
|
182
|
+
``_type``-tagged objects (id, labels/type, endpoints, properties);
|
|
183
|
+
primitives, lists, maps, temporals and points keep their shape.
|
|
146
184
|
|
|
147
185
|
Args:
|
|
148
186
|
query: The Cypher query to execute
|
|
187
|
+
params: Optional query parameters
|
|
149
188
|
|
|
150
189
|
Returns:
|
|
151
|
-
List of dictionaries
|
|
190
|
+
List of dictionaries, one per result row
|
|
152
191
|
"""
|
|
153
192
|
from neo4j.exceptions import Neo4jError
|
|
154
193
|
|
|
155
194
|
if params is None:
|
|
156
195
|
params = {}
|
|
157
196
|
try:
|
|
158
|
-
|
|
159
|
-
|
|
197
|
+
# neo4j's driver stubs require query_: LiteralString | Query, a hint aimed at
|
|
198
|
+
# discouraging unparameterized query construction. This is a generic passthrough
|
|
199
|
+
# executor that legitimately accepts caller-built Cypher text (injection is
|
|
200
|
+
# already guarded by `params`), which a `str` can never satisfy statically.
|
|
201
|
+
records, _, _ = self.driver.execute_query(
|
|
202
|
+
cast("Any", query),
|
|
160
203
|
parameters_=params,
|
|
161
204
|
database_=self.database,
|
|
162
205
|
)
|
|
163
|
-
|
|
164
|
-
return json_data
|
|
206
|
+
return serialize_records(records)
|
|
165
207
|
except Neo4jError as e:
|
|
166
208
|
if not _is_implicit_transaction_fallback_error(e):
|
|
167
209
|
raise
|
|
168
210
|
|
|
169
211
|
# fallback to allow implicit transactions
|
|
170
212
|
with self.driver.session(database=self.database) as session:
|
|
171
|
-
|
|
172
|
-
json_data = [serialize_record_data(r.data()) for r in data]
|
|
173
|
-
return json_data
|
|
213
|
+
return serialize_records(session.run(cast("Any", query), params))
|
|
174
214
|
|
|
175
215
|
def close(self) -> None:
|
|
176
216
|
"""
|
|
@@ -191,10 +231,10 @@ class AsyncMemgraph:
|
|
|
191
231
|
|
|
192
232
|
def __init__(
|
|
193
233
|
self,
|
|
194
|
-
url: str = None,
|
|
195
|
-
username: str = None,
|
|
196
|
-
password: str = None,
|
|
197
|
-
database: str = None,
|
|
234
|
+
url: str | None = None,
|
|
235
|
+
username: str | None = None,
|
|
236
|
+
password: str | None = None,
|
|
237
|
+
database: str | None = None,
|
|
198
238
|
driver_config: dict | None = None,
|
|
199
239
|
user_agent: str | None = None,
|
|
200
240
|
):
|
|
@@ -236,41 +276,45 @@ class AsyncMemgraph:
|
|
|
236
276
|
"""
|
|
237
277
|
await self.driver.verify_connectivity()
|
|
238
278
|
|
|
239
|
-
async def query(self, query: str, params: dict = None) -> list[dict[str, Any]]:
|
|
279
|
+
async def query(self, query: str, params: dict | None = None) -> list[dict[str, Any]]:
|
|
240
280
|
"""
|
|
241
|
-
Execute a Cypher query and return
|
|
281
|
+
Execute a Cypher query and return type-preserving result rows.
|
|
242
282
|
|
|
243
|
-
|
|
244
|
-
transaction for statements that cannot run inside a managed
|
|
283
|
+
Async mirror of :meth:`Memgraph.query`, including the fallback to an
|
|
284
|
+
implicit transaction for statements that cannot run inside a managed
|
|
285
|
+
transaction.
|
|
245
286
|
|
|
246
287
|
Args:
|
|
247
288
|
query: The Cypher query to execute
|
|
248
289
|
params: Optional query parameters
|
|
249
290
|
|
|
250
291
|
Returns:
|
|
251
|
-
List of dictionaries
|
|
292
|
+
List of dictionaries, one per result row
|
|
252
293
|
"""
|
|
253
294
|
from neo4j.exceptions import Neo4jError
|
|
254
295
|
|
|
255
296
|
if params is None:
|
|
256
297
|
params = {}
|
|
257
298
|
try:
|
|
258
|
-
|
|
259
|
-
|
|
299
|
+
# See the matching comment in Memgraph.query: query_ is typed LiteralString |
|
|
300
|
+
# Query in the driver stubs, but this is a generic passthrough executor that
|
|
301
|
+
# legitimately accepts caller-built Cypher text -- a `str` can never satisfy
|
|
302
|
+
# LiteralString statically, regardless of how it was validated at runtime.
|
|
303
|
+
records, _, _ = await self.driver.execute_query(
|
|
304
|
+
cast("Any", query),
|
|
260
305
|
parameters_=params,
|
|
261
306
|
database_=self.database,
|
|
262
307
|
)
|
|
263
|
-
|
|
264
|
-
return json_data
|
|
308
|
+
return serialize_records(records)
|
|
265
309
|
except Neo4jError as e:
|
|
266
310
|
if not _is_implicit_transaction_fallback_error(e):
|
|
267
311
|
raise
|
|
268
312
|
|
|
269
313
|
# fallback to allow implicit transactions
|
|
270
314
|
async with self.driver.session(database=self.database) as session:
|
|
271
|
-
result = await session.run(query, params)
|
|
272
|
-
|
|
273
|
-
return
|
|
315
|
+
result = await session.run(cast("Any", query), params)
|
|
316
|
+
records = [record async for record in result]
|
|
317
|
+
return serialize_records(records)
|
|
274
318
|
|
|
275
319
|
async def close(self) -> None:
|
|
276
320
|
"""
|
|
@@ -17,7 +17,7 @@ class BaseTool(ABC):
|
|
|
17
17
|
self.input_schema = input_schema
|
|
18
18
|
|
|
19
19
|
@abstractmethod
|
|
20
|
-
def call(self, arguments: dict[str, Any]) -> list[Any]:
|
|
20
|
+
def call(self, arguments: dict[str, Any]) -> list[Any] | dict[str, Any]:
|
|
21
21
|
"""
|
|
22
22
|
Execute the tool with the provided arguments.
|
|
23
23
|
|
|
@@ -25,7 +25,9 @@ class BaseTool(ABC):
|
|
|
25
25
|
arguments (dict): A dictionary of arguments as defined by the input schema.
|
|
26
26
|
|
|
27
27
|
Returns:
|
|
28
|
-
|
|
28
|
+
Either a list containing one or more output values, or -- for tools whose
|
|
29
|
+
"found" result is naturally a single object (e.g. schema lookups) -- a bare
|
|
30
|
+
dict.
|
|
29
31
|
"""
|
|
30
32
|
pass
|
|
31
33
|
|
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
|
+
from typing import cast
|
|
6
7
|
|
|
7
8
|
try:
|
|
8
9
|
from litellm import acompletion, experimental_mcp_client
|
|
@@ -19,9 +20,9 @@ logger = logging.getLogger(__name__)
|
|
|
19
20
|
async def prompt_with_tools(
|
|
20
21
|
prompt: str,
|
|
21
22
|
tool_selection_model_name: str = "openai/gpt-4o",
|
|
22
|
-
tool_selection_system_messages: list[dict[str, str]] = None,
|
|
23
|
+
tool_selection_system_messages: list[dict[str, str]] | None = None,
|
|
23
24
|
response_model_name: str = "openai/gpt-4o",
|
|
24
|
-
response_system_messages: list[dict[str, str]] = None,
|
|
25
|
+
response_system_messages: list[dict[str, str]] | None = None,
|
|
25
26
|
mcp_server_params: StdioServerParameters | None = None,
|
|
26
27
|
):
|
|
27
28
|
"""
|
|
@@ -29,7 +30,6 @@ async def prompt_with_tools(
|
|
|
29
30
|
First there is a tool selection phase where the LLM is asked to select the tools to use.
|
|
30
31
|
Second there is a response phase where the LLM is asked to generate a response.
|
|
31
32
|
"""
|
|
32
|
-
# Configure MCP server connection.
|
|
33
33
|
if response_system_messages is None:
|
|
34
34
|
response_system_messages = []
|
|
35
35
|
if tool_selection_system_messages is None:
|
|
@@ -48,18 +48,17 @@ async def prompt_with_tools(
|
|
|
48
48
|
],
|
|
49
49
|
env={
|
|
50
50
|
"MEMGRAPH_URL": os.environ.get("MEMGRAPH_URL", "bolt://localhost:7687"),
|
|
51
|
-
#
|
|
51
|
+
# Set UV_PYTHON or VIRTUAL_ENV when the MCP subprocess must use a specific environment.
|
|
52
52
|
},
|
|
53
53
|
)
|
|
54
54
|
else:
|
|
55
55
|
server_params = mcp_server_params
|
|
56
56
|
|
|
57
|
-
# Establish MCP server connection.
|
|
58
57
|
async with stdio_client(server_params) as (read, write), ClientSession(read, write) as session:
|
|
59
58
|
await session.initialize()
|
|
60
59
|
# https://docs.litellm.ai/docs/mcp#litellm-python-sdk-mcp-bridge
|
|
61
60
|
tools = await experimental_mcp_client.load_mcp_tools(
|
|
62
|
-
#
|
|
61
|
+
# LiteLLM's OpenAI format is required for the tool-call shape consumed below.
|
|
63
62
|
session=session,
|
|
64
63
|
format="openai",
|
|
65
64
|
)
|
|
@@ -69,7 +68,6 @@ async def prompt_with_tools(
|
|
|
69
68
|
)
|
|
70
69
|
logger.info(f"{len(tools)} MCP tools loaded")
|
|
71
70
|
|
|
72
|
-
# Tool selection by an LLM.
|
|
73
71
|
messages = [*tool_selection_system_messages, {"role": "user", "content": prompt}]
|
|
74
72
|
resp = await acompletion(
|
|
75
73
|
model=tool_selection_model_name,
|
|
@@ -77,7 +75,6 @@ async def prompt_with_tools(
|
|
|
77
75
|
tools=tools,
|
|
78
76
|
)
|
|
79
77
|
msg = resp["choices"][0]["message"]
|
|
80
|
-
# Tool calls by the MCP server.
|
|
81
78
|
tool_calls = msg.get("tool_calls", [])
|
|
82
79
|
if not tool_calls:
|
|
83
80
|
logger.info("LLM Response: %s", msg.get("content", ""))
|
|
@@ -89,7 +86,7 @@ async def prompt_with_tools(
|
|
|
89
86
|
tc["function"]["name"],
|
|
90
87
|
tc["function"].get("description", "No description"),
|
|
91
88
|
)
|
|
92
|
-
#
|
|
89
|
+
# OpenAI limits tool-call IDs to 40 characters.
|
|
93
90
|
for tc in tool_calls:
|
|
94
91
|
if len(tc["id"]) > 40:
|
|
95
92
|
logger.warning(
|
|
@@ -113,10 +110,11 @@ async def prompt_with_tools(
|
|
|
113
110
|
result = await session.call_tool(name=tc["function"]["name"], arguments=arguments)
|
|
114
111
|
if hasattr(result, "content") and result.content:
|
|
115
112
|
if isinstance(result.content, list):
|
|
116
|
-
content_text = []
|
|
113
|
+
content_text: list[str] = []
|
|
117
114
|
for content_item in result.content:
|
|
118
115
|
if hasattr(content_item, "text"):
|
|
119
|
-
|
|
116
|
+
# Only TextContent exposes `.text`; other content blocks are serialized below.
|
|
117
|
+
content_text.append(cast("str", content_item.text))
|
|
120
118
|
elif isinstance(content_item, str):
|
|
121
119
|
content_text.append(content_item)
|
|
122
120
|
else:
|
|
@@ -148,7 +146,6 @@ async def prompt_with_tools(
|
|
|
148
146
|
tool_error,
|
|
149
147
|
)
|
|
150
148
|
|
|
151
|
-
# Generating the final response using LLM.
|
|
152
149
|
messages = messages + response_system_messages
|
|
153
150
|
final_resp = await acompletion(
|
|
154
151
|
model=response_model_name,
|
|
@@ -177,7 +174,6 @@ async def __async_context():
|
|
|
177
174
|
|
|
178
175
|
|
|
179
176
|
if __name__ == "__main__":
|
|
180
|
-
#
|
|
181
|
-
# server is started in another process.
|
|
177
|
+
# The MCP server runs in a separate process, so configure its client-side logger here.
|
|
182
178
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
183
179
|
asyncio.run(__async_context())
|
|
@@ -110,8 +110,11 @@ class CoherenceEmbeddingsBasedMetric(BaseMetric):
|
|
|
110
110
|
Returns:
|
|
111
111
|
Array of embeddings with shape (n_sentences, embedding_dim)
|
|
112
112
|
"""
|
|
113
|
-
#
|
|
114
|
-
|
|
113
|
+
# Keep this local model call until the shared embedding utility supports this metric's API.
|
|
114
|
+
# convert_to_numpy is already the model's default; pinning it explicitly picks the
|
|
115
|
+
# np.ndarray-returning overload instead of leaving overload resolution to fall
|
|
116
|
+
# through to a Tensor-returning one.
|
|
117
|
+
return self.model.encode(sentences, convert_to_numpy=True)
|
|
115
118
|
|
|
116
119
|
def _compute_cosine_similarity(self, embeddings: np.ndarray) -> list[float]:
|
|
117
120
|
"""Compute cosine similarity between consecutive embeddings.
|
|
@@ -221,7 +224,9 @@ class CoherenceEmbeddingsBasedMetric(BaseMetric):
|
|
|
221
224
|
Returns:
|
|
222
225
|
True if score meets threshold, False otherwise
|
|
223
226
|
"""
|
|
224
|
-
|
|
227
|
+
# self.success is bool | None (None until measure() has run); treat "never measured"
|
|
228
|
+
# as "not successful" rather than lying about the return type or leaking None.
|
|
229
|
+
return self.success or False
|
|
225
230
|
|
|
226
231
|
@property
|
|
227
232
|
def __name__(self) -> str:
|
{memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_async_memgraph.py
RENAMED
|
@@ -42,12 +42,12 @@ async def test_async_memgraph_query_live():
|
|
|
42
42
|
"""Live round-trip; skips cleanly when no server is reachable."""
|
|
43
43
|
import neo4j
|
|
44
44
|
|
|
45
|
-
client = AsyncMemgraph(
|
|
45
|
+
client = AsyncMemgraph()
|
|
46
46
|
try:
|
|
47
47
|
await client.verify_connectivity()
|
|
48
48
|
except (neo4j.exceptions.ServiceUnavailable, neo4j.exceptions.SessionExpired, OSError):
|
|
49
49
|
await client.close()
|
|
50
|
-
pytest.skip("No Memgraph server reachable at
|
|
50
|
+
pytest.skip("No Memgraph server reachable at the configured MEMGRAPH_URL")
|
|
51
51
|
|
|
52
52
|
try:
|
|
53
53
|
result = await client.query("RETURN 1 AS value")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for merge_with_retry against a live Memgraph; they skip when none is reachable."""
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from neo4j.exceptions import ClientError
|
|
7
|
+
|
|
8
|
+
from ..api.memgraph import Memgraph, merge_with_retry
|
|
9
|
+
|
|
10
|
+
_LABEL = "MergeWithRetryProbe"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def db():
|
|
15
|
+
try:
|
|
16
|
+
client = Memgraph()
|
|
17
|
+
except Exception as exc:
|
|
18
|
+
pytest.skip(f"No Memgraph server reachable at the configured MEMGRAPH_URL: {exc}")
|
|
19
|
+
client.query(f"MATCH (n:{_LABEL}) DETACH DELETE n")
|
|
20
|
+
client.query(f"CREATE CONSTRAINT ON (n:{_LABEL}) ASSERT n.key IS UNIQUE;")
|
|
21
|
+
yield client
|
|
22
|
+
client.query(f"MATCH (n:{_LABEL}) DETACH DELETE n")
|
|
23
|
+
client.query(f"DROP CONSTRAINT ON (n:{_LABEL}) ASSERT n.key IS UNIQUE;")
|
|
24
|
+
client.close()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_concurrent_merges_of_new_nodes_converge_on_one_node_each(db):
|
|
28
|
+
keys = [f"k{i}" for i in range(10)]
|
|
29
|
+
|
|
30
|
+
def merge(key: str) -> None:
|
|
31
|
+
merge_with_retry(db, f"MERGE (n:{_LABEL} {{key: $key}})", {"key": key})
|
|
32
|
+
|
|
33
|
+
with ThreadPoolExecutor(max_workers=16) as pool:
|
|
34
|
+
list(pool.map(merge, keys * 16))
|
|
35
|
+
|
|
36
|
+
rows = db.query(f"MATCH (n:{_LABEL}) RETURN n.key AS key, count(*) AS c ORDER BY key")
|
|
37
|
+
assert rows == [{"key": key, "c": 1} for key in sorted(keys)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_returns_the_rows_of_the_run_that_committed(db):
|
|
41
|
+
rows = merge_with_retry(db, f"MERGE (n:{_LABEL} {{key: 'a'}}) RETURN n.key AS key")
|
|
42
|
+
assert rows == [{"key": "a"}]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_any_other_client_error_is_raised_without_retrying(db):
|
|
46
|
+
calls = []
|
|
47
|
+
|
|
48
|
+
class Counting:
|
|
49
|
+
def query(self, query, params=None):
|
|
50
|
+
calls.append(query)
|
|
51
|
+
return db.query(query, params)
|
|
52
|
+
|
|
53
|
+
with pytest.raises(ClientError):
|
|
54
|
+
merge_with_retry(Counting(), "MERGE (n:")
|
|
55
|
+
assert len(calls) == 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_a_conflict_on_every_attempt_is_raised_after_the_last():
|
|
59
|
+
"""A conflict that never clears can't be produced on demand by a real server."""
|
|
60
|
+
calls = []
|
|
61
|
+
|
|
62
|
+
class AlwaysConflicting:
|
|
63
|
+
def query(self, query, params=None):
|
|
64
|
+
calls.append(query)
|
|
65
|
+
raise ClientError("Unable to commit due to unique constraint violation on :X(key)")
|
|
66
|
+
|
|
67
|
+
with pytest.raises(ClientError, match="unique constraint"):
|
|
68
|
+
merge_with_retry(AlwaysConflicting(), "MERGE (n:X {key: 1})", attempts=2)
|
|
69
|
+
assert len(calls) == 2
|
{memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_toolbox.py
RENAMED
|
@@ -40,11 +40,7 @@ def test_base_toolbox():
|
|
|
40
40
|
def test_memgraph_toolbox():
|
|
41
41
|
"""Test the Memgraph Toolbox."""
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
user = ""
|
|
45
|
-
password = ""
|
|
46
|
-
|
|
47
|
-
memgraph_client = Memgraph(url=url, username=user, password=password)
|
|
43
|
+
memgraph_client = Memgraph()
|
|
48
44
|
|
|
49
45
|
toolkit = MemgraphToolbox(db=memgraph_client)
|
|
50
46
|
|
|
@@ -14,7 +14,7 @@ from ..tools.schema import (
|
|
|
14
14
|
|
|
15
15
|
@pytest.fixture()
|
|
16
16
|
def db():
|
|
17
|
-
client = Memgraph(
|
|
17
|
+
client = Memgraph()
|
|
18
18
|
yield client
|
|
19
19
|
client.close()
|
|
20
20
|
|
|
@@ -72,6 +72,71 @@ def test_cypher_date_time_serialization(db):
|
|
|
72
72
|
assert "PT2M2.33S" in record["test_duration"]
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def test_query_tags_graph_entities(db, schema_graph):
|
|
76
|
+
"""Nodes, relationships and paths are returned as _type-tagged objects."""
|
|
77
|
+
rows = db.query("MATCH p = (a:Person {name: 'Alice'})-[r:KNOWS]->(b) RETURN a, r, b, p")
|
|
78
|
+
|
|
79
|
+
assert len(rows) == 1
|
|
80
|
+
row = rows[0]
|
|
81
|
+
|
|
82
|
+
assert row["a"]["_type"] == "node"
|
|
83
|
+
assert "Person" in row["a"]["labels"]
|
|
84
|
+
assert row["a"]["properties"]["name"] == "Alice"
|
|
85
|
+
assert "id" in row["a"]
|
|
86
|
+
|
|
87
|
+
assert row["r"]["_type"] == "relationship"
|
|
88
|
+
assert row["r"]["type"] == "KNOWS"
|
|
89
|
+
assert row["r"]["start"] == row["a"]["id"]
|
|
90
|
+
assert row["r"]["end"] == row["b"]["id"]
|
|
91
|
+
|
|
92
|
+
assert row["p"]["_type"] == "path"
|
|
93
|
+
assert len(row["p"]["nodes"]) == 2
|
|
94
|
+
assert len(row["p"]["relationships"]) == 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_query_mixed_row(db, schema_graph):
|
|
98
|
+
"""A row can mix a typed node with a scalar aggregation in the same result."""
|
|
99
|
+
rows = db.query("MATCH (n:Person)-[e]->() RETURN n, count(e) AS out_deg")
|
|
100
|
+
|
|
101
|
+
assert len(rows) > 0
|
|
102
|
+
row = rows[0]
|
|
103
|
+
assert row["n"]["_type"] == "node"
|
|
104
|
+
assert isinstance(row["out_deg"], int)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_query_primitives_and_containers(db):
|
|
108
|
+
"""Primitives, lists, maps and temporals pass through with their shape."""
|
|
109
|
+
rows = db.query("RETURN 1 AS i, 'x' AS s, [1, 2] AS lst, {k: 3} AS m, date('2024-01-15') AS d")
|
|
110
|
+
|
|
111
|
+
row = rows[0]
|
|
112
|
+
assert row["i"] == 1
|
|
113
|
+
assert row["s"] == "x"
|
|
114
|
+
assert row["lst"] == [1, 2]
|
|
115
|
+
assert row["m"] == {"k": 3}
|
|
116
|
+
assert row["d"] == "2024-01-15"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_query_point(db):
|
|
120
|
+
"""A geo/point value is serialized to a tagged srid + coordinates record."""
|
|
121
|
+
rows = db.query("RETURN point({x: 1.5, y: 2.5}) AS p")
|
|
122
|
+
|
|
123
|
+
point = rows[0]["p"]
|
|
124
|
+
assert point["_type"] == "point"
|
|
125
|
+
assert "srid" in point
|
|
126
|
+
assert point["coordinates"] == [1.5, 2.5]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_query_temporals(db):
|
|
130
|
+
"""Temporal types serialize to ISO strings, zoned datetimes keeping the offset."""
|
|
131
|
+
rows = db.query("RETURN datetime('2024-01-15T10:30:45+01:00') AS dt, duration('PT2M') AS dur")
|
|
132
|
+
|
|
133
|
+
row = rows[0]
|
|
134
|
+
assert "2024-01-15T10:30:45" in row["dt"]
|
|
135
|
+
assert "+01:00" in row["dt"]
|
|
136
|
+
assert isinstance(row["dur"], str)
|
|
137
|
+
assert "PT2M" in row["dur"]
|
|
138
|
+
|
|
139
|
+
|
|
75
140
|
def test_get_node_schema_found(db, schema_graph):
|
|
76
141
|
tool = NodeSchemaTool(db=db)
|
|
77
142
|
result = tool.call({"node_labels": ["Person"]})
|
|
@@ -33,7 +33,7 @@ class NodeSchemaTool(BaseTool):
|
|
|
33
33
|
)
|
|
34
34
|
self.db = db
|
|
35
35
|
|
|
36
|
-
def call(self, arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
36
|
+
def call(self, arguments: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
|
|
37
37
|
try:
|
|
38
38
|
node_labels = arguments["node_labels"]
|
|
39
39
|
schema = _get_schema(self.db)
|
|
@@ -96,7 +96,7 @@ class RelationshipSchemaTool(BaseTool):
|
|
|
96
96
|
)
|
|
97
97
|
self.db = db
|
|
98
98
|
|
|
99
|
-
def call(self, arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
99
|
+
def call(self, arguments: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
|
|
100
100
|
try:
|
|
101
101
|
edge_type = arguments["relationship_type"]
|
|
102
102
|
start_node_labels = arguments["start_node_labels"]
|
|
@@ -138,7 +138,7 @@ class EnumSchemaTool(BaseTool):
|
|
|
138
138
|
)
|
|
139
139
|
self.db = db
|
|
140
140
|
|
|
141
|
-
def call(self, arguments: dict[str, Any]) -> list[dict[str, Any]]:
|
|
141
|
+
def call(self, arguments: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
|
|
142
142
|
try:
|
|
143
143
|
enum_name = arguments["enum_name"]
|
|
144
144
|
schema = _get_schema(self.db)
|
|
@@ -7,7 +7,6 @@ except ImportError as e:
|
|
|
7
7
|
) from e
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
# NOTE: HF_TOKEN has to be set in the environment variables.
|
|
11
10
|
def get_sentence_transformer_model(
|
|
12
11
|
name: str = "all-MiniLM-L6-v2",
|
|
13
12
|
device: str | None = None,
|
|
@@ -26,7 +25,6 @@ def get_sentence_transformer_model(
|
|
|
26
25
|
SentenceTransformer model. Always returns the underlying model, not DataParallel
|
|
27
26
|
wrapper because then it's possible to use the whole SentenceTransformer API.
|
|
28
27
|
"""
|
|
29
|
-
# Auto-detect device if not specified
|
|
30
28
|
if device is None:
|
|
31
29
|
if torch.cuda.is_available():
|
|
32
30
|
device = "cuda"
|
|
@@ -35,13 +33,11 @@ def get_sentence_transformer_model(
|
|
|
35
33
|
else:
|
|
36
34
|
device = "cpu"
|
|
37
35
|
model = SentenceTransformer(name, device=device)
|
|
38
|
-
# Wrap with DataParallel if requested and CUDA is available
|
|
39
36
|
if use_data_parallel and torch.cuda.is_available():
|
|
40
37
|
if gpu_ids is None:
|
|
41
38
|
gpu_ids = list(range(torch.cuda.device_count()))
|
|
42
|
-
# Create DataParallel wrapper but return the underlying model
|
|
43
39
|
parallel_model = torch.nn.DataParallel(model, device_ids=gpu_ids)
|
|
44
|
-
#
|
|
40
|
+
# Keep the public API as SentenceTransformer rather than exposing the wrapper.
|
|
45
41
|
return parallel_model.module
|
|
46
42
|
return model
|
|
47
43
|
|
|
@@ -57,13 +53,11 @@ if __name__ == "__main__":
|
|
|
57
53
|
print(f" MPS: {hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()}")
|
|
58
54
|
print(" CPU: True (always available)")
|
|
59
55
|
|
|
60
|
-
# Example usage
|
|
61
56
|
print("\nExample usage:")
|
|
62
57
|
model = get_sentence_transformer_model()
|
|
63
58
|
print(f"Model device: {get_model_device(model)}")
|
|
64
59
|
print(f"Model type: {type(model).__name__}")
|
|
65
60
|
|
|
66
|
-
# Example of using the model directly (no wrapper needed)
|
|
67
61
|
print("\nDirect usage example:")
|
|
68
62
|
sample_texts = ["Hello world", "Graph databases are fast"]
|
|
69
63
|
print(f"Sample texts: {sample_texts}")
|
|
@@ -73,7 +67,6 @@ if __name__ == "__main__":
|
|
|
73
67
|
print(" - model.encode(texts, normalize_embeddings=True)")
|
|
74
68
|
print(" - model.encode(texts, show_progress_bar=True)")
|
|
75
69
|
|
|
76
|
-
# Test with DataParallel (if multiple GPUs available)
|
|
77
70
|
if torch.cuda.is_available() and torch.cuda.device_count() > 1:
|
|
78
71
|
print("\nTesting DataParallel:")
|
|
79
72
|
parallel_model = get_sentence_transformer_model(use_data_parallel=True)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serialization utilities for Neo4j date/time types.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from neo4j.graph import Node, Path, Relationship
|
|
9
|
+
from neo4j.spatial import Point
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def serialize_neo4j_types(value: Any) -> Any:
|
|
13
|
+
"""
|
|
14
|
+
Convert Neo4j date/time types to JSON-serializable strings.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
value: The value to serialize
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
The serialized value with Neo4j types converted to strings
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
class_name = value.__class__.__name__
|
|
24
|
+
module_name = getattr(value.__class__, "__module__", "")
|
|
25
|
+
|
|
26
|
+
# Check if it's a Neo4j temporal type
|
|
27
|
+
if "neo4j" in module_name and hasattr(value, "iso_format"):
|
|
28
|
+
neo4j_temporal_types = [
|
|
29
|
+
"Date",
|
|
30
|
+
"Time",
|
|
31
|
+
"DateTime",
|
|
32
|
+
"LocalTime",
|
|
33
|
+
"LocalDateTime",
|
|
34
|
+
]
|
|
35
|
+
if class_name in neo4j_temporal_types:
|
|
36
|
+
return value.iso_format()
|
|
37
|
+
|
|
38
|
+
# Handle Neo4j Duration type
|
|
39
|
+
if "neo4j" in module_name and class_name == "Duration":
|
|
40
|
+
return str(value)
|
|
41
|
+
|
|
42
|
+
except (AttributeError, TypeError):
|
|
43
|
+
# If we can't access the class name or module, return as-is
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def serialize_record_data(record_data: dict) -> dict:
|
|
50
|
+
"""
|
|
51
|
+
Serialize a single record's data, handling Neo4j types recursively.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
record_data: Dictionary from a Neo4j record.data()
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Dictionary with Neo4j types serialized to JSON-safe values
|
|
58
|
+
"""
|
|
59
|
+
serialized = {}
|
|
60
|
+
for key, value in record_data.items():
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
serialized[key] = serialize_record_data(value)
|
|
63
|
+
elif isinstance(value, list):
|
|
64
|
+
serialized[key] = [serialize_neo4j_types(item) for item in value]
|
|
65
|
+
else:
|
|
66
|
+
serialized[key] = serialize_neo4j_types(value)
|
|
67
|
+
|
|
68
|
+
return serialized
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def serialize_node(node: Node) -> dict:
|
|
72
|
+
"""
|
|
73
|
+
Project a neo4j Node into a JSON-safe node record.
|
|
74
|
+
|
|
75
|
+
Preserves the identity that ``Record.data()`` discards: the stable
|
|
76
|
+
``element_id`` and ``labels``. Property values reuse
|
|
77
|
+
:func:`serialize_record_data` so temporal/duration types stay JSON-safe.
|
|
78
|
+
"""
|
|
79
|
+
return {
|
|
80
|
+
"_type": "node",
|
|
81
|
+
"id": node.element_id,
|
|
82
|
+
"labels": sorted(node.labels),
|
|
83
|
+
"properties": serialize_record_data(dict(node)),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def serialize_relationship(rel: Relationship) -> dict:
|
|
88
|
+
"""
|
|
89
|
+
Project a neo4j Relationship into a JSON-safe edge record.
|
|
90
|
+
|
|
91
|
+
Keeps the type and the start/end endpoints, which ``Record.data()`` drops.
|
|
92
|
+
``start``/``end`` are the endpoint nodes' ``element_id``s, or ``None`` when
|
|
93
|
+
the driver did not hydrate an endpoint.
|
|
94
|
+
"""
|
|
95
|
+
start = rel.start_node
|
|
96
|
+
end = rel.end_node
|
|
97
|
+
return {
|
|
98
|
+
"_type": "relationship",
|
|
99
|
+
"id": rel.element_id,
|
|
100
|
+
"type": rel.type,
|
|
101
|
+
"start": start.element_id if start is not None else None,
|
|
102
|
+
"end": end.element_id if end is not None else None,
|
|
103
|
+
"properties": serialize_record_data(dict(rel)),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def serialize_path(path: Path) -> dict:
|
|
108
|
+
"""Project a neo4j Path into a JSON-safe record of its nodes and relationships."""
|
|
109
|
+
return {
|
|
110
|
+
"_type": "path",
|
|
111
|
+
"nodes": [serialize_node(node) for node in path.nodes],
|
|
112
|
+
"relationships": [serialize_relationship(rel) for rel in path.relationships],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def serialize_point(point: Point) -> dict:
|
|
117
|
+
"""Project a neo4j spatial Point into a JSON-safe record."""
|
|
118
|
+
return {
|
|
119
|
+
"_type": "point",
|
|
120
|
+
"srid": point.srid,
|
|
121
|
+
"coordinates": [float(c) for c in point],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def serialize_value(value: Any) -> Any:
|
|
126
|
+
"""Serialize a single Cypher result value, preserving its type.
|
|
127
|
+
|
|
128
|
+
The type-preserving counterpart to ``Record.data()`` (which flattens graph
|
|
129
|
+
entities into bare property maps): nodes, relationships and paths become
|
|
130
|
+
``_type``-tagged objects with their identity and topology intact; points and
|
|
131
|
+
temporals become JSON-safe structures; lists and maps recurse; primitives
|
|
132
|
+
pass through.
|
|
133
|
+
"""
|
|
134
|
+
if isinstance(value, Node):
|
|
135
|
+
return serialize_node(value)
|
|
136
|
+
if isinstance(value, Relationship):
|
|
137
|
+
return serialize_relationship(value)
|
|
138
|
+
if isinstance(value, Path):
|
|
139
|
+
return serialize_path(value)
|
|
140
|
+
if isinstance(value, Point):
|
|
141
|
+
return serialize_point(value)
|
|
142
|
+
if isinstance(value, list):
|
|
143
|
+
return [serialize_value(item) for item in value]
|
|
144
|
+
if isinstance(value, dict):
|
|
145
|
+
return {key: serialize_value(item) for key, item in value.items()}
|
|
146
|
+
return serialize_neo4j_types(value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def serialize_records(records: Iterable[Any]) -> list[dict]:
|
|
150
|
+
"""Serialize raw neo4j records into type-preserving rows (one dict per row).
|
|
151
|
+
|
|
152
|
+
Each row keeps its ``RETURN`` column names; each value is serialized with
|
|
153
|
+
:func:`serialize_value`, so nodes/edges/paths retain their identity instead
|
|
154
|
+
of collapsing to bare property maps.
|
|
155
|
+
|
|
156
|
+
Accepts anything iterable of records (a plain ``list``, or a neo4j
|
|
157
|
+
``Result``/``AsyncResult`` consumed lazily) -- the body only ever iterates
|
|
158
|
+
once and calls ``.items()`` per record, it never needs list operations.
|
|
159
|
+
"""
|
|
160
|
+
return [{key: serialize_value(value) for key, value in record.items()} for record in records]
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Serialization utilities for Neo4j date/time types.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def serialize_neo4j_types(value: Any) -> Any:
|
|
9
|
-
"""
|
|
10
|
-
Convert Neo4j date/time types to JSON-serializable strings.
|
|
11
|
-
|
|
12
|
-
Args:
|
|
13
|
-
value: The value to serialize
|
|
14
|
-
|
|
15
|
-
Returns:
|
|
16
|
-
The serialized value with Neo4j types converted to strings
|
|
17
|
-
"""
|
|
18
|
-
try:
|
|
19
|
-
class_name = value.__class__.__name__
|
|
20
|
-
module_name = getattr(value.__class__, "__module__", "")
|
|
21
|
-
|
|
22
|
-
# Check if it's a Neo4j temporal type
|
|
23
|
-
if "neo4j" in module_name and hasattr(value, "iso_format"):
|
|
24
|
-
neo4j_temporal_types = [
|
|
25
|
-
"Date",
|
|
26
|
-
"Time",
|
|
27
|
-
"DateTime",
|
|
28
|
-
"LocalTime",
|
|
29
|
-
"LocalDateTime",
|
|
30
|
-
]
|
|
31
|
-
if class_name in neo4j_temporal_types:
|
|
32
|
-
return value.iso_format()
|
|
33
|
-
|
|
34
|
-
# Handle Neo4j Duration type
|
|
35
|
-
if "neo4j" in module_name and class_name == "Duration":
|
|
36
|
-
return str(value)
|
|
37
|
-
|
|
38
|
-
except (AttributeError, TypeError):
|
|
39
|
-
# If we can't access the class name or module, return as-is
|
|
40
|
-
pass
|
|
41
|
-
|
|
42
|
-
return value
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def serialize_record_data(record_data: dict) -> dict:
|
|
46
|
-
"""
|
|
47
|
-
Serialize a single record's data, handling Neo4j types recursively.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
record_data: Dictionary from a Neo4j record.data()
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
Dictionary with Neo4j types serialized to JSON-safe values
|
|
54
|
-
"""
|
|
55
|
-
serialized = {}
|
|
56
|
-
for key, value in record_data.items():
|
|
57
|
-
if isinstance(value, dict):
|
|
58
|
-
serialized[key] = serialize_record_data(value)
|
|
59
|
-
elif isinstance(value, list):
|
|
60
|
-
serialized[key] = [serialize_neo4j_types(item) for item in value]
|
|
61
|
-
else:
|
|
62
|
-
serialized[key] = serialize_neo4j_types(value)
|
|
63
|
-
|
|
64
|
-
return serialized
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_embedding.py
RENAMED
|
File without changes
|
{memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_eval_coherence.py
RENAMED
|
File without changes
|
{memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_mcp_prompt.py
RENAMED
|
File without changes
|
{memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_memgraph_config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|