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.
Files changed (33) hide show
  1. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/.gitignore +3 -6
  2. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/PKG-INFO +2 -2
  3. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/pyproject.toml +1 -1
  4. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/api/memgraph.py +76 -32
  5. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/api/tool.py +4 -2
  6. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/client/mcp_prompt.py +10 -14
  7. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/evals/coherence.py +8 -3
  8. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/memgraph_toolbox.py +0 -2
  9. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_async_memgraph.py +2 -2
  10. memgraph_toolbox-0.2.2/src/memgraph_toolbox/tests/test_merge_with_retry.py +69 -0
  11. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_toolbox.py +1 -5
  12. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_tools.py +66 -1
  13. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tools/schema.py +3 -3
  14. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/utils/embedding.py +1 -8
  15. memgraph_toolbox-0.2.2/src/memgraph_toolbox/utils/serialization.py +160 -0
  16. memgraph_toolbox-0.2.0/src/memgraph_toolbox/utils/serialization.py +0 -64
  17. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/LICENSE +0 -0
  18. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/README.md +0 -0
  19. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/__init__.py +0 -0
  20. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/api/__init__.py +0 -0
  21. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/api/toolbox.py +0 -0
  22. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/client/__init__.py +0 -0
  23. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/evals/__init__.py +0 -0
  24. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/py.typed +0 -0
  25. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/__init__.py +0 -0
  26. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_embedding.py +0 -0
  27. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_eval_coherence.py +0 -0
  28. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_mcp_prompt.py +0 -0
  29. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tests/test_memgraph_config.py +0 -0
  30. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tools/__init__.py +0 -0
  31. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/tools/cypher.py +0 -0
  32. {memgraph_toolbox-0.2.0 → memgraph_toolbox-0.2.2}/src/memgraph_toolbox/utils/logger.py +0 -0
  33. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: memgraph-toolbox
3
- Version: 0.2.0
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "memgraph-toolbox"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Memgraph toolbox library for Memgraph AI tools and utilities"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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 serialize_record_data
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 results as a list of dictionaries.
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 containing query results
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
- data, _, _ = self.driver.execute_query(
159
- query,
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
- json_data = [serialize_record_data(r.data()) for r in data]
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
- data = session.run(query, params)
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 results as a list of dictionaries.
281
+ Execute a Cypher query and return type-preserving result rows.
242
282
 
243
- Mirrors :meth:`Memgraph.query`, including the fallback to an implicit
244
- transaction for statements that cannot run inside a managed transaction.
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 containing query results
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
- data, _, _ = await self.driver.execute_query(
259
- query,
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
- json_data = [serialize_record_data(r.data()) for r in data]
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
- json_data = [serialize_record_data(r.data()) async for r in result]
273
- return json_data
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
- List containing one or more output values.
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
- # NOTE: If you want to control where uv pulls the dependencies from inject UV_PYTHON or VIRTUAL_ENV.
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
- # NOTE: format="openai" works with everything tested, "mcp" does NOT work.
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
- # Ensure all tool call IDs are within OpenAI's 40-character limit
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
- content_text.append(content_item.text)
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
- # NOTE: Chaning how mcp-memgraph logs from here is not easy because the
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
- # TODO(gitbuda): This should use the utils.embeddings because GPU could be used.
114
- return self.model.encode(sentences)
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
- return self.success
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:
@@ -1,7 +1,5 @@
1
1
  from .api.memgraph import Memgraph
2
2
  from .api.toolbox import BaseToolbox
3
-
4
- # Import all tool classes
5
3
  from .tools.cypher import CypherTool
6
4
  from .tools.schema import EnumSchemaTool, NodeSchemaTool, RelationshipSchemaTool, SearchSchemaTool
7
5
 
@@ -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(url="bolt://localhost:7687", username="", password="")
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 bolt://localhost:7687")
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
@@ -40,11 +40,7 @@ def test_base_toolbox():
40
40
  def test_memgraph_toolbox():
41
41
  """Test the Memgraph Toolbox."""
42
42
 
43
- url = "bolt://localhost:7687"
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(url="bolt://localhost:7687", username="", password="")
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
- # Return the underlying model to avoid DataParallel wrapper issues
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