diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac71da9a..e98eeeb85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Neo4j Driver Change Log +## Version 5.0 + +- Python 3.10 support added +- Python 3.6 support has been dropped. + + ## Version 4.4 - Python 3.5 support has been dropped. diff --git a/README.rst b/README.rst index d905dc7c5..913fe572a 100644 --- a/README.rst +++ b/README.rst @@ -6,10 +6,10 @@ This repository contains the official Neo4j driver for Python. Each driver release (from 4.0 upwards) is built specifically to work with a corresponding Neo4j release, i.e. that with the same `major.minor` version number. These drivers will also be compatible with the previous Neo4j release, although new server features will not be available. ++ Python 3.10 supported. + Python 3.9 supported. + Python 3.8 supported. + Python 3.7 supported. -+ Python 3.6 supported. Python 2.7 support has been dropped as of the Neo4j 4.0 release. diff --git a/TESTING.md b/TESTING.md index 88d99878c..cddc698b1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,7 +1,7 @@ # Neo4j Driver Testing To run driver tests, [Tox](https://tox.readthedocs.io) is required as well as at least one version of Python. -The versions of Python supported by this driver are CPython 3.6, 3.7, 3.8, and 3.9. +The versions of Python supported by this driver are CPython 3.7, 3.8, 3.9, and 3.10. ## Unit Tests & Stub Tests diff --git a/docs/source/index.rst b/docs/source/index.rst index feb8d90d4..ac4bda164 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,20 +6,15 @@ The Official Neo4j Driver for Python. Neo4j versions supported: +* Neo4j 5.0 * Neo4j 4.4 -* Neo4j 4.3 -* Neo4j 3.5 Python versions supported: +* Python 3.10 * Python 3.9 * Python 3.8 * Python 3.7 -* Python 3.6 - -.. note:: - - The `Python Driver 1.7`_ supports older versions of python, **Neo4j 4.1** will work in fallback mode with that driver. ****** diff --git a/neo4j/_driver.py b/neo4j/_driver.py new file mode 100644 index 000000000..c8aa688b9 --- /dev/null +++ b/neo4j/_driver.py @@ -0,0 +1,281 @@ +# Copyright (c) "Neo4j" +# Neo4j Sweden AB [http://neo4j.com] +# +# This file is part of Neo4j. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +from .addressing import Address +from .api import READ_ACCESS +from .conf import ( + Config, + PoolConfig, + SessionConfig, + WorkspaceConfig, +) +from .meta import experimental +from .work.simple import Session + + +class Direct: + + default_host = "localhost" + default_port = 7687 + + default_target = ":" + + def __init__(self, address): + self._address = address + + @property + def address(self): + return self._address + + @classmethod + def parse_target(cls, target): + """ Parse a target string to produce an address. + """ + if not target: + target = cls.default_target + address = Address.parse(target, default_host=cls.default_host, + default_port=cls.default_port) + return address + + +class Routing: + + default_host = "localhost" + default_port = 7687 + + default_targets = ": :17601 :17687" + + def __init__(self, initial_addresses): + self._initial_addresses = initial_addresses + + @property + def initial_addresses(self): + return self._initial_addresses + + @classmethod + def parse_targets(cls, *targets): + """ Parse a sequence of target strings to produce an address + list. + """ + targets = " ".join(targets) + if not targets: + targets = cls.default_targets + addresses = Address.parse_list(targets, default_host=cls.default_host, default_port=cls.default_port) + return addresses + + +class Driver: + """ Base class for all types of :class:`neo4j.Driver`, instances of which are + used as the primary access point to Neo4j. + """ + + #: Connection pool + _pool = None + + def __init__(self, pool): + assert pool is not None + self._pool = pool + + def __del__(self): + self.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + @property + def encrypted(self): + return bool(self._pool.pool_config.encrypted) + + def session(self, **config): + """Create a session, see :ref:`session-construction-ref` + + :param config: session configuration key-word arguments, see :ref:`session-configuration-ref` for available key-word arguments. + + :returns: new :class:`neo4j.Session` object + """ + raise NotImplementedError + + @experimental("The pipeline API is experimental and may be removed or changed in a future release") + def pipeline(self, **config): + """ Create a pipeline. + """ + raise NotImplementedError + + def close(self): + """ Shut down, closing any open connections in the pool. + """ + self._pool.close() + + @experimental("The configuration may change in the future.") + def verify_connectivity(self, **config): + """ This verifies if the driver can connect to a remote server or a cluster + by establishing a network connection with the remote and possibly exchanging + a few data before closing the connection. It throws exception if fails to connect. + + Use the exception to further understand the cause of the connectivity problem. + + Note: Even if this method throws an exception, the driver still need to be closed via close() to free up all resources. + """ + raise NotImplementedError + + @experimental("Feature support query, based on Bolt Protocol Version and Neo4j Server Version will change in the future.") + def supports_multi_db(self): + """ Check if the server or cluster supports multi-databases. + + :return: Returns true if the server or cluster the driver connects to supports multi-databases, otherwise false. + :rtype: bool + """ + with self.session() as session: + session._connect(READ_ACCESS) + return session._connection.supports_multiple_databases + + +class BoltDriver(Direct, Driver): + """ A :class:`.BoltDriver` is created from a ``bolt`` URI and addresses + a single database machine. This may be a standalone server or could be a + specific member of a cluster. + + Connections established by a :class:`.BoltDriver` are always made to the + exact host and port detailed in the URI. + """ + + @classmethod + def open(cls, target, *, auth=None, **config): + """ + :param target: + :param auth: + :param config: The values that can be specified are found in :class: `neo4j.PoolConfig` and :class: `neo4j.WorkspaceConfig` + + :return: + :rtype: :class: `neo4j.BoltDriver` + """ + from neo4j.io import BoltPool + address = cls.parse_target(target) + pool_config, default_workspace_config = Config.consume_chain(config, PoolConfig, WorkspaceConfig) + pool = BoltPool.open(address, auth=auth, pool_config=pool_config, workspace_config=default_workspace_config) + return cls(pool, default_workspace_config) + + def __init__(self, pool, default_workspace_config): + Direct.__init__(self, pool.address) + Driver.__init__(self, pool) + self._default_workspace_config = default_workspace_config + + def session(self, **config): + """ + :param config: The values that can be specified are found in :class: `neo4j.SessionConfig` + + :return: + :rtype: :class: `neo4j.Session` + """ + from neo4j.work.simple import Session + session_config = SessionConfig(self._default_workspace_config, config) + SessionConfig.consume(config) # Consume the config + return Session(self._pool, session_config) + + def pipeline(self, **config): + from neo4j.work.pipelining import ( + Pipeline, + PipelineConfig, + ) + pipeline_config = PipelineConfig(self._default_workspace_config, config) + PipelineConfig.consume(config) # Consume the config + return Pipeline(self._pool, pipeline_config) + + @experimental("The configuration may change in the future.") + def verify_connectivity(self, **config): + server_agent = None + config["fetch_size"] = -1 + with self.session(**config) as session: + result = session.run("RETURN 1 AS x") + value = result.single().value() + summary = result.consume() + server_agent = summary.server.agent + return server_agent + + +class Neo4jDriver(Routing, Driver): + """ A :class:`.Neo4jDriver` is created from a ``neo4j`` URI. The + routing behaviour works in tandem with Neo4j's `Causal Clustering + `_ + feature by directing read and write behaviour to appropriate + cluster members. + """ + + @classmethod + def open(cls, *targets, auth=None, routing_context=None, **config): + from neo4j.io import Neo4jPool + addresses = cls.parse_targets(*targets) + pool_config, default_workspace_config = Config.consume_chain(config, PoolConfig, WorkspaceConfig) + pool = Neo4jPool.open(*addresses, auth=auth, routing_context=routing_context, pool_config=pool_config, workspace_config=default_workspace_config) + return cls(pool, default_workspace_config) + + def __init__(self, pool, default_workspace_config): + Routing.__init__(self, pool.get_default_database_initial_router_addresses()) + Driver.__init__(self, pool) + self._default_workspace_config = default_workspace_config + + def session(self, **config): + session_config = SessionConfig(self._default_workspace_config, config) + SessionConfig.consume(config) # Consume the config + return Session(self._pool, session_config) + + def pipeline(self, **config): + from neo4j.work.pipelining import ( + Pipeline, + PipelineConfig, + ) + pipeline_config = PipelineConfig(self._default_workspace_config, config) + PipelineConfig.consume(config) # Consume the config + return Pipeline(self._pool, pipeline_config) + + @experimental("The configuration may change in the future.") + def verify_connectivity(self, **config): + """ + :raise ServiceUnavailable: raised if the server does not support routing or if routing support is broken. + """ + # TODO: Improve and update Stub Test Server to be able to test. + return self._verify_routing_connectivity() + + def _verify_routing_connectivity(self): + from neo4j.exceptions import ( + Neo4jError, + ServiceUnavailable, + SessionExpired, + ) + + table = self._pool.get_routing_table_for_default_database() + routing_info = {} + for ix in list(table.routers): + try: + routing_info[ix] = self._pool.fetch_routing_info( + address=table.routers[0], + database=self._default_workspace_config.database, + imp_user=self._default_workspace_config.impersonated_user, + bookmarks=None, + timeout=self._default_workspace_config + .connection_acquisition_timeout + ) + except (ServiceUnavailable, SessionExpired, Neo4jError): + routing_info[ix] = None + for key, val in routing_info.items(): + if val is not None: + return routing_info + raise ServiceUnavailable("Could not connect to any routing servers.") diff --git a/neo4j/conf.py b/neo4j/conf.py index f74dd2e51..fb86c4891 100644 --- a/neo4j/conf.py +++ b/neo4j/conf.py @@ -71,7 +71,9 @@ def __new__(mcs, name, bases, attributes): for k, v in attributes.items(): if isinstance(v, DeprecatedAlias): deprecated_aliases[k] = v.new - elif not k.startswith("_") and not callable(v): + elif not (k.startswith("_") + or callable(v) + or isinstance(v, (staticmethod, classmethod))): fields.append(k) def keys(_): @@ -232,18 +234,19 @@ def get_ssl_context(self): # TLS 1.1 - Released in 2006, published as RFC 4346. (Disabled) # TLS 1.2 - Released in 2008, published as RFC 5246. - # https://docs.python.org/3.6/library/ssl.html#ssl.PROTOCOL_TLS_CLIENT + # https://docs.python.org/3.7/library/ssl.html#ssl.PROTOCOL_TLS_CLIENT ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # For recommended security options see - # https://docs.python.org/3.6/library/ssl.html#protocol-versions + # https://docs.python.org/3.7/library/ssl.html#protocol-versions ssl_context.options |= ssl.OP_NO_TLSv1 # Python 3.2 ssl_context.options |= ssl.OP_NO_TLSv1_1 # Python 3.4 if self.trust == TRUST_ALL_CERTIFICATES: ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE # https://docs.python.org/3.5/library/ssl.html#ssl.CERT_NONE + # https://docs.python.org/3.7/library/ssl.html#ssl.CERT_NONE + ssl_context.verify_mode = ssl.CERT_NONE # Must be load_default_certs, not set_default_verify_paths to work # on Windows with system CAs. diff --git a/neo4j/meta.py b/neo4j/meta.py index 78f5de819..dd727eabb 100644 --- a/neo4j/meta.py +++ b/neo4j/meta.py @@ -24,7 +24,7 @@ # Can be automatically overridden in builds package = "neo4j" -version = "4.4.dev0" +version = "5.0.dev0" def get_user_agent(): diff --git a/setup.py b/setup.py index 349b6391d..24384398b 100644 --- a/setup.py +++ b/setup.py @@ -33,10 +33,10 @@ "Operating System :: OS Independent", "Topic :: Database", "Topic :: Software Development", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ] entry_points = { "console_scripts": [ @@ -62,7 +62,7 @@ "classifiers": classifiers, "packages": packages, "entry_points": entry_points, - "python_requires": ">=3.6", + "python_requires": ">=3.7", } setup(**setup_args) diff --git a/testkit/Dockerfile b/testkit/Dockerfile index fe8130ae5..021850cbb 100644 --- a/testkit/Dockerfile +++ b/testkit/Dockerfile @@ -40,9 +40,9 @@ ENV PYENV_ROOT /.pyenv ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH # Set minimum supported Python version -RUN pyenv install 3.6.13 +RUN pyenv install 3.7.12 RUN pyenv rehash -RUN pyenv global 3.6.13 +RUN pyenv global 3.7.12 # Install Latest pip for each environment # https://pip.pypa.io/en/stable/news/ diff --git a/tests/unit/test_conf.py b/tests/unit/test_conf.py index 6e685edae..9eb718194 100644 --- a/tests/unit/test_conf.py +++ b/tests/unit/test_conf.py @@ -67,8 +67,6 @@ "fetch_size": 100, } -config_function_names = ["consume_chain", "consume"] - def test_pool_config_consume(): @@ -84,10 +82,9 @@ def test_pool_config_consume(): assert consumed_pool_config[key] == test_pool_config[key] for key in consumed_pool_config.keys(): - if key not in config_function_names: - assert test_pool_config[key] == consumed_pool_config[key] + assert test_pool_config[key] == consumed_pool_config[key] - assert len(consumed_pool_config) - len(config_function_names) == len(test_pool_config) + assert len(consumed_pool_config) == len(test_pool_config) def test_pool_config_consume_default_values(): @@ -171,12 +168,11 @@ def test_config_consume_chain(): assert consumed_pool_config[key] == val for key, val in consumed_pool_config.items(): - if key not in config_function_names: - assert test_pool_config[key] == val + assert test_pool_config[key] == val - assert len(consumed_pool_config) - len(config_function_names) == len(test_pool_config) + assert len(consumed_pool_config) == len(test_pool_config) - assert len(consumed_session_config) - len(config_function_names) == len(test_session_config) + assert len(consumed_session_config) == len(test_session_config) def test_init_session_config_merge(): diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 2152a5b6d..f574ce671 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -71,7 +71,7 @@ def test_bolt_error(): with pytest.raises(BoltError) as e: error = BoltError("Error Message", address="localhost") - # assert repr(error) == "BoltError('Error Message')" This differs between python version 3.6 "BoltError('Error Message',)" and 3.7 + assert repr(error) == "BoltError('Error Message')" assert str(error) == "Error Message" assert error.args == ("Error Message",) assert error.address == "localhost" diff --git a/tox-performance.ini b/tox-performance.ini index 79e39ed59..3c7779a16 100644 --- a/tox-performance.ini +++ b/tox-performance.ini @@ -1,8 +1,9 @@ [tox] envlist = - py34 - py35 - py36 + py37 + py38 + py39 + py310 [testenv] passenv = diff --git a/tox-unit.ini b/tox-unit.ini index 8ac52441f..11307a04a 100644 --- a/tox-unit.ini +++ b/tox-unit.ini @@ -1,6 +1,6 @@ [tox] envlist = - py36 + py37 [testenv] deps = diff --git a/tox.ini b/tox.ini index 36eb6506d..1407cad26 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist = - py36 py37 py38 py39 + py310 [testenv] passenv =