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.
- {pyneoinstance-4.0.2/pyneoinstance.egg-info → pyneoinstance-4.0.4}/PKG-INFO +1 -1
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/__init__.py +1 -1
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/database/neo4jdbms.py +82 -6
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4/pyneoinstance.egg-info}/PKG-INFO +1 -1
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance.egg-info/SOURCES.txt +0 -2
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyproject.toml +1 -1
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/test_neo4jdb.py +98 -0
- pyneoinstance-4.0.2/test/__init__.py +0 -3
- pyneoinstance-4.0.2/test/context.py +0 -8
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/LICENSE +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/MANIFEST.in +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/README.md +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/database/__init__.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/database/context.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/fileload/__init__.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/fileload/helpers.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/fileload/loadyaml.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/py.typed +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/util/__init__.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance/util/functions.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance.egg-info/dependency_links.txt +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance.egg-info/requires.txt +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/pyneoinstance.egg-info/top_level.txt +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/setup.cfg +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/setup.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/age_data.csv +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/cypher_queries.yaml +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/load_files.yaml +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/movies.csv +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/people.csv +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/roles.csv +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/test.py +0 -0
- {pyneoinstance-4.0.2 → pyneoinstance-4.0.4}/test/test_fileload.py +0 -0
- {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.
|
|
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
|
|
@@ -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(
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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,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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|