From 027d5ab156a986f0c9a22cdb689ece004c0c23f8 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Wed, 1 Oct 2025 16:22:44 +0200 Subject: [PATCH 1/6] Add GitHub actions: windows unit tests --- .github/CODEOWNERS | 2 ++ .github/workflows/tests.yaml | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/tests.yaml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..ff2c165e2 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +/.github/CODEOWNERS @neo4j/drivers +/.github/workflows/ @neo4j/drivers diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 000000000..1de707bc2 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,52 @@ +name: Tests + +on: + push: + branches: + - '6.x' + pull_request: + branches: + - '6.x' + +jobs: + win-unit-tests: + name: Windows Unit Tests + runs-on: windows-latest + strategy: + matrix: + python-version: + - semver: '3.10' + tox-factor: 'py310' + - semver: '3.11' + tox-factor: 'py311' + - semver: '3.12' + tox-factor: 'py312' + - semver: '3.13' + tox-factor: 'py313' + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Set up Python ${{ matrix.python-version.semver }} + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + with: + python-version: ${{ matrix.python-version.semver }} + cache: 'pip' + + - name: Run install tox + run: python -m pip install -U --group tox + - name: Run unit tests + run: python -m tox -vv -f unit ${{ matrix.python-version.tox-factor }} + + gha-conclusion: + name: GHA Conclusion + needs: win-unit-tests + runs-on: ubuntu-latest + steps: + - name: Signal failure + if: ${{ cancelled() || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'failure') }} + run: | + echo "Some workflows have failed!" + exit 1 + - name: Signal success + if: ${{ !cancelled() && !contains(needs.*.result, 'cancelled') && !contains(needs.*.result, 'failure') }} + run: echo "All done!" From bbe760817256b674c8a72732b02be64e25dcc293 Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Thu, 2 Oct 2025 08:37:03 +0200 Subject: [PATCH 2/6] work around pytest on windows not liking long test ids --- tests/unit/common/vector/test_vector.py | 119 +++++++++++++++++++----- 1 file changed, 98 insertions(+), 21 deletions(-) diff --git a/tests/unit/common/vector/test_vector.py b/tests/unit/common/vector/test_vector.py index 496710338..7844517d8 100644 --- a/tests/unit/common/vector/test_vector.py +++ b/tests/unit/common/vector/test_vector.py @@ -275,37 +275,114 @@ def test_swap_endian_unhandled_size(mocker, ext, type_size): @pytest.mark.parametrize( ("dtype", "data"), ( - ("i8", b""), - ("i8", b"\x01"), - ("i8", b"\x01\x02\x03\x04"), - ("i8", _max_value_be_bytes(1, 4096)), - ("i16", b""), - ("i16", b"\x00\x01"), - ("i16", b"\x00\x01\x00\x02"), - ("i16", _max_value_be_bytes(2, 4096)), - ("i32", b""), - ("i32", b"\x00\x00\x00\x01"), - ("i32", b"\x00\x00\x00\x01\x00\x00\x00\x02"), - ("i32", _max_value_be_bytes(4, 4096)), - ("i64", b""), - ("i64", b"\x00\x00\x00\x00\x00\x00\x00\x01"), - ( + pytest.param( + "i8", + b"", + id="i8-empty", + ), + pytest.param( + "i8", + b"\x01", + id="i8-single", + ), + pytest.param( + "i8", + b"\x01\x02\x03\x04", + id="i8-some", + ), + pytest.param( + "i8", + _max_value_be_bytes(1, 4096), + id="i8-limit", + ), + pytest.param( + "i16", + b"", + id="i16-empty", + ), + pytest.param( + "i16", + b"\x00\x01", + id="i16-single", + ), + pytest.param( + "i16", + b"\x00\x01\x00\x02", + id="i16-some", + ), + pytest.param( + "i16", + _max_value_be_bytes(2, 4096), + id="i16-limit", + ), + pytest.param( + "i32", + b"", + id="i32-empty", + ), + pytest.param( + "i32", + b"\x00\x00\x00\x01", + id="i32-single", + ), + pytest.param( + "i32", + b"\x00\x00\x00\x01\x00\x00\x00\x02", + id="i32-some", + ), + pytest.param( + "i32", + _max_value_be_bytes(4, 4096), + id="i32-limit", + ), + pytest.param( + "i64", + b"", + id="i64-empty", + ), + pytest.param( + "i64", + b"\x00\x00\x00\x00\x00\x00\x00\x01", + id="i64-single", + ), + pytest.param( "i64", ( b"\x00\x00\x00\x00\x00\x00\x00\x01" b"\x00\x00\x00\x00\x00\x00\x00\x02" ), + id="i64-some", + ), + pytest.param( + "i64", + _max_value_be_bytes(8, 4096), + id="i64-limit", + ), + pytest.param( + "f32", + b"", + id="f32-empty", + ), + pytest.param( + "f32", + _random_value_be_bytes(4, 4096), + id="f32-limit", + ), + pytest.param( + "f64", + b"", + id="f64-empty", + ), + pytest.param( + "f64", + _random_value_be_bytes(8, 4096), + id="f64-limit", ), - ("i64", _max_value_be_bytes(8, 4096)), - ("f32", b""), - ("f32", _random_value_be_bytes(4, 4096)), - ("f64", b""), - ("f64", _random_value_be_bytes(8, 4096)), ), ) @pytest.mark.parametrize("input_endian", (None, *ENDIAN_LITERALS)) @pytest.mark.parametrize("as_bytearray", (False, True)) -def test_raw_data( +def test_raw_data_limits( dtype: t.Literal["i8", "i16", "i32", "i64", "f32", "f64"], data: bytes, input_endian: T_ENDIAN_LITERAL | None, From 67aad79f197da640e9bc343d84b13b1a899ea1f2 Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Thu, 2 Oct 2025 09:27:41 +0200 Subject: [PATCH 3/6] Fix DNS error re-write on Windows On windows only, the driver would re-write the DNS error when resolving `Address(None, None)` to be retryable, which it shouldn't. --- src/neo4j/_async_compat/network/_util.py | 19 ++++++++++++------- tests/unit/async_/test_addressing.py | 18 ++++++++++++++++-- tests/unit/sync/test_addressing.py | 18 ++++++++++++++++-- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/neo4j/_async_compat/network/_util.py b/src/neo4j/_async_compat/network/_util.py index 54206c253..85ea6e986 100644 --- a/src/neo4j/_async_compat/network/_util.py +++ b/src/neo4j/_async_compat/network/_util.py @@ -14,6 +14,8 @@ # limitations under the License. +from __future__ import annotations + import asyncio import contextlib import logging @@ -88,14 +90,17 @@ async def _dns_resolver(address, family=0): type=socket.SOCK_STREAM, ) except OSError as e: - if e.errno in _RETRYABLE_DNS_ERRNOS or ( - e.errno in _EAI_NONAME - and (address.host is not None or address.port is not None) + # note: on some systems like Windows, EAI_NONAME and EAI_NODATA + # have the same error-code. + if e.errno in _EAI_NONAME and ( + address.host is None and address.port is None ): - raise ServiceUnavailable( - f"Failed to DNS resolve address {address}: {e}" - ) from e - raise ValueError( + err_cls = ValueError + elif e.errno in _RETRYABLE_DNS_ERRNOS or e.errno in _EAI_NONAME: + err_cls = ServiceUnavailable + else: + err_cls = ValueError + raise err_cls( f"Failed to DNS resolve address {address}: {e}" ) from e return list(_resolved_addresses_from_info(info, address._host_name)) diff --git a/tests/unit/async_/test_addressing.py b/tests/unit/async_/test_addressing.py index b65a855ff..c66fda3f7 100644 --- a/tests/unit/async_/test_addressing.py +++ b/tests/unit/async_/test_addressing.py @@ -56,6 +56,7 @@ async def test_address_resolve_with_custom_resolver_none() -> None: [ (Address(("example.invalid", "7687")), ServiceUnavailable), (Address(("example.invalid", 7687)), ServiceUnavailable), + (Address(("example.invalid", None)), ServiceUnavailable), (Address(("127.0.0.1", "abcd")), ValueError), (Address((None, None)), ValueError), (Address((1234, "7687")), TypeError), @@ -65,14 +66,27 @@ async def test_address_resolve_with_custom_resolver_none() -> None: async def test_address_resolve_with_unresolvable_address( test_input, expected ) -> None: - # import contextlib - # with contextlib.suppress(Exception): with pytest.raises(expected): await AsyncUtil.list( AsyncNetworkUtil.resolve_address(test_input, resolver=None) ) +@pytest.mark.parametrize( + "test_input", + [ + Address((None, 7687)), + Address(("example.com", None)), + ], +) +@mark_async_test +async def test_address_resolves_with_none(test_input) -> None: + resolved = await AsyncUtil.list( + AsyncNetworkUtil.resolve_address(test_input, resolver=None) + ) + assert resolved + + @mark_async_test @pytest.mark.parametrize("resolver_type", ("sync", "async")) async def test_address_resolve_with_custom_resolver(resolver_type) -> None: diff --git a/tests/unit/sync/test_addressing.py b/tests/unit/sync/test_addressing.py index 0b3e497c5..0101fd2a0 100644 --- a/tests/unit/sync/test_addressing.py +++ b/tests/unit/sync/test_addressing.py @@ -56,6 +56,7 @@ def test_address_resolve_with_custom_resolver_none() -> None: [ (Address(("example.invalid", "7687")), ServiceUnavailable), (Address(("example.invalid", 7687)), ServiceUnavailable), + (Address(("example.invalid", None)), ServiceUnavailable), (Address(("127.0.0.1", "abcd")), ValueError), (Address((None, None)), ValueError), (Address((1234, "7687")), TypeError), @@ -65,14 +66,27 @@ def test_address_resolve_with_custom_resolver_none() -> None: def test_address_resolve_with_unresolvable_address( test_input, expected ) -> None: - # import contextlib - # with contextlib.suppress(Exception): with pytest.raises(expected): Util.list( NetworkUtil.resolve_address(test_input, resolver=None) ) +@pytest.mark.parametrize( + "test_input", + [ + Address((None, 7687)), + Address(("example.com", None)), + ], +) +@mark_sync_test +def test_address_resolves_with_none(test_input) -> None: + resolved = Util.list( + NetworkUtil.resolve_address(test_input, resolver=None) + ) + assert resolved + + @mark_sync_test @pytest.mark.parametrize("resolver_type", ("sync", "async")) def test_address_resolve_with_custom_resolver(resolver_type) -> None: From 6fe9de00d248ca4132b49966e45e2ecba449c209 Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Thu, 2 Oct 2025 09:41:13 +0200 Subject: [PATCH 4/6] fixup! Add GitHub actions: windows unit tests --- .github/workflows/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 1de707bc2..230bfdf2f 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -38,7 +38,7 @@ jobs: run: python -m tox -vv -f unit ${{ matrix.python-version.tox-factor }} gha-conclusion: - name: GHA Conclusion + name: gha-conclusion needs: win-unit-tests runs-on: ubuntu-latest steps: From 5a274dee8fba3ec97ba6b180d28165280c0cbded Mon Sep 17 00:00:00 2001 From: Robsdedude Date: Thu, 2 Oct 2025 09:56:20 +0200 Subject: [PATCH 5/6] fixup! Fix DNS error re-write on Windows --- src/neo4j/_async_compat/network/_util.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/neo4j/_async_compat/network/_util.py b/src/neo4j/_async_compat/network/_util.py index 85ea6e986..6b0361321 100644 --- a/src/neo4j/_async_compat/network/_util.py +++ b/src/neo4j/_async_compat/network/_util.py @@ -184,14 +184,17 @@ def _dns_resolver(address, family=0): type=socket.SOCK_STREAM, ) except OSError as e: - if e.errno in _RETRYABLE_DNS_ERRNOS or ( - e.errno in _EAI_NONAME - and (address.host is not None or address.port is not None) + # note: on some systems like Windows, EAI_NONAME and EAI_NODATA + # have the same error-code. + if e.errno in _EAI_NONAME and ( + address.host is None and address.port is None ): - raise ServiceUnavailable( - f"Failed to DNS resolve address {address}: {e}" - ) from e - raise ValueError( + err_cls = ValueError + elif e.errno in _RETRYABLE_DNS_ERRNOS or e.errno in _EAI_NONAME: + err_cls = ServiceUnavailable + else: + err_cls = ValueError + raise err_cls( f"Failed to DNS resolve address {address}: {e}" ) from e return _resolved_addresses_from_info(info, address._host_name) From 75e9ff78506930c38c20c567836867a5ef6876af Mon Sep 17 00:00:00 2001 From: Rouven Bauer Date: Thu, 2 Oct 2025 10:19:22 +0200 Subject: [PATCH 6/6] Fix test flakiness on systems with low monotonic clock resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 👀️🪟️ --- tests/unit/async_/io/test_neo4j_pool.py | 8 ++++++++ tests/unit/sync/io/test_neo4j_pool.py | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/tests/unit/async_/io/test_neo4j_pool.py b/tests/unit/async_/io/test_neo4j_pool.py index d74ab7684..7b2a73df7 100644 --- a/tests/unit/async_/io/test_neo4j_pool.py +++ b/tests/unit/async_/io/test_neo4j_pool.py @@ -17,6 +17,7 @@ import contextlib import inspect import sys +import time from copy import deepcopy import pytest @@ -32,6 +33,7 @@ AsyncBolt, AsyncNeo4jPool, ) +from neo4j._async_compat import async_sleep from neo4j._async_compat.util import AsyncUtil from neo4j._conf import ( RoutingConfig, @@ -49,6 +51,8 @@ from ...._async_compat import mark_async_test +MONOTONIC_TIME_RESOLUTION = time.get_clock_info("monotonic").resolution + ROUTER1_ADDRESS = ResolvedAddress(("1.2.3.1", 9000), host_name="host") ROUTER2_ADDRESS = ResolvedAddress(("1.2.3.1", 9001), host_name="host") ROUTER3_ADDRESS = ResolvedAddress(("1.2.3.1", 9002), host_name="host") @@ -197,6 +201,8 @@ async def test_acquires_new_routing_table_if_stale( old_value = pool.routing_tables[db.name].last_updated_time pool.routing_tables[db.name].ttl = 0 + await async_sleep(MONOTONIC_TIME_RESOLUTION * 2) + cx = await pool.acquire(READ_ACCESS, 30, db, None, None, None) await pool.release(cx) assert pool.routing_tables[db.name].last_updated_time > old_value @@ -218,6 +224,8 @@ async def test_removes_old_routing_table(opener): db2_rt = pool.routing_tables[TEST_DB2.name] db2_rt.ttl = -RoutingConfig.routing_table_purge_delay + await async_sleep(MONOTONIC_TIME_RESOLUTION * 2) + cx = await pool.acquire(READ_ACCESS, 30, TEST_DB1, None, None, None) await pool.release(cx) assert pool.routing_tables[TEST_DB1.name].last_updated_time > old_value diff --git a/tests/unit/sync/io/test_neo4j_pool.py b/tests/unit/sync/io/test_neo4j_pool.py index d2e455676..b84d9bb2a 100644 --- a/tests/unit/sync/io/test_neo4j_pool.py +++ b/tests/unit/sync/io/test_neo4j_pool.py @@ -17,6 +17,7 @@ import contextlib import inspect import sys +import time from copy import deepcopy import pytest @@ -26,6 +27,7 @@ WRITE_ACCESS, ) from neo4j._addressing import ResolvedAddress +from neo4j._async_compat import sleep from neo4j._async_compat.util import Util from neo4j._conf import ( RoutingConfig, @@ -49,6 +51,8 @@ from ...._async_compat import mark_sync_test +MONOTONIC_TIME_RESOLUTION = time.get_clock_info("monotonic").resolution + ROUTER1_ADDRESS = ResolvedAddress(("1.2.3.1", 9000), host_name="host") ROUTER2_ADDRESS = ResolvedAddress(("1.2.3.1", 9001), host_name="host") ROUTER3_ADDRESS = ResolvedAddress(("1.2.3.1", 9002), host_name="host") @@ -197,6 +201,8 @@ def test_acquires_new_routing_table_if_stale( old_value = pool.routing_tables[db.name].last_updated_time pool.routing_tables[db.name].ttl = 0 + sleep(MONOTONIC_TIME_RESOLUTION * 2) + cx = pool.acquire(READ_ACCESS, 30, db, None, None, None) pool.release(cx) assert pool.routing_tables[db.name].last_updated_time > old_value @@ -218,6 +224,8 @@ def test_removes_old_routing_table(opener): db2_rt = pool.routing_tables[TEST_DB2.name] db2_rt.ttl = -RoutingConfig.routing_table_purge_delay + sleep(MONOTONIC_TIME_RESOLUTION * 2) + cx = pool.acquire(READ_ACCESS, 30, TEST_DB1, None, None, None) pool.release(cx) assert pool.routing_tables[TEST_DB1.name].last_updated_time > old_value