Skip to content

Commit 5bfb461

Browse files
chore: Add python 3.10 support (#10)
2 parents 34adbef + 6a08554 commit 5bfb461

File tree

7 files changed

+168
-30
lines changed

7 files changed

+168
-30
lines changed

.github/workflows/tests.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ jobs:
1212
strategy:
1313
fail-fast: false
1414
matrix:
15-
python-version: ["3.11", "3.12", "3.13", "3.14"]
15+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
1616

1717
services:
1818
artemis:
@@ -78,7 +78,7 @@ jobs:
7878
with:
7979
python-version: ${{ matrix.python-version }}
8080
- run: uv sync --group dev
81-
- run: uv run pytest --cov=src/zmqtt --cov-report=xml
81+
- run: uv run pytest -vv --cov=src/zmqtt --cov-report=xml
8282
- uses: codecov/codecov-action@v5
8383
with:
8484
files: coverage.xml

.python-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.11
1+
3.10

pyproject.toml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ readme = "README.md"
66
authors = [
77
{ name = "borisalekseev", email = "i.borisalekseev@gmail.com" }
88
]
9-
requires-python = ">=3.11"
9+
requires-python = ">=3.10"
1010
dependencies = []
1111

1212
[build-system]
@@ -30,7 +30,13 @@ docs = [
3030

3131
[tool.mypy]
3232
strict = true
33-
files = ["src"]
33+
files = ["src", "tests"]
34+
python_version = "3.10"
35+
36+
[tool.ruff]
37+
src = ["src"]
38+
include = ["src/**.py", "tests/**.py"]
39+
target-version = "py310"
3440

3541
[tool.pytest.ini_options]
3642
asyncio_mode = "auto"

src/zmqtt/_compat.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Python version compatibility helpers."""
2+
3+
import asyncio
4+
import contextlib
5+
import sys
6+
from collections.abc import AsyncGenerator
7+
8+
9+
if sys.version_info >= (3, 11):
10+
11+
@contextlib.asynccontextmanager
12+
async def defer_cancellation() -> AsyncGenerator[None, None]:
13+
"""Temporarily remove pending cancellations, restore them after cleanup."""
14+
task = asyncio.current_task()
15+
cancels = task.cancelling() if task else 0
16+
for _ in range(cancels):
17+
if task:
18+
task.uncancel()
19+
try:
20+
yield
21+
finally:
22+
for _ in range(cancels):
23+
if task:
24+
task.cancel()
25+
26+
else:
27+
28+
@contextlib.asynccontextmanager
29+
async def defer_cancellation() -> AsyncGenerator[None, None]:
30+
"""No-op on Python < 3.11: cancelling()/uncancel() are not available."""
31+
yield

src/zmqtt/client.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dataclasses import dataclass
77
from typing import AsyncIterator, Final, Literal, Protocol, overload
88

9+
from zmqtt._compat import defer_cancellation
910
from zmqtt.errors import MQTTDisconnectedError, MQTTTimeoutError
1011
from zmqtt.log import get_logger
1112
from zmqtt.packets.auth import Auth
@@ -142,8 +143,7 @@ async def __aenter__(self) -> "Subscription":
142143
async def __aexit__(self, *exc: object) -> None:
143144
self._client._subscriptions.remove(self)
144145
await self._cancel_relays()
145-
task = asyncio.current_task()
146-
being_cancelled = task is not None and task.cancelling() > 0
146+
being_cancelled = isinstance(exc[1], asyncio.CancelledError)
147147
if (
148148
not being_cancelled
149149
and self._registered_filters
@@ -243,21 +243,14 @@ async def __aenter__(self) -> "MQTTClient":
243243
return self
244244

245245
async def __aexit__(self, *exc: object) -> None:
246-
task = asyncio.current_task()
247-
cancels = task.cancelling() if task else 0
248-
for _ in range(cancels):
249-
task.uncancel() # type: ignore[union-attr]
250-
try:
246+
async with defer_cancellation():
251247
if self._run_task is not None:
252248
self._run_task.cancel()
253249
await asyncio.gather(self._run_task, return_exceptions=True)
254250
self._run_task = None
255251
if self._protocol is not None:
256252
await self._protocol.disconnect()
257253
self._protocol = None
258-
finally:
259-
for _ in range(cancels):
260-
task.cancel() # type: ignore[union-attr]
261254

262255
async def publish(
263256
self,
@@ -356,18 +349,19 @@ async def _run_loop(self) -> None:
356349

357350
while True:
358351
assert self._protocol is not None
352+
protocol_run_task = asyncio.create_task(self._protocol.run())
359353
try:
360354
# Run the protocol as a sub-task so _read_loop is live while we
361355
# re-subscribe. For the first connection subs_to_restore is empty,
362356
# so this collapses to the original "await protocol.run()" pattern.
363-
async with asyncio.TaskGroup() as tg:
364-
tg.create_task(self._protocol.run())
365-
if subs_to_restore:
366-
await asyncio.sleep(0) # let _read_loop start
367-
for sub in subs_to_restore:
368-
await sub._reconnect(self._protocol)
369-
subs_to_restore = []
370-
except* (MQTTDisconnectedError, MQTTTimeoutError):
357+
if subs_to_restore:
358+
await self._protocol.started_event.wait()
359+
for sub in subs_to_restore:
360+
await sub._reconnect(self._protocol)
361+
subs_to_restore = []
362+
await protocol_run_task
363+
364+
except (MQTTDisconnectedError, MQTTTimeoutError):
371365
if not self._reconnect.enabled:
372366
raise
373367
# Close the dead transport to release the file descriptor.

src/zmqtt/protocol.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,10 @@ def __init__(
9898
self._keepalive = keepalive
9999
self._ping_timeout = ping_timeout
100100
self._version: Final = version
101-
self._buf: PacketBuffer = PacketBuffer(version=version)
101+
self._buf = PacketBuffer(version=version)
102102
self._ping_waiters: list[asyncio.Future[None]] = []
103-
self._disconnecting: bool = False
103+
self._disconnecting = False
104+
self.started_event = asyncio.Event()
104105

105106
async def connect(self, packet: Connect) -> ConnAck:
106107
"""Send CONNECT, read and return CONNACK. Raises on failure."""
@@ -122,11 +123,18 @@ async def connect(self, packet: Connect) -> ConnAck:
122123

123124
async def run(self) -> None:
124125
"""Run read loop and ping loop concurrently until disconnection."""
126+
read_task = asyncio.create_task(self._read_loop())
127+
ping_task = asyncio.create_task(self._ping_loop())
128+
self.started_event.set()
125129
try:
126-
async with asyncio.TaskGroup() as tg:
127-
tg.create_task(self._read_loop())
128-
tg.create_task(self._ping_loop())
130+
await asyncio.gather(read_task, ping_task)
131+
except BaseException:
132+
read_task.cancel()
133+
ping_task.cancel()
134+
await asyncio.gather(read_task, ping_task, return_exceptions=True)
135+
raise
129136
finally:
137+
self.started_event.clear()
130138
self._cancel_pending()
131139

132140
async def disconnect(self) -> None:

0 commit comments

Comments
 (0)