pyneoinstance 4.0.2__tar.gz → 4.0.4__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 (34) hide show
  1. {pyneoinstance-4.0.2/pyneoinstance.egg-info → pyneoinstance-4.0.4}/PKG-INFO +1 -1
  2. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/__init__.py +1 -1
  3. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/database/neo4jdbms.py +82 -6
  4. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4/pyneoinstance.egg-info}/PKG-INFO +1 -1
  5. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance.egg-info/SOURCES.txt +0 -2
  6. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyproject.toml +1 -1
  7. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/test_neo4jdb.py +98 -0
  8. pyneoinstance-4.0.2/test/__init__.py +0 -3
  9. pyneoinstance-4.0.2/test/context.py +0 -8
  10. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/LICENSE +0 -0
  11. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/MANIFEST.in +0 -0
  12. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/README.md +0 -0
  13. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/database/__init__.py +0 -0
  14. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/database/context.py +0 -0
  15. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/fileload/__init__.py +0 -0
  16. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/fileload/helpers.py +0 -0
  17. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/fileload/loadyaml.py +0 -0
  18. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/py.typed +0 -0
  19. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/util/__init__.py +0 -0
  20. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/util/functions.py +0 -0
  21. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance.egg-info/dependency_links.txt +0 -0
  22. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance.egg-info/requires.txt +0 -0
  23. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance.egg-info/top_level.txt +0 -0
  24. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/setup.cfg +0 -0
  25. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/setup.py +0 -0
  26. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/age_data.csv +0 -0
  27. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/cypher_queries.yaml +0 -0
  28. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/load_files.yaml +0 -0
  29. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/movies.csv +0 -0
  30. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/people.csv +0 -0
  31. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/roles.csv +0 -0
  32. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/test.py +0 -0
  33. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/test_fileload.py +0 -0
  34. {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/test_util_functions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyneoinstance
3
- Version: 4.0.2
3
+ Version: 4.0.4
4
4
  Summary: Handle Neo4j read and write transactions and provide methods for performing graph EDA
5
5
  Author-email: "Jose A. Alvarado-Guzman" <jose.alvarado-guzman@neo4j.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -1,5 +1,5 @@
1
1
  from .database.neo4jdbms import Neo4jInstance
2
2
  from .fileload.loadyaml import load_yaml_file
3
3
 
4
- __version__ = '4.0.2'
4
+ __version__ = '4.0.4'
5
5
  __all__ = ['Neo4jInstance', 'load_yaml_file']
@@ -15,12 +15,15 @@ from concurrent.futures import ThreadPoolExecutor
15
15
  import logging
16
16
  import os
17
17
  import re
18
+ import time
18
19
  from pandas import DataFrame
19
20
  from neo4j import GraphDatabase
20
21
  from pyvis.network import Network
21
22
  from neo4j_viz import VisualizationGraph
22
23
  from neo4j_viz.neo4j import from_neo4j
23
24
  from neo4j.exceptions import ServiceUnavailable
25
+ from neo4j.exceptions import SessionExpired
26
+ from neo4j.exceptions import TransientError
24
27
  from neo4j.exceptions import AuthError
25
28
  from neo4j.exceptions import ClientError
26
29
  from neo4j.exceptions import ConfigurationError
@@ -28,7 +31,58 @@ from .context import get_logger, get_batches, get_columns_diff
28
31
 
29
32
  _NODE_LABEL_RE = re.compile(r"\(:?(\w*)\)")
30
33
  _REL_TYPE_RE = re.compile(r"\[:?(\w*)\]")
31
- _IMPLICIT_TX_RE = re.compile(r"\bIN\s+TRANSACTIONS\b", re.IGNORECASE)
34
+ _IMPLICIT_TX_RE = re.compile(
35
+ r"\bIN\s+(?:\d+\s+)?CONCURRENT\s+TRANSACTIONS\b"
36
+ r"|\bIN\s+TRANSACTIONS\b",
37
+ re.IGNORECASE,
38
+ )
39
+
40
+ # Errors treated as transient/retryable for auto-commit queries (run via
41
+ # session.run). session.execute_read/execute_write retry these automatically
42
+ # through the driver's managed-transaction logic; session.run does not.
43
+ _RETRYABLE_EXCEPTIONS = (ServiceUnavailable, SessionExpired, TransientError)
44
+
45
+
46
+ def _run_autocommit_with_retry(session, consume, query, max_retries=5,
47
+ initial_delay=1.0, backoff_factor=2.0, **kwargs):
48
+ """Run an auto-commit query (via session.run) with manual retry.
49
+
50
+ Queries using ``CALL { ... } IN TRANSACTIONS`` or
51
+ ``IN CONCURRENT TRANSACTIONS`` are only valid in an implicit
52
+ (auto-commit) transaction, so they can't be executed through
53
+ session.execute_read/execute_write and don't benefit from the driver's
54
+ built-in managed-transaction retry behavior. This re-runs the query and
55
+ re-consumes its result from scratch on transient failures such as
56
+ ServiceUnavailable.
57
+
58
+ Parameters
59
+ ----------
60
+ session : neo4j.Session
61
+ The session to run the query on.
62
+ consume : Callable[[neo4j.Result], Any]
63
+ Function that consumes the Result object (e.g. reads values, builds
64
+ a graph, reads counters) and returns the desired output.
65
+ query : str
66
+ The Cypher query to run.
67
+ max_retries : int, optional
68
+ Maximum number of retry attempts after the initial try.
69
+ initial_delay : float, optional
70
+ Delay in seconds before the first retry.
71
+ backoff_factor : float, optional
72
+ Multiplier applied to the delay after each subsequent retry.
73
+ kwargs
74
+ Cypher query parameters.
75
+ """
76
+ delay = initial_delay
77
+ for attempt in range(max_retries + 1):
78
+ try:
79
+ result = session.run(query, **kwargs)
80
+ return consume(result)
81
+ except _RETRYABLE_EXCEPTIONS:
82
+ if attempt == max_retries:
83
+ raise
84
+ time.sleep(delay)
85
+ delay *= backoff_factor
32
86
 
33
87
  def _get_driver(neo_info : Dict[str, str]):
34
88
  try:
@@ -197,8 +251,17 @@ class Neo4jInstance:
197
251
  params = parameters or {}
198
252
  with _get_session(self._driver, database) as session:
199
253
  try:
200
- result = session.execute_read(
201
- _read_transaction_function,query=query,**params)
254
+ if _IMPLICIT_TX_RE.search(query):
255
+ # 'CALL { ... } IN TRANSACTIONS' is only valid in an implicit
256
+ # (auto-commit) transaction, so it can't go through execute_read.
257
+ # session.run isn't retried by the driver, so retry manually.
258
+ result = _run_autocommit_with_retry(
259
+ session,
260
+ lambda r: DataFrame(r.values(), columns=r.keys()),
261
+ query, **params)
262
+ else:
263
+ result = session.execute_read(
264
+ _read_transaction_function,query=query,**params)
202
265
  except ServiceUnavailable as exception:
203
266
  raise ServiceUnavailable() from exception
204
267
  except ClientError as exception:
@@ -243,8 +306,18 @@ class Neo4jInstance:
243
306
  params = parameters or {}
244
307
  with _get_session(self._driver, database) as session:
245
308
  try:
246
- graph = session.execute_read(
247
- _graph_transaction_function, query=query, **params)
309
+ if _IMPLICIT_TX_RE.search(query):
310
+ # 'CALL { ... } IN TRANSACTIONS' is only valid in an implicit
311
+ # (auto-commit) transaction, so it can't go through execute_read.
312
+ # session.run isn't retried by the driver, so retry manually.
313
+ def _consume_graph(result):
314
+ list(result) # consume records so .graph() is populated
315
+ return result.graph()
316
+ graph = _run_autocommit_with_retry(
317
+ session, _consume_graph, query, **params)
318
+ else:
319
+ graph = session.execute_read(
320
+ _graph_transaction_function, query=query, **params)
248
321
  except ServiceUnavailable as exception:
249
322
  raise ServiceUnavailable() from exception
250
323
  except ClientError as exception:
@@ -758,7 +831,10 @@ class Neo4jInstance:
758
831
  if _IMPLICIT_TX_RE.search(query):
759
832
  # 'CALL { ... } IN TRANSACTIONS' is only valid in an implicit
760
833
  # (auto-commit) transaction, so it can't go through execute_write.
761
- results = session.run(query, **kwargs).consume().counters.__dict__
834
+ # session.run isn't retried by the driver, so retry manually.
835
+ results = _run_autocommit_with_retry(
836
+ session, lambda r: r.consume().counters.__dict__,
837
+ query, **kwargs)
762
838
  else:
763
839
  results = session.execute_write(
764
840
  _write_transaction_function, query,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyneoinstance
3
- Version: 4.0.2
3
+ Version: 4.0.4
4
4
  Summary: Handle Neo4j read and write transactions and provide methods for performing graph EDA
5
5
  Author-email: "Jose A. Alvarado-Guzman" <jose.alvarado-guzman@neo4j.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -18,9 +18,7 @@ pyneoinstance/fileload/helpers.py
18
18
  pyneoinstance/fileload/loadyaml.py
19
19
  pyneoinstance/util/__init__.py
20
20
  pyneoinstance/util/functions.py
21
- test/__init__.py
22
21
  test/age_data.csv
23
- test/context.py
24
22
  test/cypher_queries.yaml
25
23
  test/load_files.yaml
26
24
  test/movies.csv
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
 
7
7
  [project]
8
8
  name = "pyneoinstance"
9
- version = "4.0.2"
9
+ version = "4.0.4"
10
10
  description = "Handle Neo4j read and write transactions and provide methods for performing graph EDA"
11
11
  readme = "README.md"
12
12
  authors = [{ name = "Jose A. Alvarado-Guzman",email = "jose.alvarado-guzman@neo4j.com" }]
@@ -10,10 +10,14 @@
10
10
  """
11
11
  import os
12
12
  import unittest
13
+ from contextlib import contextmanager
14
+ from unittest.mock import patch
15
+ import neo4j
13
16
  import pandas as pd
14
17
  import numpy as np
15
18
  import pandas.testing as pd_testing
16
19
  from neo4j.exceptions import ClientError
20
+ from neo4j.exceptions import ServiceUnavailable
17
21
  from neo4j_viz import VisualizationGraph
18
22
  from .context import database
19
23
  from .context import fileload
@@ -40,6 +44,32 @@ def _clean_db(graph, db):
40
44
  ["CALL apoc.schema.assert({},{})", "MATCH(n) DETACH DELETE n"], db)
41
45
 
42
46
 
47
+ @contextmanager
48
+ def _patch_flaky_session_run(fail_times=1):
49
+ """Patch neo4j.Session.run so the first `fail_times` calls raise a
50
+ transient ServiceUnavailable, then delegate to the real driver.
51
+
52
+ Used to verify that 'IN TRANSACTIONS'/'IN CONCURRENT TRANSACTIONS'
53
+ queries (which go through session.run rather than execute_read/
54
+ execute_write, and so don't get the driver's built-in managed-
55
+ transaction retries) are retried by the manual retry wrapper instead
56
+ of failing outright. Also patches time.sleep so the retry backoff
57
+ doesn't slow the test down.
58
+ """
59
+ original_run = neo4j.Session.run
60
+ call_count = {'n': 0}
61
+
62
+ def flaky_run(session_self, *args, **kwargs):
63
+ call_count['n'] += 1
64
+ if call_count['n'] <= fail_times:
65
+ raise ServiceUnavailable('simulated transient failure')
66
+ return original_run(session_self, *args, **kwargs)
67
+
68
+ with patch.object(neo4j.Session, 'run', flaky_run), \
69
+ patch.object(database.neo4jdbms.time, 'sleep', return_value=None):
70
+ yield call_count
71
+
72
+
43
73
  class TestWriteMethods(unittest.TestCase):
44
74
  """Test write operations. Each test gets a clean database."""
45
75
 
@@ -111,6 +141,38 @@ class TestWriteMethods(unittest.TestCase):
111
141
  self.assertEqual(
112
142
  {'nodes_created': 3, 'labels_added': 3, 'properties_set': 3}, result)
113
143
 
144
+ def test_execute_write_query_in_concurrent_transactions(self):
145
+ """Test a query with 'CALL { ... } IN CONCURRENT TRANSACTIONS', which
146
+ also requires an implicit transaction rather than the managed
147
+ transaction used by execute_write_query for regular queries."""
148
+ query = """
149
+ UNWIND range(1, 3) AS i
150
+ CALL (i) {
151
+ CREATE (:Actor {id: i})
152
+ } IN 2 CONCURRENT TRANSACTIONS OF 1 ROWS
153
+ """
154
+ result = self.graph.execute_write_query(query, self.db)
155
+ self.assertEqual(
156
+ {'nodes_created': 3, 'labels_added': 3, 'properties_set': 3}, result)
157
+
158
+ def test_execute_write_query_in_transactions_retries_on_transient_failure(self):
159
+ """An 'IN TRANSACTIONS' write query is retried and still succeeds
160
+ after a transient ServiceUnavailable failure, since it goes through
161
+ session.run (not execute_write) and isn't covered by the driver's
162
+ built-in managed-transaction retries."""
163
+ query = """
164
+ UNWIND range(1, 3) AS i
165
+ CALL (i) {
166
+ CREATE (:Actor {id: i})
167
+ } IN TRANSACTIONS OF 1 ROWS
168
+ """
169
+ with _patch_flaky_session_run(fail_times=1) as call_count:
170
+ result = self.graph.execute_write_query(query, self.db)
171
+
172
+ self.assertEqual(call_count['n'], 2)
173
+ self.assertEqual(
174
+ {'nodes_created': 3, 'labels_added': 3, 'properties_set': 3}, result)
175
+
114
176
 
115
177
  class TestReadAndEdaMethods(unittest.TestCase):
116
178
  """Test read and EDA methods. Data is loaded once for the entire class."""
@@ -173,6 +235,42 @@ class TestReadAndEdaMethods(unittest.TestCase):
173
235
  query, self.db, {'movie_name': movie})
174
236
  self.assertEqual(movie, result['movie'][0])
175
237
 
238
+ def test_execute_read_query_in_transactions(self):
239
+ """Test a read query with 'CALL { ... } IN TRANSACTIONS', which requires
240
+ an implicit transaction rather than the managed transaction used by
241
+ execute_read_query for regular queries."""
242
+ plain_count = self.graph.execute_read_query(
243
+ "MATCH (p:Person) RETURN count(p) AS row_num", self.db)['row_num'][0]
244
+ query = """
245
+ MATCH (p:Person)
246
+ CALL (p) {
247
+ RETURN p AS person
248
+ } IN TRANSACTIONS OF 1000 ROWS
249
+ RETURN count(person) AS row_num
250
+ """
251
+ result = self.graph.execute_read_query(query, self.db)
252
+ self.assertEqual(plain_count, result['row_num'][0])
253
+
254
+ def test_execute_read_query_in_transactions_retries_on_transient_failure(self):
255
+ """An 'IN TRANSACTIONS' read query is retried and still succeeds
256
+ after a transient ServiceUnavailable failure, since it goes through
257
+ session.run (not execute_read) and isn't covered by the driver's
258
+ built-in managed-transaction retries."""
259
+ plain_count = self.graph.execute_read_query(
260
+ "MATCH (p:Person) RETURN count(p) AS row_num", self.db)['row_num'][0]
261
+ query = """
262
+ MATCH (p:Person)
263
+ CALL (p) {
264
+ RETURN p AS person
265
+ } IN TRANSACTIONS OF 1000 ROWS
266
+ RETURN count(person) AS row_num
267
+ """
268
+ with _patch_flaky_session_run(fail_times=1) as call_count:
269
+ result = self.graph.execute_read_query(query, self.db)
270
+
271
+ self.assertEqual(call_count['n'], 2)
272
+ self.assertEqual(plain_count, result['row_num'][0])
273
+
176
274
  def test_get_node_label_freq(self):
177
275
  """Test node label frequency EDA."""
178
276
  result = self.graph.get_node_label_freq(self.db)
@@ -1,3 +0,0 @@
1
- import os
2
-
3
- test_dir = os.path.abspath(os.path.dirname(__file__))
@@ -1,8 +0,0 @@
1
- """Re-exports package modules for use by the test suite."""
2
- import os
3
- import sys
4
- sys.path.insert(0, os.path.abspath(
5
- os.path.join(os.path.dirname(__file__), '..')))
6
- from pyneoinstance import fileload
7
- from pyneoinstance import util
8
- from pyneoinstance import database
File without changes
File without changes
File without changes
File without changes
File without changes