Coverage for zenmqtt/mqtt/protocol/commands.py: 95%
69 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 21:55 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 21:55 +0000
1import asyncio
2from dataclasses import dataclass
3from logging import getLogger
4from typing import Any, Optional, Sequence, TypeVar, Union
6from zenmqtt.exceptions import NotConnectedError, ServerLimitError
7from zenmqtt.mqtt.protocol.context import ProtocolContext
8from zenmqtt.mqtt.subscribe import (
9 SubscribeResult,
10 SubscriptionProperties,
11 SubscriptionRequest,
12 UnsubscribeProperties,
13 UnsubscribeResult,
14 pack_subscribe_packet,
15 pack_unsubscribe_packet,
16)
17from zenmqtt.session import MQTTSession, PacketIdentifier
19logger = getLogger(__name__)
21CommandResult = Union[SubscribeResult, UnsubscribeResult]
23_Result = TypeVar("_Result", SubscribeResult, UnsubscribeResult)
26@dataclass(slots=True)
27class _PendingCommand:
28 # the future of SUBACK or UNSUBACK
29 future: asyncio.Future[Any]
30 sent_at: float
33class Commands:
34 """SUBSCRIBE and UNSUBSCRIBE, which wait for SUBACK and UNSUBACK."""
36 def __init__(self, context: ProtocolContext, session: MQTTSession) -> None:
37 self._context = context
38 self._session = session
40 self._pending: dict[PacketIdentifier, _PendingCommand] = {}
42 async def subscribe(
43 self,
44 topics: Sequence[SubscriptionRequest],
45 properties: Optional[SubscriptionProperties] = None,
46 ) -> SubscribeResult:
47 self._context.ensure_connected()
49 properties = properties or {}
51 self._context.server_limits.check_subscribe(topics, properties)
53 packet_identifier = await self._session.acquire_packet_identifier()
54 future: asyncio.Future[SubscribeResult] = self._create_future()
56 return await self._send(
57 packet_identifier,
58 pack_subscribe_packet(packet_identifier, topics, properties),
59 future,
60 )
62 async def unsubscribe(
63 self, topics: Sequence[str], properties: Optional[UnsubscribeProperties] = None
64 ) -> UnsubscribeResult:
65 self._context.ensure_connected()
67 properties = properties or {}
69 packet_identifier = await self._session.acquire_packet_identifier()
70 future: asyncio.Future[UnsubscribeResult] = self._create_future()
72 return await self._send(
73 packet_identifier,
74 pack_unsubscribe_packet(packet_identifier, topics, properties),
75 future,
76 )
78 async def handle_suback(self, suback_packet: SubscribeResult) -> None:
79 logger.debug("mqtt_protocol.handle_suback_packet packet:%s", suback_packet)
81 await self._complete(suback_packet.packet_identifier, suback_packet)
83 async def handle_unsuback(self, unsuback_packet: UnsubscribeResult) -> None:
84 logger.debug("mqtt_protocol.handle_unsuback_packet packet:%s", unsuback_packet)
86 await self._complete(unsuback_packet.packet_identifier, unsuback_packet)
88 def on_connection_lost(self, exc: Exception) -> list[PacketIdentifier]:
89 """
90 Fails waiting commands, they aren't re-sent. Returns their packet
91 identifiers, which must be released.
92 """
93 pending, self._pending = self._pending, {}
95 for command in pending.values():
96 if not command.future.done(): 96 ↛ 95line 96 didn't jump to line 95 because the condition on line 96 was always true
97 command.future.set_exception(exc)
99 return list(pending)
101 @staticmethod
102 def _create_future() -> asyncio.Future[Any]:
103 return asyncio.get_running_loop().create_future()
105 async def _send(
106 self,
107 packet_identifier: PacketIdentifier,
108 packet: bytes,
109 future: asyncio.Future[_Result],
110 ) -> _Result:
111 try:
112 self._context.server_limits.check_packet_size(packet)
113 connection = self._context.ensure_connected()
114 except (NotConnectedError, ServerLimitError):
115 await self._session.release_packet_identifier(packet_identifier)
116 raise
118 self._pending[packet_identifier] = _PendingCommand(future, self._context.now())
120 await self._context.write(connection, packet)
122 return await future
124 async def _complete(
125 self, packet_identifier: PacketIdentifier, result: CommandResult
126 ) -> None:
127 await self._session.release_packet_identifier(packet_identifier)
129 if (command := self._pending.pop(packet_identifier, None)) is None: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 return
132 duration = self._context.now() - command.sent_at
133 metrics = self._context.metrics
135 if isinstance(result, SubscribeResult):
136 metrics.on_subscribe_completed(result.reason_codes, duration)
137 else:
138 metrics.on_unsubscribe_completed(result.reason_codes, duration)
140 if not command.future.done(): 140 ↛ exitline 140 didn't return from function '_complete' because the condition on line 140 was always true
141 command.future.set_result(result)