"""Neo4j connector Client.""" import importlib.util import os import sys from collections import namedtuple from contextlib import ContextDecorator from enum import Enum from ssl import SSLError import connector_neo4j.exceptions as exc from neo4j import GraphDatabase from neo4j.api import READ_ACCESS from neo4j.api import WRITE_ACCESS from neo4j._sync.work.session import Session from neo4j.exceptions import DriverError from neo4j.exceptions import ServiceUnavailable from neo4j.exceptions import SessionExpired neo4j_driver = None neo4j_session = None conf = None __version__ = "1.0.4-rc0" class SessionStorageMode(Enum): """Options for storing open session.""" # session global to this module SINGLE = 1 # session per request context in Flask FLASK = 2 def configure(url, username, password, override_storage_mode=None, **extras): """Initialize neo4j configuration without connecting to server. Args: url (str): location of neo4j server to connect username (str): identifier for neo4j server auth password (str): secret for neo4j server auth override_storage_mode (Enum): explicitly set session storage mode Returns: None """ global conf if conf: raise exc.ConfigurationAlreadySet() # not using @dataclass for <3.7 compatibility Conf = namedtuple("Conf", ["url", "username", "password", "storage_mode", "extras"]) conf = Conf( url, username, password, _session_storage_mode(override_storage_mode), extras ) def get_session(): """Return neo4j session for request to use. Args: None Returns: neo4j.work.simple.Session or neo4j.work.transaction.Transaction * both of which run queries by calling run() """ session = _get_session() return session._transaction or session def _module_imported(name): return name in sys.modules def _try_import(name): if not _module_imported(name): spec = importlib.util.find_spec(name) if spec: module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) def _session_storage_mode(override_mode=None): """Determine how to store neo4j session objects.""" global session_storage_mode # import optional modules _try_import("flask") # allow client to explictly set storage mode if override_mode: try: if override_mode.value in [x.value for x in SessionStorageMode]: return override_mode else: raise AttributeError() except AttributeError: raise exc.InvalidSessionStorageMode() # auto determine storage mode based on packages installed else: if _module_imported("flask"): return SessionStorageMode.FLASK return SessionStorageMode.SINGLE # https://neo4j.com/docs/api/python-driver/current/api.html#driver-configuration def _init_driver(): """Initialize neo4j database driver as application level singleton. Raises: ConfigurationNotSet: configure not called DriverAlreadyInitiated: driver already exists in application """ global conf global neo4j_driver if not conf: raise exc.ConfigurationNotSet() if neo4j_driver: raise exc.DriverAlreadyInitiated() neo4j_driver = GraphDatabase.driver( conf.url, auth=(conf.username, conf.password), **conf.extras ) def _get_session(): """Return neo4j session for internal functions to use. Args: None Returns: neo4j.work.simple.Session: request context grouping of neo4j queries and transactions Raises: SessionNotCreated: no session has been created in request context """ if conf.storage_mode == SessionStorageMode.FLASK: try: session = sys.modules["flask"].g.neo4j_session if session is None: raise exc.SessionNotCreated() return session except AttributeError: raise exc.SessionNotCreated() elif conf.storage_mode == SessionStorageMode.SINGLE: global neo4j_session if not neo4j_session: raise exc.SessionNotCreated() return neo4j_session def _set_session(session): """Set neo4j session for request. Args: session (neo4j.work.simple.Session): request context grouping of neo4j queries and transactions Returns: None """ if conf.storage_mode == SessionStorageMode.FLASK: sys.modules["flask"].g.neo4j_session = session elif conf.storage_mode == SessionStorageMode.SINGLE: global neo4j_session neo4j_session = session def _create_session(access_mode, database=None, retries=5): """Create neo4j session with retries. Args: access_mode (str): read or write mode database (str): name of the database to connect to retries (int): attempts left on failure Returns: neo4j.work.simple.Session """ try: # checkout connection from driver pool session = neo4j_driver.session( default_access_mode=access_mode, database=database ) # available connections try: pool_size = len(session._pool.connections[session._pool.address]) # noqa:E501 except KeyError: pool_size = 0 # run simple query try: results = session.run("return 1") # catch errors neo4j client may not handle properly except OSError as e: if type(e) is SSLError or e.strerror == "Bad file descriptor": raise ServiceUnavailable raise e except AttributeError as e: if str(e) == "'NoneType' object has no attribute 'complete'": raise ServiceUnavailable raise e # iterate over "results" to verify results.consume() return session except (SessionExpired, ServiceUnavailable) as e: retries = max(retries, pool_size) if retries > 0: return _create_session(access_mode, database=database, retries=retries - 1) raise e def _create_session_v2( access_mode: str, database: str = None, retries: int = 5 ) -> Session: """Create neo4j session with retries. In contrast to _create_session(...), pre-ping and pool_size functionality are based on the driver, not on the creation of a session. Args: access_mode (str): read or write mode database (str): name of the database to connect to. retries (int): attempts left on failure Returns: neo4j._sync.work.session.Session """ try: # Get available connection count try: pool_size = len(neo4j_driver._pool.connections[neo4j_driver._pool.address]) except KeyError: pool_size = 0 try: # Check neo4j driver connectivity neo4j_driver.verify_connectivity() # Catch errors neo4j driver may not handle properly # although my theory is that we should assume neo4j driver is handling them except OSError as e: if type(e) is SSLError or e.strerror == "Bad file descriptor": raise ServiceUnavailable raise e except AttributeError as e: if str(e) == "'NoneType' object has no attribute 'complete'": raise ServiceUnavailable raise e # No errors raised, let's get/return that session return neo4j_driver.session(default_access_mode=access_mode, database=database) except DriverError as e: if e.is_retryable(): retries = max(retries, pool_size) if retries > 0: return _create_session_v2( access_mode, database=database, retries=retries - 1 ) raise e class Neo4jSession(ContextDecorator): """Decorator for neo4j connection and transaction handling.""" def __init__( self, database=None, transaction=False, force_write_server=False, use_v2=False ): """Create decorator. Args: database (str): Name of the database to connect to. transaction (bool): Open (self-managed) transaction on __enter__. force_write_server (bool): Query write server explicitly. use_v2 (bool): Use updated session methods. Returns: None """ self.database = database self.transaction = transaction self.force_write_server = force_write_server self.use_v2 = use_v2 self.create_session_callable = _create_session_v2 if use_v2 else _create_session def __enter__(self): """Create neo4j session.""" if not neo4j_driver: _init_driver() try: _get_session() except exc.SessionNotCreated: if self.transaction: session = self.create_session_callable( WRITE_ACCESS, database=self.database ) session.begin_transaction() _set_session(session) else: access_mode = WRITE_ACCESS if self.force_write_server else READ_ACCESS _set_session( self.create_session_callable(access_mode, database=self.database) ) else: raise exc.SessionAlreadyOpen() def __exit__(self, exc_type, exc_val, exc_tb): """Finalize transaction (if open) and close neo4j session. Args: exc_type (class): type of exception raised exc_val (Exception): instance of exception raised exc_tb (Traceback): traceback of exception raised Returns: None """ session = _get_session() try: if self.transaction: # A failed query inside the transaction causes the driver to tear # it down (session._transaction is set to None). If that error was # swallowed upstream, exc_type is None here; guard against calling # commit() on a transaction the driver already closed. if not exc_type and session._transaction is not None: session._transaction.commit() finally: session.close() # will rollback open transaction _set_session(None) if not self.use_v2: # catch errors neo4j client may not handle properly if isinstance(exc_val, OSError): if exc_type == SSLError or exc_val.strerror == "Bad file descriptor": raise ServiceUnavailable elif isinstance(exc_val, TypeError): _, _, exception_traceback = sys.exc_info() exc_line = exception_traceback.tb_lineno exc_filename = exception_traceback.tb_frame.f_code.co_filename if ( exc_filename.split(os.path.sep)[-3:] == ["neo4j", "io", "__init__.py"] and exc_line == 980 ): raise ServiceUnavailable