redqueue 0.11.1__tar.gz → 0.11.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {redqueue-0.11.1 → redqueue-0.11.2}/CHANGELOG.md +28 -11
  2. {redqueue-0.11.1 → redqueue-0.11.2}/PKG-INFO +1 -1
  3. {redqueue-0.11.1 → redqueue-0.11.2}/docs/API.md +6 -2
  4. {redqueue-0.11.1 → redqueue-0.11.2}/pyproject.toml +1 -1
  5. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/_version.py +1 -1
  6. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/async_client.py +107 -84
  7. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/client.py +60 -47
  8. {redqueue-0.11.1 → redqueue-0.11.2}/tests/test_project_skeleton.py +187 -122
  9. {redqueue-0.11.1 → redqueue-0.11.2}/.github/workflows/ci.yml +0 -0
  10. {redqueue-0.11.1 → redqueue-0.11.2}/.gitignore +0 -0
  11. {redqueue-0.11.1 → redqueue-0.11.2}/CODE_OF_CONDUCT.md +0 -0
  12. {redqueue-0.11.1 → redqueue-0.11.2}/CONTRIBUTING.md +0 -0
  13. {redqueue-0.11.1 → redqueue-0.11.2}/LICENSE +0 -0
  14. {redqueue-0.11.1 → redqueue-0.11.2}/NOTICE +0 -0
  15. {redqueue-0.11.1 → redqueue-0.11.2}/README-zh-CN.md +0 -0
  16. {redqueue-0.11.1 → redqueue-0.11.2}/README.md +0 -0
  17. {redqueue-0.11.1 → redqueue-0.11.2}/docs/RELEASE.md +0 -0
  18. {redqueue-0.11.1 → redqueue-0.11.2}/examples/README.md +0 -0
  19. {redqueue-0.11.1 → redqueue-0.11.2}/examples/__init__.py +0 -0
  20. {redqueue-0.11.1 → redqueue-0.11.2}/examples/async_list_queue.py +0 -0
  21. {redqueue-0.11.1 → redqueue-0.11.2}/examples/common.py +0 -0
  22. {redqueue-0.11.1 → redqueue-0.11.2}/examples/compatibility_check.py +0 -0
  23. {redqueue-0.11.1 → redqueue-0.11.2}/examples/custom_serializer.py +0 -0
  24. {redqueue-0.11.1 → redqueue-0.11.2}/examples/delayed_tasks.py +0 -0
  25. {redqueue-0.11.1 → redqueue-0.11.2}/examples/monitoring_hooks.py +0 -0
  26. {redqueue-0.11.1 → redqueue-0.11.2}/examples/stream_queue.py +0 -0
  27. {redqueue-0.11.1 → redqueue-0.11.2}/examples/sync_list_queue.py +0 -0
  28. {redqueue-0.11.1 → redqueue-0.11.2}/requirements.txt +0 -0
  29. {redqueue-0.11.1 → redqueue-0.11.2}/scripts/check.py +0 -0
  30. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/__init__.py +0 -0
  31. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/backends/__init__.py +0 -0
  32. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/backends/async_delay.py +0 -0
  33. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/backends/async_list.py +0 -0
  34. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/backends/async_stream.py +0 -0
  35. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/backends/base.py +0 -0
  36. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/backends/delay.py +0 -0
  37. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/backends/list.py +0 -0
  38. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/backends/stream.py +0 -0
  39. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/compat.py +0 -0
  40. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/config.py +0 -0
  41. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/connection.py +0 -0
  42. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/exceptions.py +0 -0
  43. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/message.py +0 -0
  44. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/monitoring.py +0 -0
  45. {redqueue-0.11.1 → redqueue-0.11.2}/src/redqueue/serialization.py +0 -0
  46. {redqueue-0.11.1 → redqueue-0.11.2}/tests/README.md +0 -0
  47. {redqueue-0.11.1 → redqueue-0.11.2}/tests/__init__.py +0 -0
  48. {redqueue-0.11.1 → redqueue-0.11.2}/tests/fakes.py +0 -0
  49. {redqueue-0.11.1 → redqueue-0.11.2}/tests/test_availability.py +0 -0
  50. {redqueue-0.11.1 → redqueue-0.11.2}/tests/test_backend_contracts.py +0 -0
  51. {redqueue-0.11.1 → redqueue-0.11.2}/tests/test_integration_redis.py +0 -0
  52. {redqueue-0.11.1 → redqueue-0.11.2}/tests/test_performance.py +0 -0
  53. {redqueue-0.11.1 → redqueue-0.11.2}/tests/test_real_redis_availability.py +0 -0
  54. {redqueue-0.11.1 → redqueue-0.11.2}/tests/test_real_redis_performance.py +0 -0
@@ -7,24 +7,41 @@ All notable public release changes are documented here.
7
7
  Development versions are tracked separately from formal release versions.
8
8
  开发版本与正式版本分开管理。
