Skip to content

Commit 060da4a

Browse files
committed
feature: optionally support uvloop
1 parent e76b1e0 commit 060da4a

File tree

4 files changed

+195
-7
lines changed

4 files changed

+195
-7
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ remote_tests = [
9292
"requests",
9393
]
9494
optional = ["rich", "universal-pathlib"]
95+
uvloop = ["uvloop"]
9596
docs = [
9697
# Doc building
9798
'sphinx==8.1.3',

src/zarr/core/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def enable_gpu(self) -> ConfigSet:
107107
"order": "C",
108108
"write_empty_chunks": False,
109109
},
110-
"async": {"concurrency": 10, "timeout": None},
110+
"async": {"concurrency": 10, "timeout": None, "use_uvloop": True},
111111
"threading": {"max_workers": None},
112112
"json_indent": 2,
113113
"codec_pipeline": {

src/zarr/core/sync.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import atexit
55
import logging
66
import os
7+
import sys
78
import threading
89
from concurrent.futures import ThreadPoolExecutor, wait
910
from typing import TYPE_CHECKING, TypeVar
@@ -165,6 +166,31 @@ def sync(
165166
return return_result
166167

167168

169+
def _create_event_loop() -> asyncio.AbstractEventLoop:
170+
"""Create a new event loop, optionally using uvloop if available and enabled."""
171+
use_uvloop = config.get("async.use_uvloop", True)
172+
173+
if use_uvloop and sys.platform != "win32":
174+
try:
175+
import uvloop
176+
177+
logger.debug("Creating Zarr event loop with uvloop")
178+
# uvloop.new_event_loop() returns a loop compatible with AbstractEventLoop
179+
loop: asyncio.AbstractEventLoop = uvloop.new_event_loop()
180+
except ImportError:
181+
logger.debug("uvloop not available, falling back to asyncio")
182+
else:
183+
return loop
184+
else:
185+
if not use_uvloop:
186+
logger.debug("uvloop disabled via config, using asyncio")
187+
else:
188+
logger.debug("uvloop not supported on Windows, using asyncio")
189+
190+
logger.debug("Creating Zarr event loop with asyncio")
191+
return asyncio.new_event_loop()
192+
193+
168194
def _get_loop() -> asyncio.AbstractEventLoop:
169195
"""Create or return the default fsspec IO loop
170196
@@ -175,8 +201,7 @@ def _get_loop() -> asyncio.AbstractEventLoop:
175201
# repeat the check just in case the loop got filled between the
176202
# previous two calls from another thread
177203
if loop[0] is None:
178-
logger.debug("Creating Zarr event loop")
179-
new_loop = asyncio.new_event_loop()
204+
new_loop = _create_event_loop()
180205
loop[0] = new_loop
181206
iothread[0] = threading.Thread(target=new_loop.run_forever, name="zarr_io")
182207
assert iothread[0] is not None

tests/test_sync.py

Lines changed: 166 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import asyncio
2+
import importlib.util
3+
import sys
24
from collections.abc import AsyncGenerator
3-
from unittest.mock import AsyncMock, patch
5+
from unittest.mock import AsyncMock, call, patch
46

57
import pytest
68

79
import zarr
810
from zarr.core.sync import (
911
SyncError,
1012
SyncMixin,
13+
_create_event_loop,
1114
_get_executor,
1215
_get_lock,
1316
_get_loop,
@@ -150,16 +153,175 @@ def test_threadpool_executor(clean_state, workers: int | None) -> None:
150153
if workers is None:
151154
# confirm no executor was created if no workers were specified
152155
# (this is the default behavior)
153-
assert loop[0]._default_executor is None
156+
# Note: uvloop doesn't expose _default_executor attribute, so we skip this check for uvloop
157+
if hasattr(loop[0], "_default_executor"):
158+
assert loop[0]._default_executor is None
154159
else:
155160
# confirm executor was created and attached to loop as the default executor
156161
# note: python doesn't have a direct way to get the default executor so we
157-
# use the private attribute
158-
assert _get_executor() is loop[0]._default_executor
162+
# use the private attribute (when available)
159163
assert _get_executor()._max_workers == workers
164+
if hasattr(loop[0], "_default_executor"):
165+
assert _get_executor() is loop[0]._default_executor
160166

161167

162168
def test_cleanup_resources_idempotent() -> None:
163169
_get_executor() # trigger resource creation (iothread, loop, thread-pool)
164170
cleanup_resources()
165171
cleanup_resources()
172+
173+
174+
def test_create_event_loop_default_config() -> None:
175+
"""Test that _create_event_loop respects the default config."""
176+
# Reset config to default
177+
with zarr.config.set({"async.use_uvloop": True}):
178+
loop = _create_event_loop()
179+
if sys.platform != "win32":
180+
if importlib.util.find_spec("uvloop") is not None:
181+
# uvloop is available, should use it
182+
assert "uvloop" in str(type(loop))
183+
else:
184+
# uvloop not available, should use asyncio
185+
assert isinstance(loop, asyncio.AbstractEventLoop)
186+
assert "uvloop" not in str(type(loop))
187+
else:
188+
# Windows doesn't support uvloop
189+
assert isinstance(loop, asyncio.AbstractEventLoop)
190+
assert "uvloop" not in str(type(loop))
191+
192+
loop.close()
193+
194+
195+
def test_create_event_loop_uvloop_disabled() -> None:
196+
"""Test that uvloop can be disabled via config."""
197+
with zarr.config.set({"async.use_uvloop": False}):
198+
loop = _create_event_loop()
199+
# Should always use asyncio when disabled
200+
assert isinstance(loop, asyncio.AbstractEventLoop)
201+
assert "uvloop" not in str(type(loop))
202+
loop.close()
203+
204+
205+
@pytest.mark.skipif(sys.platform == "win32", reason="uvloop is not supported on Windows")
206+
@pytest.mark.skipif(importlib.util.find_spec("uvloop") is None, reason="uvloop is not installed")
207+
def test_create_event_loop_uvloop_enabled_non_windows() -> None:
208+
"""Test uvloop usage on non-Windows platforms when uvloop is installed."""
209+
with zarr.config.set({"async.use_uvloop": True}):
210+
loop = _create_event_loop()
211+
# uvloop is available and should be used
212+
assert "uvloop" in str(type(loop))
213+
loop.close()
214+
215+
216+
@pytest.mark.skipif(sys.platform != "win32", reason="This test is specific to Windows behavior")
217+
def test_create_event_loop_windows_no_uvloop() -> None:
218+
"""Test that uvloop is never used on Windows."""
219+
with zarr.config.set({"async.use_uvloop": True}):
220+
loop = _create_event_loop()
221+
# Should use asyncio even when uvloop is requested on Windows
222+
assert isinstance(loop, asyncio.AbstractEventLoop)
223+
assert "uvloop" not in str(type(loop))
224+
loop.close()
225+
226+
227+
def test_uvloop_config_environment_variable() -> None:
228+
"""Test that uvloop can be controlled via environment variable."""
229+
# This test verifies the config system works with uvloop setting
230+
# We test both True and False values
231+
with zarr.config.set({"async.use_uvloop": False}):
232+
assert zarr.config.get("async.use_uvloop") is False
233+
234+
with zarr.config.set({"async.use_uvloop": True}):
235+
assert zarr.config.get("async.use_uvloop") is True
236+
237+
238+
def test_uvloop_integration_with_zarr_operations(clean_state) -> None:
239+
"""Test that uvloop integration doesn't break zarr operations."""
240+
# Test with uvloop enabled (default)
241+
with zarr.config.set({"async.use_uvloop": True}):
242+
arr = zarr.zeros((10, 10), chunks=(5, 5))
243+
arr[0, 0] = 42.0
244+
result = arr[0, 0]
245+
assert result == 42.0
246+
247+
# Test with uvloop disabled
248+
with zarr.config.set({"async.use_uvloop": False}):
249+
arr2 = zarr.zeros((10, 10), chunks=(5, 5))
250+
arr2[0, 0] = 24.0
251+
result2 = arr2[0, 0]
252+
assert result2 == 24.0
253+
254+
255+
@patch("zarr.core.sync.logger.debug")
256+
def test_uvloop_logging_availability(mock_debug, clean_state) -> None:
257+
"""Test that appropriate debug messages are logged."""
258+
# Test with uvloop enabled
259+
with zarr.config.set({"async.use_uvloop": True}):
260+
loop = _create_event_loop()
261+
262+
if sys.platform != "win32":
263+
if importlib.util.find_spec("uvloop") is not None:
264+
# Should log that uvloop is being used
265+
mock_debug.assert_called_with("Creating Zarr event loop with uvloop")
266+
else:
267+
# Should log fallback to asyncio
268+
mock_debug.assert_called_with("uvloop not available, falling back to asyncio")
269+
else:
270+
# Should log that uvloop is not supported on Windows
271+
mock_debug.assert_called_with("uvloop not supported on Windows, using asyncio")
272+
273+
loop.close()
274+
275+
276+
@pytest.mark.skipif(sys.platform == "win32", reason="uvloop is not supported on Windows")
277+
@pytest.mark.skipif(importlib.util.find_spec("uvloop") is None, reason="uvloop is not installed")
278+
@patch("zarr.core.sync.logger.debug")
279+
def test_uvloop_logging_with_uvloop_installed(mock_debug, clean_state) -> None:
280+
"""Test that uvloop is logged when installed and enabled."""
281+
with zarr.config.set({"async.use_uvloop": True}):
282+
loop = _create_event_loop()
283+
# Should log that uvloop is being used
284+
mock_debug.assert_called_with("Creating Zarr event loop with uvloop")
285+
loop.close()
286+
287+
288+
@pytest.mark.skipif(importlib.util.find_spec("uvloop") is not None, reason="uvloop is installed")
289+
@patch("zarr.core.sync.logger.debug")
290+
def test_uvloop_logging_without_uvloop_installed(mock_debug, clean_state) -> None:
291+
"""Test that fallback to asyncio is logged when uvloop is not installed."""
292+
with zarr.config.set({"async.use_uvloop": True}):
293+
loop = _create_event_loop()
294+
if sys.platform != "win32":
295+
# Should log fallback to asyncio
296+
mock_debug.assert_called_with("uvloop not available, falling back to asyncio")
297+
else:
298+
# Should log that uvloop is not supported on Windows
299+
mock_debug.assert_called_with("uvloop not supported on Windows, using asyncio")
300+
loop.close()
301+
302+
303+
@patch("zarr.core.sync.logger.debug")
304+
def test_uvloop_logging_disabled(mock_debug, clean_state) -> None:
305+
"""Test that appropriate debug message is logged when uvloop is disabled."""
306+
with zarr.config.set({"async.use_uvloop": False}):
307+
loop = _create_event_loop()
308+
# Should log both that uvloop is disabled and the final loop creation
309+
expected_calls = [
310+
call("uvloop disabled via config, using asyncio"),
311+
call("Creating Zarr event loop with asyncio"),
312+
]
313+
mock_debug.assert_has_calls(expected_calls)
314+
loop.close()
315+
316+
317+
def test_uvloop_mock_import_error(clean_state) -> None:
318+
"""Test graceful handling when uvloop import fails."""
319+
with zarr.config.set({"async.use_uvloop": True}):
320+
# Mock uvloop import failure
321+
with patch.dict("sys.modules", {"uvloop": None}):
322+
with patch("builtins.__import__", side_effect=ImportError("No module named 'uvloop'")):
323+
loop = _create_event_loop()
324+
# Should fall back to asyncio
325+
assert isinstance(loop, asyncio.AbstractEventLoop)
326+
assert "uvloop" not in str(type(loop))
327+
loop.close()

0 commit comments

Comments
 (0)