diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac71da9..28c3b606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Neo4j Driver Change Log +## Version 4.4.9 + +- Python 3.10 support added + + ## Version 4.4 - Python 3.5 support has been dropped. diff --git a/README.rst b/README.rst index d905dc7c..d5e28881 100644 --- a/README.rst +++ b/README.rst @@ -6,6 +6,7 @@ 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. diff --git a/TESTING.md b/TESTING.md index 88d99878..93a4c9f2 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.6, 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 b0b973b7..42cf1105 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,6 +12,7 @@ Neo4j versions supported: Python versions supported: +* Python 3.10 (added in driver version 4.4.9) * Python 3.9 * Python 3.8 * Python 3.7 diff --git a/neo4j/conf.py b/neo4j/conf.py index cb18ee26..3f3f365f 100644 --- a/neo4j/conf.py +++ b/neo4j/conf.py @@ -21,8 +21,8 @@ from abc import ABCMeta from collections.abc import Mapping +import sys import warnings -from warnings import warn from neo4j.meta import ( deprecation_warn, @@ -83,7 +83,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) if isinstance(v, DeprecatedOption): deprecated_options[k] = v.value @@ -286,8 +288,11 @@ def get_ssl_context(self): # For recommended security options see # https://docs.python.org/3.6/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 sys.version_info >= (3, 7): + ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2 # Python 3.10 + else: + 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: diff --git a/setup.py b/setup.py index 349b6391..45358ce1 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", ] entry_points = { "console_scripts": [ diff --git a/testkit/Dockerfile b/testkit/Dockerfile index 4685d473..34d781a0 100644 --- a/testkit/Dockerfile +++ b/testkit/Dockerfile @@ -40,7 +40,7 @@ ENV PYENV_ROOT /.pyenv ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH # Setup python version -ENV PYTHON_VERSIONS 3.6 3.7 3.8 3.9 +ENV PYTHON_VERSIONS 3.6 3.7 3.8 3.9 3.10 RUN for version in $PYTHON_VERSIONS; do \ pyenv install $version:latest; \ diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 015ba64d..55526f18 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -25,13 +25,11 @@ from threading import RLock import pytest -import urllib +import warnings -from neo4j import ( - GraphDatabase, -) -from neo4j.exceptions import ServiceUnavailable +from neo4j import GraphDatabase from neo4j._exceptions import BoltHandshakeError +from neo4j.exceptions import ServiceUnavailable from neo4j.io import Bolt # import logging @@ -76,7 +74,9 @@ def __init__(self, name=None, image=None, auth=None, n_cores=None, n_replicas=None, bolt_port=None, http_port=None, debug_port=None, debug_suspend=None, dir_spec=None, config=None): - from boltkit.legacy.controller import _install, create_controller + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ImportWarning) + from boltkit.legacy.controller import _install, create_controller assert image.endswith("-enterprise") release = image[:-11] if release == "snapshot": @@ -189,7 +189,9 @@ def service(request): if existing_service: NEO4J_SERVICE = existing_service else: - NEO4J_SERVICE = Neo4jService(auth=NEO4J_AUTH, image=request.param, n_cores=NEO4J_CORES, n_replicas=NEO4J_REPLICAS) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + NEO4J_SERVICE = Neo4jService(auth=NEO4J_AUTH, image=request.param, n_cores=NEO4J_CORES, n_replicas=NEO4J_REPLICAS) NEO4J_SERVICE.start(timeout=300) yield NEO4J_SERVICE if NEO4J_SERVICE is not None: diff --git a/tests/requirements.txt b/tests/requirements.txt index 56b4d66e..4f9b30c2 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -3,6 +3,6 @@ coverage pytest pytest-benchmark pytest-cov -pytest-mock +pytest-mock~=3.6.1 teamcity-messages pandas>=1.0.0 diff --git a/tests/stub/conftest.py b/tests/stub/conftest.py index 6a9c4aa5..28e84e7a 100644 --- a/tests/stub/conftest.py +++ b/tests/stub/conftest.py @@ -21,12 +21,15 @@ import subprocess import os +import warnings from platform import system from threading import Thread from time import sleep -from boltkit.server.stub import BoltStubService +with warnings.catch_warnings(): + warnings.simplefilter("ignore", ImportWarning) + from boltkit.server.stub import BoltStubService from pytest import fixture import logging diff --git a/tests/unit/test_conf.py b/tests/unit/test_conf.py index 561a1330..e1b51d51 100644 --- a/tests/unit/test_conf.py +++ b/tests/unit/test_conf.py @@ -20,6 +20,8 @@ from contextlib import contextmanager +import ssl +import sys import warnings import pytest @@ -34,9 +36,10 @@ SessionConfig, ) from neo4j.api import ( + READ_ACCESS, + TRUST_ALL_CERTIFICATES, TRUST_SYSTEM_CA_SIGNED_CERTIFICATES, WRITE_ACCESS, - READ_ACCESS, ) # python -m pytest tests/unit/test_conf.py -s -v @@ -78,15 +81,15 @@ @contextmanager def _pool_config_deprecations(): with pytest.warns(DeprecationWarning, - match="update_routing_table_timeout") as warnings: - yield warnings + match="update_routing_table_timeout") as warnings_: + yield warnings_ @contextmanager def _session_config_deprecations(): with pytest.warns(DeprecationWarning, - match="session_connection_timeout") as warnings: - yield warnings + match="session_connection_timeout") as warnings_: + yield warnings_ def test_pool_config_consume(): @@ -103,12 +106,10 @@ def test_pool_config_consume(): for key in test_pool_config.keys(): 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] + for key, val in consumed_pool_config.items(): + 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) def test_pool_config_consume_default_values(): @@ -135,8 +136,7 @@ def test_pool_config_consume_key_not_valid(): with pytest.raises(ConfigurationError) as error: # might or might not warn DeprecationWarning, but we're only # interested in the error - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) + with _pool_config_deprecations(): _ = PoolConfig.consume(test_config) error.match("Unexpected config keys: not_valid_key") @@ -165,8 +165,10 @@ def test_pool_config_set_value(): def test_pool_config_consume_and_then_consume_again(): test_config = dict(test_pool_config) + with _pool_config_deprecations(): consumed_pool_config = PoolConfig.consume(test_config) + assert consumed_pool_config.encrypted is False consumed_pool_config.encrypted = "test" @@ -192,10 +194,9 @@ def test_config_consume_chain(): test_config.update(test_session_config) with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - consumed_pool_config, consumed_session_config = Config.consume_chain( - test_config, PoolConfig, SessionConfig - ) + warnings.simplefilter("ignore", DeprecationWarning) + consumed_pool_config, consumed_session_config = \ + Config.consume_chain(test_config, PoolConfig, SessionConfig) assert isinstance(consumed_pool_config, PoolConfig) assert isinstance(consumed_session_config, SessionConfig) @@ -206,14 +207,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(): @@ -222,19 +220,23 @@ def test_init_session_config_merge(): test_config_a = {"connection_acquisition_timeout": 111} test_config_c = {"max_transaction_retry_time": 222} - workspace_config = WorkspaceConfig(test_config_a, WorkspaceConfig.consume(test_config_c)) + workspace_config = WorkspaceConfig(test_config_a, + WorkspaceConfig.consume(test_config_c)) assert len(test_config_a) == 1 assert len(test_config_c) == 0 assert isinstance(workspace_config, WorkspaceConfig) - assert workspace_config.connection_acquisition_timeout == WorkspaceConfig.connection_acquisition_timeout + assert workspace_config.connection_acquisition_timeout == \ + WorkspaceConfig.connection_acquisition_timeout assert workspace_config.max_transaction_retry_time == 222 workspace_config = WorkspaceConfig(test_config_c, test_config_a) assert isinstance(workspace_config, WorkspaceConfig) assert workspace_config.connection_acquisition_timeout == 111 - assert workspace_config.max_transaction_retry_time == WorkspaceConfig.max_transaction_retry_time + assert workspace_config.max_transaction_retry_time == \ + WorkspaceConfig.max_transaction_retry_time - test_config_b = {"default_access_mode": READ_ACCESS, "connection_acquisition_timeout": 333} + test_config_b = {"default_access_mode": READ_ACCESS, + "connection_acquisition_timeout": 333} session_config = SessionConfig(workspace_config, test_config_b) assert session_config.connection_acquisition_timeout == 333 @@ -251,7 +253,11 @@ def test_init_session_config_with_not_valid_key(): test_config_a = {"connection_acquisition_timeout": 111} workspace_config = WorkspaceConfig.consume(test_config_a) - test_config_b = {"default_access_mode": READ_ACCESS, "connection_acquisition_timeout": 333, "not_valid_key": None} + test_config_b = { + "default_access_mode": READ_ACCESS, + "connection_acquisition_timeout": 333, + "not_valid_key": None, + } session_config = SessionConfig(workspace_config, test_config_b) with pytest.raises(AttributeError): @@ -279,3 +285,111 @@ def test_session_config_deprecated_session_connection_timeout(): with warnings.catch_warnings(): warnings.simplefilter("error") _ = SessionConfig.consume({}) + + +@pytest.mark.parametrize("config", ( + {}, + {"encrypted": False}, + {"trust": TRUST_SYSTEM_CA_SIGNED_CERTIFICATES}, + {"trust": TRUST_ALL_CERTIFICATES}, +)) +def test_no_ssl_mock(config, mocker): + ssl_context_mock = mocker.patch("ssl.SSLContext", autospec=True) + pool_config = PoolConfig.consume(config) + assert pool_config.encrypted is False + assert pool_config.get_ssl_context() is None + ssl_context_mock.assert_not_called() + + +@pytest.mark.parametrize("config", ( + {"encrypted": True}, + {"encrypted": True, "trust": TRUST_SYSTEM_CA_SIGNED_CERTIFICATES}, +)) +def test_trust_system_cas_mock(config, mocker): + ssl_context_mock = mocker.patch("ssl.SSLContext", autospec=True) + ssl_context_mock.return_value.options = 0 + pool_config = PoolConfig.consume(config) + assert pool_config.encrypted is True + ssl_context = pool_config.get_ssl_context() + _assert_mock_tls_1_2(ssl_context_mock) + ssl_context_mock.return_value.load_default_certs.assert_called_once_with() + ssl_context_mock.return_value.load_verify_locations.assert_not_called() + assert ssl_context.check_hostname + assert ssl_context.verify_mode + + +@pytest.mark.parametrize("config", ( + {"encrypted": True, "trust": TRUST_ALL_CERTIFICATES}, +)) +def test_trust_all_mock(config, mocker): + ssl_context_mock = mocker.patch("ssl.SSLContext", autospec=True) + ssl_context_mock.return_value.options = 0 + pool_config = PoolConfig.consume(config) + assert pool_config.encrypted is True + ssl_context = pool_config.get_ssl_context() + _assert_mock_tls_1_2(ssl_context_mock) + ssl_context_mock.return_value.load_verify_locations.assert_not_called() + assert ssl_context.check_hostname is False + assert ssl_context.verify_mode is ssl.CERT_NONE + + +def _assert_mock_tls_1_2(mock): + mock.assert_called_once_with(ssl.PROTOCOL_TLS_CLIENT) + if sys.version_info >= (3, 7): + assert mock.return_value.minimum_version == ssl.TLSVersion.TLSv1_2 + else: + assert mock.return_value.options & ssl.OP_NO_TLSv1 + assert mock.return_value.options & ssl.OP_NO_TLSv1_1 + assert not mock.return_value.options & ssl.OP_NO_TLSv1_2 + assert not mock.return_value.options & ssl.OP_NO_TLSv1_3 + + +@pytest.mark.parametrize("config", ( + {}, + {"encrypted": False}, + {"trust": TRUST_SYSTEM_CA_SIGNED_CERTIFICATES}, + {"trust": TRUST_ALL_CERTIFICATES}, +)) +def test_no_ssl(config): + pool_config = PoolConfig.consume(config) + assert pool_config.encrypted is False + assert pool_config.get_ssl_context() is None + + +@pytest.mark.parametrize("config", ( + {"encrypted": True}, + {"encrypted": True, "trust": TRUST_SYSTEM_CA_SIGNED_CERTIFICATES}, +)) +def test_trust_system_cas(config): + pool_config = PoolConfig.consume(config) + assert pool_config.encrypted is True + ssl_context = pool_config.get_ssl_context() + assert isinstance(ssl_context, ssl.SSLContext) + _assert_context_tls_1_2(ssl_context) + assert ssl_context.check_hostname is True + assert ssl_context.verify_mode == ssl.CERT_REQUIRED + + +@pytest.mark.parametrize("config", ( + {"encrypted": True, "trust": TRUST_ALL_CERTIFICATES}, +)) +def test_trust_all(config): + pool_config = PoolConfig.consume(config) + assert pool_config.encrypted is True + ssl_context = pool_config.get_ssl_context() + assert isinstance(ssl_context, ssl.SSLContext) + _assert_context_tls_1_2(ssl_context) + assert ssl_context.check_hostname is False + assert ssl_context.verify_mode is ssl.CERT_NONE + + +def _assert_context_tls_1_2(ctx): + if sys.version_info >= (3, 7): + assert ctx.protocol == ssl.PROTOCOL_TLS_CLIENT + assert ctx.minimum_version == ssl.TLSVersion.TLSv1_2 + else: + assert ctx.protocol == ssl.PROTOCOL_TLS_CLIENT + assert ctx.options & ssl.OP_NO_TLSv1 + assert ctx.options & ssl.OP_NO_TLSv1_1 + assert not ctx.options & ssl.OP_NO_TLSv1_2 + assert not ctx.options & ssl.OP_NO_TLSv1_3 diff --git a/tests/unit/work/test_result.py b/tests/unit/work/test_result.py index 7ce85ba4..d5eb0911 100644 --- a/tests/unit/work/test_result.py +++ b/tests/unit/work/test_result.py @@ -22,7 +22,9 @@ from unittest import mock import warnings -import pandas as pd +with warnings.catch_warnings(): + warnings.simplefilter("ignore", ImportWarning) + import pandas as pd import pytest from neo4j import ( diff --git a/tox-unit.ini b/tox-unit.ini index 8d771723..d4d7664e 100644 --- a/tox-unit.ini +++ b/tox-unit.ini @@ -4,6 +4,7 @@ envlist = py37 py38 py39 + py310 [testenv] deps = diff --git a/tox.ini b/tox.ini index 17afb1b7..15413083 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ envlist = py37 py38 py39 + py310 [testenv] passenv =