9
9
 
10
- ## [0.11.1] - 2026-06-21
10
+ ## [0.11.2] - 2026-06-21
11
11
 
12
12
  ### Fixed
13
13
 
14
- - Fixed resource cleanup in sync and async `from_url()` when Redis capability
15
- detection, configuration validation, or backend initialization fails after the
16
- client created an owned Redis connection.
17
- - Added explicit `owns_redis` override support to sync and async `from_url()`
18
- for advanced ownership control.
14
+ - Fixed cleanup for directly constructed sync clients when owned Redis backend
15
+ initialization fails.
16
+ - Fixed cleanup for directly constructed async clients when lazy backend
17
+ initialization fails.
18
+ - Made sync and async client `close()` idempotent for owned Redis clients.
19
19
 
20
20
  ### 修复
21
21
 
22
- - 修复同步和异步 `from_url()` 在自动创建 Redis 连接后,如果 Redis 能力探测、
23
- 配置校验或后端初始化失败,已创建连接未释放的问题。
24
- - 同步和异步 `from_url()` 新增显式 `owns_redis` 覆盖支持,用于高级资源所有权
25
- 控制。
22
+ - 修复直接构造同步客户端时,如果 owned Redis 的后端初始化失败,Redis client
23
+ 未释放的问题。
24
+ - 修复直接构造异步客户端时,如果懒加载后端初始化失败,Redis client 未释放的问题。
25
+ - 同步和异步客户端的 `close()` 对 owned Redis client 变为幂等。
26
26
 
27
- ## [0.11.0] - 2026-06-21
27
+ ## [0.11.1] - 2026-06-21
28
+
29
+ ### Fixed
30
+
31
+ - Fixed resource cleanup in sync and async `from_url()` when Redis capability
32
+ detection, configuration validation, or backend initialization fails after the
33
+ client created an owned Redis connection.
34
+ - Added explicit `owns_redis` override support to sync and async `from_url()`
35
+ for advanced ownership control.
36
+
37
+ ### 修复
38
+
39
+ - 修复同步和异步 `from_url()` 在自动创建 Redis 连接后,如果 Redis 能力探测、
40
+ 配置校验或后端初始化失败,已创建连接未释放的问题。
41
+ - 同步和异步 `from_url()` 新增显式 `owns_redis` 覆盖支持,用于高级资源所有权
42
+ 控制。
43
+
44
+ ## [0.11.0] - 2026-06-21
28
45
 
29
46
  ### Added
30
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redqueue
3
- Version: 0.11.1
3
+ Version: 0.11.2
4
4
  Summary: Redis-backed Python message queue library with List, Streams, delayed tasks, and monitoring.
5
5
  Project-URL: Homepage, https://github.com/SpringMirror-pear/redqueue
6
6
  Project-URL: Repository, https://github.com/SpringMirror-pear/redqueue.git
@@ -1,8 +1,8 @@
1
1
  # RedQueue API / RedQueue API 文档
2
2
 
3
- This document describes the public API available in RedQueue `0.11.1`.
3
+ This document describes the public API available in RedQueue `0.11.2`.
4
4
 
5
- 本文档描述 RedQueue `0.11.1` 的公开 API。
5
+ 本文档描述 RedQueue `0.11.2` 的公开 API。
6
6
 
7
7
  ## Clients / 客户端
8
8
 
@@ -25,6 +25,8 @@ client = QueueClient.from_url(
25
25
  Methods / 方法:
26
26
 
27
27
  - `from_url(url, *, queue, backend="list", connection_manager=None, **options) -> QueueClient`
28
+ - Advanced options include `pool_options`, injected `redis`, injected
29
+ `capabilities`, and `owns_redis`.
28
30
  - `publish(payload, *, delay=None, headers=None, message_id=None) -> str`
29
31
  - `consume(*, timeout=None, batch_size=1) -> Message | list[Message] | None`
30
32
  - `ack(message) -> None`
@@ -57,6 +59,8 @@ client = await AsyncQueueClient.from_url(
57
59
  Methods / 方法:
58
60
 
59
61
  - `await from_url(url, *, queue, backend="list", connection_manager=None, **options) -> AsyncQueueClient`
62
+ - Advanced options include `pool_options`, injected `redis`, injected
63
+ `capabilities`, and `owns_redis`.
60
64
  - `await publish(payload, *, delay=None, headers=None, message_id=None) -> str`
61
65
  - `await consume(*, timeout=None, batch_size=1) -> Message | list[Message] | None`
62
66
  - `await ack(message) -> None`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "redqueue"
7
- version = "0.11.1"
7
+ version = "0.11.2"
8
8
  description = "Redis-backed Python message queue library with List, Streams, delayed tasks, and monitoring."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -3,4 +3,4 @@
3
3
 
4
4
  """Package version."""
5
5
 
6
- __version__ = "0.11.1"
6
+ __version__ = "0.11.2"
@@ -56,11 +56,12 @@ class AsyncQueueClient:
56
56
  """
57
57
 
58
58
  self.config = config
59
- self.redis = redis
60
- self.capabilities = capabilities
61
- self._owns_redis = owns_redis
62
- self.backend: AsyncListBackend | AsyncStreamBackend | None = None
63
- self.delay_backend: AsyncDelayBackend | None = None
59
+ self.redis = redis
60
+ self.capabilities = capabilities
61
+ self._owns_redis = owns_redis
62
+ self._closed = False
63
+ self.backend: AsyncListBackend | AsyncStreamBackend | None = None
64
+ self.delay_backend: AsyncDelayBackend | None = None
64
65
  self.config.monitoring.emit(
65
66
  MonitoringEvent(
66
67
  type=MonitoringEventType.CLIENT_CREATED,
@@ -88,7 +89,8 @@ class AsyncQueueClient:
88
89
  connection_manager: Optional async connection manager used to create
89
90
  a client from a shared pool.
90
91
  **options: Additional ``QueueConfig`` options. Tests may also pass
91
- ``redis``, ``capabilities``, or ``pool_options``.
92
+ ``redis``, ``capabilities``, ``pool_options``, or
93
+ ``owns_redis``.
92
94
 
93
95
  Returns:
94
96
  An initialized ``AsyncQueueClient`` with its primary backend ready.
@@ -99,50 +101,48 @@ class AsyncQueueClient:
99
101
  QueueConfigError: If configuration values are invalid.
100
102
  """
101
103
 
102
- redis = options.pop("redis", None)
103
- pool_options = options.pop("pool_options", None) or {}
104
- explicit_owns_redis = options.pop("owns_redis", None)
105
- owns_redis = (
106
- bool(explicit_owns_redis)
107
- if explicit_owns_redis is not None
108
- else False
109
- )
110
- if redis is None:
111
- if connection_manager is not None:
112
- redis = connection_manager.redis()
113
- else:
114
- redis = Redis.from_url(url, **pool_options)
115
- owns_redis = (
116
- True
117
- if explicit_owns_redis is None
118
- else bool(explicit_owns_redis)
104
+ redis = options.pop("redis", None)
105
+ pool_options = options.pop("pool_options", None) or {}
106
+ explicit_owns_redis = options.pop("owns_redis", None)
107
+ owns_redis = (
108
+ bool(explicit_owns_redis)
109
+ if explicit_owns_redis is not None
110
+ else False
111
+ )
112
+ if redis is None:
113
+ if connection_manager is not None:
114
+ redis = connection_manager.redis()
115
+ else:
116
+ redis = Redis.from_url(url, **pool_options)
117
+ owns_redis = (
118
+ True
119
+ if explicit_owns_redis is None
120
+ else bool(explicit_owns_redis)
121
+ )
122
+ capabilities = options.pop("capabilities", None)
123
+ if capabilities is None:
124
+ try:
125
+ capabilities = await detect_capabilities_async(
126
+ cast(AsyncRedisInfoClient, redis)
119
127
  )
128
+ except Exception:
129
+ if owns_redis:
130
+ await cls._close_redis(redis)
131
+ raise
120
132
  try:
121
- capabilities = options.pop(
122
- "capabilities",
123
- None,
124
- ) or await detect_capabilities_async(cast(AsyncRedisInfoClient, redis))
125
133
  config = QueueConfig(queue=queue, backend=backend, **options)
126
- client = cls(
127
- config=config,
128
- redis=redis,
129
- capabilities=capabilities,
130
- owns_redis=owns_redis,
131
- )
132
- await client._ensure_backend()
133
- return client
134
134
  except Exception:
135
135
  if owns_redis:
136
- close = getattr(redis, "aclose", None) or getattr(
137
- redis,
138
- "close",
139
- None,
140
- )
141
- if close is not None:
142
- result = close()
143
- if hasattr(result, "__await__"):
144
- await result
136
+ await cls._close_redis(redis)
145
137
  raise
138
+ client = cls(
139
+ config=config,
140
+ redis=redis,
141
+ capabilities=capabilities,
142
+ owns_redis=owns_redis,
143
+ )
144
+ await client._ensure_backend()
145
+ return client
146
146
 
147
147
  async def publish(
148
148
  self,
@@ -321,21 +321,20 @@ class AsyncQueueClient:
321
321
 
322
322
  await (await self._ensure_backend()).requeue_dead(message)
323
323
 
324
- async def close(self) -> None:
325
- """Close the async Redis client when this client owns it."""
326
-
327
- if not self._owns_redis:
328
- return
329
-
330
- close = getattr(self.redis, "aclose", None) or getattr(
331
- self.redis,
332
- "close",
333
- None,
334
- )
335
- if close is not None:
336
- result = close()
337
- if hasattr(result, "__await__"):
338
- await result
324
+ async def close(self) -> None:
325
+ """Close the async Redis client when this client owns it."""
326
+
327
+ if self._closed or not self._owns_redis:
328
+ return
329
+
330
+ close = getattr(self.redis, "aclose", None) or getattr(
331
+ self.redis,
332
+ "close",
333
+ None,
334
+ )
335
+ if close is not None:
336
+ await self._call_close(close)
337
+ self._closed = True
339
338
 
340
339
  async def __aenter__(self) -> AsyncQueueClient:
341
340
  """Enter an asynchronous resource-management context."""
@@ -365,31 +364,55 @@ class AsyncQueueClient:
365
364
 
366
365
  if self.backend is not None:
367
366
  return self.backend
368
- if self.config.backend_type is BackendType.LIST:
369
- if self.redis is None:
370
- raise TypeError("redis client is required for async List backend")
371
- capabilities = self.capabilities
372
- if capabilities is None:
373
- raise TypeError("Redis capabilities are required before backend use")
374
- self.backend = AsyncListBackend(self.redis, self.config, capabilities)
375
- return self.backend
376
- if self.config.backend_type is BackendType.STREAM:
377
- if self.redis is None:
378
- raise TypeError("redis client is required for async Streams backend")
379
- capabilities = self.capabilities
380
- if capabilities is None:
381
- raise TypeError("Redis capabilities are required before backend use")
382
- self.backend = await AsyncStreamBackend.create(
383
- self.redis,
384
- self.config,
385
- capabilities,
386
- )
387
- return self.backend
388
- raise NotImplementedError(
389
- f"backend {self.config.backend_type.value!r} is not implemented"
390
- )
391
-
392
- async def _ensure_delay_backend(self) -> AsyncDelayBackend:
367
+ if self.config.backend_type is BackendType.LIST:
368
+ if self.redis is None:
369
+ raise TypeError("redis client is required for async List backend")
370
+ capabilities = self.capabilities
371
+ if capabilities is None:
372
+ raise TypeError("Redis capabilities are required before backend use")
373
+ try:
374
+ self.backend = AsyncListBackend(self.redis, self.config, capabilities)
375
+ return self.backend
376
+ except Exception:
377
+ await self.close()
378
+ raise
379
+ if self.config.backend_type is BackendType.STREAM:
380
+ if self.redis is None:
381
+ raise TypeError("redis client is required for async Streams backend")
382
+ capabilities = self.capabilities
383
+ if capabilities is None:
384
+ raise TypeError("Redis capabilities are required before backend use")
385
+ try:
386
+ self.backend = await AsyncStreamBackend.create(
387
+ self.redis,
388
+ self.config,
389
+ capabilities,
390
+ )
391
+ return self.backend
392
+ except Exception:
393
+ await self.close()
394
+ raise
395
+ raise NotImplementedError(
396
+ f"backend {self.config.backend_type.value!r} is not implemented"
397
+ )
398
+
399
+ @staticmethod
400
+ async def _close_redis(redis: Any) -> None:
401
+ """Close an async Redis-like object if it exposes a close method."""
402
+
403
+ close = getattr(redis, "aclose", None) or getattr(redis, "close", None)
404
+ if close is not None:
405
+ await AsyncQueueClient._call_close(close)
406
+
407
+ @staticmethod
408
+ async def _call_close(close: Any) -> None:
409
+ """Call a sync or async close method."""
410
+
411
+ result = close()
412
+ if hasattr(result, "__await__"):
413
+ await result
414
+
415
+ async def _ensure_delay_backend(self) -> AsyncDelayBackend:
393
416
  """Return the initialized async delay scheduler, creating it when needed."""
394
417
 
395
418
  if self.delay_backend is not None:
@@ -61,15 +61,20 @@ class QueueClient:
61
61
  RedisCompatibilityError: If Redis lacks a required command family.
62
62
  """
63
63
 
64
- self.config = config
65
- self.redis = redis
66
- self.capabilities = capabilities
67
- self._owns_redis = owns_redis
68
- self.backend = self._create_backend()
69
- self.delay_backend = self._create_delay_backend()
70
- self.config.monitoring.emit(
71
- MonitoringEvent(
72
- type=MonitoringEventType.CLIENT_CREATED,
64
+ self.config = config
65
+ self.redis = redis
66
+ self.capabilities = capabilities
67
+ self._owns_redis = owns_redis
68
+ self._closed = False
69
+ try:
70
+ self.backend = self._create_backend()
71
+ self.delay_backend = self._create_delay_backend()
72
+ except Exception:
73
+ self.close()
74
+ raise
75
+ self.config.monitoring.emit(
76
+ MonitoringEvent(
77
+ type=MonitoringEventType.CLIENT_CREATED,
73
78
  queue=config.queue,
74
79
  backend=config.backend_type.value,
75
80
  )
@@ -94,8 +99,8 @@ class QueueClient:
94
99
  connection_manager: Optional connection manager used to create a
95
100
  client from a shared pool.
96
101
  **options: Additional ``QueueConfig`` options. Tests may also pass
97
- ``redis``, ``capabilities``, or ``pool_options`` to bypass or
98
- customize connection creation.
102
+ ``redis``, ``capabilities``, ``pool_options``, or
103
+ ``owns_redis`` to bypass or customize connection creation.
99
104
 
100
105
  Returns:
101
106
  A ready-to-use synchronous ``QueueClient``.
@@ -107,41 +112,48 @@ class QueueClient:
107
112
  QueueConfigError: If configuration values are invalid.
108
113
  """
109
114
 
110
- redis = options.pop("redis", None)
111
- pool_options = options.pop("pool_options", None) or {}
112
- explicit_owns_redis = options.pop("owns_redis", None)
113
- owns_redis = (
114
- bool(explicit_owns_redis)
115
- if explicit_owns_redis is not None
116
- else False
117
- )
118
- if redis is None:
119
- if connection_manager is not None:
120
- redis = connection_manager.redis()
121
- else:
122
- redis = Redis.from_url(url, **pool_options)
123
- owns_redis = (
124
- True
125
- if explicit_owns_redis is None
126
- else bool(explicit_owns_redis)
127
- )
115
+ redis = options.pop("redis", None)
116
+ pool_options = options.pop("pool_options", None) or {}
117
+ explicit_owns_redis = options.pop("owns_redis", None)
118
+ owns_redis = (
119
+ bool(explicit_owns_redis)
120
+ if explicit_owns_redis is not None
121
+ else False
122
+ )
123
+ if redis is None:
124
+ if connection_manager is not None:
125
+ redis = connection_manager.redis()
126
+ else:
127
+ redis = Redis.from_url(url, **pool_options)
128
+ owns_redis = (
129
+ True
130
+ if explicit_owns_redis is None
131
+ else bool(explicit_owns_redis)
132
+ )
133
+ capabilities = options.pop("capabilities", None)
134
+ if capabilities is None:
135
+ try:
136
+ capabilities = detect_capabilities(cast(RedisInfoClient, redis))
137
+ except Exception:
138
+ if owns_redis:
139
+ close = getattr(redis, "close", None)
140
+ if close is not None:
141
+ close()
142
+ raise
128
143
  try:
129
- capabilities = options.pop("capabilities", None) or detect_capabilities(
130
- cast(RedisInfoClient, redis)
131
- )
132
144
  config = QueueConfig(queue=queue, backend=backend, **options)
133
- return cls(
134
- config=config,
135
- redis=redis,
136
- capabilities=capabilities,
137
- owns_redis=owns_redis,
138
- )
139
145
  except Exception:
140
146
  if owns_redis:
141
147
  close = getattr(redis, "close", None)
142
148
  if close is not None:
143
149
  close()
144
150
  raise
151
+ return cls(
152
+ config=config,
153
+ redis=redis,
154
+ capabilities=capabilities,
155
+ owns_redis=owns_redis,
156
+ )
145
157
 
146
158
  def publish(
147
159
  self,
@@ -321,15 +333,16 @@ class QueueClient:
321
333
 
322
334
  self.backend.requeue_dead(message)
323
335
 
324
- def close(self) -> None:
325
- """Close the Redis client when this client owns it."""
326
-
327
- if not self._owns_redis:
328
- return
329
-
330
- close = getattr(self.redis, "close", None)
331
- if close is not None:
332
- close()
336
+ def close(self) -> None:
337
+ """Close the Redis client when this client owns it."""
338
+
339
+ if self._closed or not self._owns_redis:
340
+ return
341
+
342
+ close = getattr(self.redis, "close", None)
343
+ if close is not None:
344
+ close()
345
+ self._closed = True
333
346
 
334
347
  def __enter__(self) -> QueueClient:
335
348
  """Enter a synchronous resource-management context."""
@@ -50,7 +50,7 @@ from tests.fakes import (
50
50
 
51
51
  class ProjectSkeletonTests(unittest.TestCase):
52
52
  def test_version_is_current_dev_version(self) -> None:
53
- self.assertEqual(__version__, "0.11.1")
53
+ self.assertEqual(__version__, "0.11.2")
54
54
 
55
55
  def test_queue_config_accepts_and_normalizes_backend(self) -> None:
56
56
  config = QueueConfig(queue=" emails ", backend="stream")
@@ -592,7 +592,7 @@ class ProjectSkeletonTests(unittest.TestCase):
592
592
 
593
593
  self.assertNotIn("close", redis.commands)
594
594
 
595
- def test_sync_client_context_closes_owned_redis(self) -> None:
595
+ def test_sync_client_context_closes_owned_redis(self) -> None:
596
596
  redis = FakeListRedis()
597
597
 
598
598
  with QueueClient(
@@ -601,8 +601,37 @@ class ProjectSkeletonTests(unittest.TestCase):
601
601
  capabilities=RedisCapabilities(RedisVersion(7, 0, 0)),
602
602
  ):
603
603
  pass
604
-
605
- self.assertIn("close", redis.commands)
604
+
605
+ self.assertIn("close", redis.commands)
606
+
607
+ def test_sync_client_close_is_idempotent(self) -> None:
608
+ redis = FakeListRedis()
609
+ client = QueueClient(
610
+ QueueConfig(queue="emails"),
611
+ redis=redis,
612
+ capabilities=RedisCapabilities(RedisVersion(7, 0, 0)),
613
+ )
614
+
615
+ client.close()
616
+ client.close()
617
+
618
+ self.assertEqual(redis.commands.count("close"), 1)
619
+
620
+ def test_sync_client_closes_owned_redis_when_backend_init_fails(self) -> None:
621
+ class ClosableStreamRedis(FakeStreamRedis):
622
+ def close(self) -> None:
623
+ self.commands.append("close")
624
+
625
+ redis = ClosableStreamRedis()
626
+
627
+ with self.assertRaises(RedisCompatibilityError):
628
+ QueueClient(
629
+ QueueConfig(queue="events", backend="stream"),
630
+ redis=redis,
631
+ capabilities=RedisCapabilities(RedisVersion(4, 0, 14)),
632
+ )
633
+
634
+ self.assertIn("close", redis.commands)
606
635
 
607
636
  def test_async_client_can_leave_injected_redis_open(self) -> None:
608
637
  async def run() -> FakeAsyncListRedis:
@@ -620,7 +649,7 @@ class ProjectSkeletonTests(unittest.TestCase):
620
649
 
621
650
  self.assertNotIn("aclose", redis.commands)
622
651
 
623
- def test_async_client_context_closes_owned_redis(self) -> None:
652
+ def test_async_client_context_closes_owned_redis(self) -> None:
624
653
  async def run() -> FakeAsyncListRedis:
625
654
  redis = FakeAsyncListRedis()
626
655
  async with AsyncQueueClient(
@@ -632,8 +661,44 @@ class ProjectSkeletonTests(unittest.TestCase):
632
661
  return redis
633
662
 
634
663
  redis = asyncio.run(run())
635
-
636
- self.assertIn("aclose", redis.commands)
664
+
665
+ self.assertIn("aclose", redis.commands)
666
+
667
+ def test_async_client_close_is_idempotent(self) -> None:
668
+ async def run() -> FakeAsyncListRedis:
669
+ redis = FakeAsyncListRedis()
670
+ client = AsyncQueueClient(
671
+ QueueConfig(queue="jobs"),
672
+ redis=redis,
673
+ capabilities=RedisCapabilities(RedisVersion(7, 0, 0)),
674
+ )
675
+ await client.close()
676
+ await client.close()
677
+ return redis
678
+
679
+ redis = asyncio.run(run())
680
+
681
+ self.assertEqual(redis.commands.count("aclose"), 1)
682
+
683
+ def test_async_client_closes_owned_redis_when_backend_init_fails(self) -> None:
684
+ async def run() -> FakeAsyncStreamRedis:
685
+ class ClosableAsyncStreamRedis(FakeAsyncStreamRedis):
686
+ async def aclose(self) -> None:
687
+ self.commands.append("aclose")
688
+
689
+ redis = ClosableAsyncStreamRedis()
690
+ client = AsyncQueueClient(
691
+ QueueConfig(queue="events", backend="stream"),
692
+ redis=redis,
693
+ capabilities=RedisCapabilities(RedisVersion(4, 0, 14)),
694
+ )
695
+ with self.assertRaises(RedisCompatibilityError):
696
+ await client.consume(timeout=1)
697
+ return redis
698
+
699
+ redis = asyncio.run(run())
700
+
701
+ self.assertIn("aclose", redis.commands)
637
702
 
638
703
  def test_connection_managers_create_pooled_clients(self) -> None:
639
704
  manager = RedisConnectionManager(
@@ -653,7 +718,7 @@ class ProjectSkeletonTests(unittest.TestCase):
653
718
  with self.assertRaises(RuntimeError):
654
719
  manager.redis()
655
720
 
656
- def test_sync_from_url_accepts_connection_manager_and_pool_options(self) -> None:
721
+ def test_sync_from_url_accepts_connection_manager_and_pool_options(self) -> None:
657
722
  redis = FakeListRedis()
658
723
  client = QueueClient.from_url(
659
724
  "redis://127.0.0.1:6379/0",
@@ -682,58 +747,58 @@ class ProjectSkeletonTests(unittest.TestCase):
682
747
  self.assertIs(managed_client.redis.connection_pool, manager.pool)
683
748
  managed_client.close()
684
749
  self.assertIs(manager.redis().connection_pool, manager.pool)
685
- finally:
686
- manager.close()
687
-
688
- def test_sync_from_url_closes_owned_redis_when_initialization_fails(self) -> None:
689
- class OwnedRedis(FakeListRedis):
690
- def info(self, section: str | None = None) -> dict[str, str]:
691
- return {"redis_version": "7.0.0"}
692
-
693
- redis = OwnedRedis()
694
-
695
- with self.assertRaises(QueueConfigError):
696
- QueueClient.from_url(
697
- "redis://127.0.0.1:6379/0",
698
- queue="bad queue",
699
- redis=redis,
700
- owns_redis=True,
701
- )
702
-
703
- self.assertIn("close", redis.commands)
704
-
705
- def test_sync_from_url_leaves_injected_redis_open_when_init_fails(self) -> None:
706
- class InjectedRedis(FakeListRedis):
707
- def info(self, section: str | None = None) -> dict[str, str]:
708
- return {"redis_version": "7.0.0"}
709
-
710
- redis = InjectedRedis()
711
-
712
- with self.assertRaises(QueueConfigError):
713
- QueueClient.from_url(
714
- "redis://127.0.0.1:6379/0",
715
- queue="bad queue",
716
- redis=redis,
717
- )
718
-
719
- self.assertNotIn("close", redis.commands)
720
-
721
- def test_sync_from_url_closes_owned_redis_when_capability_probe_fails(self) -> None:
722
- class BrokenInfoRedis(FakeListRedis):
723
- def info(self, section: str | None = None) -> dict[str, str]:
724
- raise TimeoutError("redis unavailable")
725
-
726
- redis = BrokenInfoRedis()
727
-
728
- with self.assertRaises(BackendUnavailableError):
729
- QueueClient.from_url(
730
- "redis://127.0.0.1:6379/0",
731
- queue="emails",
732
- redis=redis,
733
- owns_redis=True,
734
- )
735
-
736
- self.assertIn("close", redis.commands)
750
+ finally:
751
+ manager.close()
752
+
753
+ def test_sync_from_url_closes_owned_redis_when_initialization_fails(self) -> None:
754
+ class OwnedRedis(FakeListRedis):
755
+ def info(self, section: str | None = None) -> dict[str, str]:
756
+ return {"redis_version": "7.0.0"}
757
+
758
+ redis = OwnedRedis()
759
+
760
+ with self.assertRaises(QueueConfigError):
761
+ QueueClient.from_url(
762
+ "redis://127.0.0.1:6379/0",
763
+ queue="bad queue",
764
+ redis=redis,
765
+ owns_redis=True,
766
+ )
767
+
768
+ self.assertIn("close", redis.commands)
769
+
770
+ def test_sync_from_url_leaves_injected_redis_open_when_init_fails(self) -> None:
771
+ class InjectedRedis(FakeListRedis):
772
+ def info(self, section: str | None = None) -> dict[str, str]:
773
+ return {"redis_version": "7.0.0"}
774
+
775
+ redis = InjectedRedis()
776
+
777
+ with self.assertRaises(QueueConfigError):
778
+ QueueClient.from_url(
779
+ "redis://127.0.0.1:6379/0",
780
+ queue="bad queue",
781
+ redis=redis,
782
+ )
783
+
784
+ self.assertNotIn("close", redis.commands)
785
+
786
+ def test_sync_from_url_closes_owned_redis_when_capability_probe_fails(self) -> None:
787
+ class BrokenInfoRedis(FakeListRedis):
788
+ def info(self, section: str | None = None) -> dict[str, str]:
789
+ raise TimeoutError("redis unavailable")
790
+
791
+ redis = BrokenInfoRedis()
792
+
793
+ with self.assertRaises(BackendUnavailableError):
794
+ QueueClient.from_url(
795
+ "redis://127.0.0.1:6379/0",
796
+ queue="emails",
797
+ redis=redis,
798
+ owns_redis=True,
799
+ )
800
+
801
+ self.assertIn("close", redis.commands)
737
802
 
738
803
  def test_async_connection_manager_creates_pooled_clients(self) -> None:
739
804
  async def run() -> None:
@@ -755,7 +820,7 @@ class ProjectSkeletonTests(unittest.TestCase):
755
820
 
756
821
  asyncio.run(run())
757
822
 
758
- def test_async_from_url_accepts_connection_manager_and_pool_options(self) -> None:
823
+ def test_async_from_url_accepts_connection_manager_and_pool_options(self) -> None:
759
824
  async def run() -> None:
760
825
  redis = FakeAsyncListRedis()
761
826
  client = await AsyncQueueClient.from_url(
@@ -787,67 +852,67 @@ class ProjectSkeletonTests(unittest.TestCase):
787
852
  self.assertIs(manager.redis().connection_pool, manager.pool)
788
853
  finally:
789
854
  await manager.close()
790
-
791
- asyncio.run(run())
792
-
793
- def test_async_from_url_closes_owned_redis_when_initialization_fails(self) -> None:
794
- class OwnedAsyncRedis(FakeAsyncListRedis):
795
- async def info(self, section: str | None = None) -> dict[str, str]:
796
- return {"redis_version": "7.0.0"}
797
-
798
- async def run() -> FakeAsyncListRedis:
799
- redis = OwnedAsyncRedis()
800
- with self.assertRaises(QueueConfigError):
801
- await AsyncQueueClient.from_url(
802
- "redis://127.0.0.1:6379/0",
803
- queue="bad queue",
804
- redis=redis,
805
- owns_redis=True,
806
- )
807
- return redis
808
-
809
- redis = asyncio.run(run())
810
-
811
- self.assertIn("aclose", redis.commands)
812
-
813
- def test_async_from_url_leaves_injected_redis_open_when_init_fails(self) -> None:
814
- class InjectedAsyncRedis(FakeAsyncListRedis):
815
- async def info(self, section: str | None = None) -> dict[str, str]:
816
- return {"redis_version": "7.0.0"}
817
-
818
- async def run() -> FakeAsyncListRedis:
819
- redis = InjectedAsyncRedis()
820
- with self.assertRaises(QueueConfigError):
821
- await AsyncQueueClient.from_url(
822
- "redis://127.0.0.1:6379/0",
823
- queue="bad queue",
824
- redis=redis,
825
- )
826
- return redis
827
-
828
- redis = asyncio.run(run())
829
-
830
- self.assertNotIn("aclose", redis.commands)
831
-
832
- def test_async_from_url_closes_owned_redis_on_probe_failure(self) -> None:
833
- class BrokenInfoAsyncRedis(FakeAsyncListRedis):
834
- async def info(self, section: str | None = None) -> dict[str, str]:
835
- raise TimeoutError("redis unavailable")
836
-
837
- async def run() -> FakeAsyncListRedis:
838
- redis = BrokenInfoAsyncRedis()
839
- with self.assertRaises(BackendUnavailableError):
840
- await AsyncQueueClient.from_url(
841
- "redis://127.0.0.1:6379/0",
842
- queue="jobs",
843
- redis=redis,
844
- owns_redis=True,
845
- )
846
- return redis
847
-
848
- redis = asyncio.run(run())
849
-
850
- self.assertIn("aclose", redis.commands)
855
+
856
+ asyncio.run(run())
857
+
858
+ def test_async_from_url_closes_owned_redis_when_initialization_fails(self) -> None:
859
+ class OwnedAsyncRedis(FakeAsyncListRedis):
860
+ async def info(self, section: str | None = None) -> dict[str, str]:
861
+ return {"redis_version": "7.0.0"}
862
+
863
+ async def run() -> FakeAsyncListRedis:
864
+ redis = OwnedAsyncRedis()
865
+ with self.assertRaises(QueueConfigError):
866
+ await AsyncQueueClient.from_url(
867
+ "redis://127.0.0.1:6379/0",
868
+ queue="bad queue",
869
+ redis=redis,
870
+ owns_redis=True,
871
+ )
872
+ return redis
873
+
874
+ redis = asyncio.run(run())
875
+
876
+ self.assertIn("aclose", redis.commands)
877
+
878
+ def test_async_from_url_leaves_injected_redis_open_when_init_fails(self) -> None:
879
+ class InjectedAsyncRedis(FakeAsyncListRedis):
880
+ async def info(self, section: str | None = None) -> dict[str, str]:
881
+ return {"redis_version": "7.0.0"}
882
+
883
+ async def run() -> FakeAsyncListRedis:
884
+ redis = InjectedAsyncRedis()
885
+ with self.assertRaises(QueueConfigError):
886
+ await AsyncQueueClient.from_url(
887
+ "redis://127.0.0.1:6379/0",
888
+ queue="bad queue",
889
+ redis=redis,
890
+ )
891
+ return redis
892
+
893
+ redis = asyncio.run(run())
894
+
895
+ self.assertNotIn("aclose", redis.commands)
896
+
897
+ def test_async_from_url_closes_owned_redis_on_probe_failure(self) -> None:
898
+ class BrokenInfoAsyncRedis(FakeAsyncListRedis):
899
+ async def info(self, section: str | None = None) -> dict[str, str]:
900
+ raise TimeoutError("redis unavailable")
901
+
902
+ async def run() -> FakeAsyncListRedis:
903
+ redis = BrokenInfoAsyncRedis()
904
+ with self.assertRaises(BackendUnavailableError):
905
+ await AsyncQueueClient.from_url(
906
+ "redis://127.0.0.1:6379/0",
907
+ queue="jobs",
908
+ redis=redis,
909
+ owns_redis=True,
910
+ )
911
+ return redis
912
+
913
+ redis = asyncio.run(run())
914
+
915
+ self.assertIn("aclose", redis.commands)
851
916
 
852
917
  def test_async_list_backend_ack_uses_original_serialized_payload(self) -> None:
853
918
  class NonDeterministicSerializer:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes