一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
.../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
/api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
响应下发(/api/login 与 /api/me 都带),客户端不用手填。
二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。
三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。
四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
静默吞掉):带身份快照,不匹配即丢弃。
五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
(手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。
测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
(装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
474 lines
18 KiB
Python
474 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""MQTT 推送通道的测试。
|
||
|
||
分两层:
|
||
|
||
* **装配层**:注入假客户端,确定性地验证"连接参数、订阅哪两条主题、状态机、
|
||
重连语义、缺 paho 时的降级"——不依赖网络与 broker;
|
||
* **端到端**:自建一个最小 MQTT broker(3.1.1、QoS 0,只实现 CONNECT /
|
||
SUBSCRIBE / PUBLISH / PINGREQ / DISCONNECT),用**真 paho 客户端**连上去,
|
||
让 broker 主动推消息,验证消息真的落进总线:主机消息进消息中心、插件消息
|
||
只进插件队列。没装 paho 时这一层自动跳过。
|
||
"""
|
||
|
||
import json
|
||
import socket
|
||
import struct
|
||
import sys
|
||
import threading
|
||
import time
|
||
import unittest
|
||
|
||
from helpers import VFSTestCase
|
||
|
||
from svfs_client import bus as bus_mod
|
||
from svfs_client import mqtt as mqtt_mod
|
||
from svfs_client import settings, topics
|
||
|
||
assert sys.modules.get("helpers") is not None
|
||
|
||
try:
|
||
import paho.mqtt.client as _paho # noqa: F401 - 端到端测试需要
|
||
HAVE_PAHO = True
|
||
except Exception:
|
||
HAVE_PAHO = False
|
||
|
||
|
||
def config(**overrides):
|
||
base = {"enabled": True, "host": "127.0.0.1", "port": 1883, "tls": False,
|
||
"prefix": "svfs", "org": "acme", "username": "alice",
|
||
"password": "tok", "keepalive": 30}
|
||
base.update(overrides)
|
||
return base
|
||
|
||
|
||
def wait_until(predicate, timeout=10.0, interval=0.02):
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
if predicate():
|
||
return True
|
||
time.sleep(interval)
|
||
return False
|
||
|
||
|
||
class _FakeClient:
|
||
"""paho 客户端的替身:记录调用,手动触发回调。"""
|
||
|
||
def __init__(self):
|
||
self.calls = []
|
||
self.subscriptions = []
|
||
self.on_connect = None
|
||
self.on_disconnect = None
|
||
self.on_message = None
|
||
self.username_pw = None
|
||
self.tls = None
|
||
self.reconnect_delay = None
|
||
|
||
# paho 的接口
|
||
def connect_async(self, host, port, keepalive):
|
||
self.calls.append(("connect_async", host, port, keepalive))
|
||
|
||
def loop_start(self):
|
||
self.calls.append(("loop_start",))
|
||
|
||
def loop_stop(self):
|
||
self.calls.append(("loop_stop",))
|
||
|
||
def disconnect(self):
|
||
self.calls.append(("disconnect",))
|
||
|
||
def subscribe(self, topic):
|
||
self.subscriptions.append(topic)
|
||
self.calls.append(("subscribe", topic))
|
||
|
||
def username_pw_set(self, username, password=None):
|
||
self.username_pw = (username, password)
|
||
|
||
def tls_set(self, **kwargs):
|
||
self.tls = kwargs
|
||
|
||
def reconnect_delay_set(self, min_delay, max_delay):
|
||
self.reconnect_delay = (min_delay, max_delay)
|
||
|
||
# 测试侧的触发点
|
||
def fire_connect(self, code=0):
|
||
self.on_connect(self, None, {}, code, None)
|
||
|
||
def fire_disconnect(self, code=0):
|
||
self.on_disconnect(self, None, None, code, None)
|
||
|
||
def fire_message(self, topic, payload):
|
||
self.on_message(self, None, _Message(topic, payload))
|
||
|
||
|
||
class _Message:
|
||
def __init__(self, topic, payload):
|
||
self.topic = topic
|
||
self.payload = payload if isinstance(payload, bytes) else payload.encode()
|
||
|
||
|
||
class TransportWiringTest(VFSTestCase):
|
||
"""装配层:参数、订阅、状态机(不需要 broker)。"""
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
self.states = []
|
||
self.messages = []
|
||
self.client = _FakeClient()
|
||
|
||
def transport(self, **overrides):
|
||
return mqtt_mod.MqttTransport(
|
||
config(**overrides),
|
||
on_message=lambda topic, payload: self.messages.append((topic, payload)),
|
||
on_state=lambda state, detail: self.states.append((state, detail)),
|
||
client=self.client,
|
||
)
|
||
|
||
def test_disabled_config_does_not_connect(self):
|
||
transport = mqtt_mod.MqttTransport({"enabled": False},
|
||
client=self.client)
|
||
self.assertEqual(transport.start(), mqtt_mod.STATE_DISABLED)
|
||
self.assertEqual(self.client.calls, [])
|
||
|
||
def test_missing_paho_degrades_with_hint(self):
|
||
original = mqtt_mod._import_paho
|
||
mqtt_mod._import_paho = lambda: None
|
||
self.addCleanup(setattr, mqtt_mod, "_import_paho", original)
|
||
transport = mqtt_mod.MqttTransport(config(), on_state=lambda *a: None)
|
||
self.assertEqual(transport.start(), mqtt_mod.STATE_MISSING)
|
||
self.assertIn("paho", transport.detail)
|
||
|
||
def test_start_connects_subscribes_and_reports_state(self):
|
||
transport = self.transport()
|
||
self.assertEqual(transport.start(), mqtt_mod.STATE_CONNECTING)
|
||
self.assertIn(("connect_async", "127.0.0.1", 1883, 30), self.client.calls)
|
||
self.assertIn(("loop_start",), self.client.calls)
|
||
self.assertEqual(self.client.username_pw, ("alice", "tok"))
|
||
self.assertFalse(self.client.tls) # 非 TLS 不调 tls_set
|
||
self.assertEqual(self.client.reconnect_delay,
|
||
(mqtt_mod.RECONNECT_MIN_DELAY, mqtt_mod.RECONNECT_MAX_DELAY))
|
||
|
||
self.client.fire_connect(0)
|
||
self.assertTrue(transport.is_connected())
|
||
# 订阅的是"自己的 + 组织广播"两条,不是整个组织
|
||
self.assertEqual(self.client.subscriptions,
|
||
["svfs/acme/user/alice/#", "svfs/acme/broadcast/#"])
|
||
self.assertEqual(self.states[-1][0], mqtt_mod.STATE_CONNECTED)
|
||
|
||
def test_reconnect_resubscribes(self):
|
||
transport = self.transport()
|
||
transport.start()
|
||
self.client.fire_connect(0)
|
||
self.client.subscriptions.clear()
|
||
# 掉线 → paho 自动重连 → 重新订阅(broker 不记得旧会话的订阅)
|
||
self.client.fire_disconnect(7)
|
||
self.assertEqual(transport.state, mqtt_mod.STATE_CONNECTING)
|
||
self.client.fire_connect(0)
|
||
self.assertEqual(len(self.client.subscriptions), 2)
|
||
self.assertEqual(transport.connected_count, 2)
|
||
|
||
def test_refused_connection_reports_error(self):
|
||
transport = self.transport()
|
||
transport.start()
|
||
self.client.fire_connect(5) # 5 = 未授权
|
||
self.assertEqual(transport.state, mqtt_mod.STATE_ERROR)
|
||
self.assertIn("拒绝", transport.detail)
|
||
|
||
def test_message_callback_hands_payload_to_bus(self):
|
||
transport = self.transport()
|
||
transport.start()
|
||
self.client.fire_connect(0)
|
||
self.client.fire_message("svfs/acme/user/alice/message", '{"body": "hi"}')
|
||
self.assertEqual(self.messages,
|
||
[("svfs/acme/user/alice/message", b'{"body": "hi"}')])
|
||
self.assertEqual(transport.received_count, 1)
|
||
|
||
def test_stop_is_idempotent_and_marks_stopped(self):
|
||
transport = self.transport()
|
||
transport.start()
|
||
self.client.fire_connect(0)
|
||
transport.stop()
|
||
self.assertEqual(transport.state, mqtt_mod.STATE_STOPPED)
|
||
self.assertIn(("disconnect",), self.client.calls)
|
||
self.assertIn(("loop_stop",), self.client.calls)
|
||
# 主动断开后的 on_disconnect 不该被当成"掉线重连中"
|
||
self.client.fire_disconnect(0)
|
||
self.assertEqual(transport.state, mqtt_mod.STATE_STOPPED)
|
||
|
||
def test_tls_config_sets_tls(self):
|
||
transport = self.transport(tls=True, port=8883)
|
||
transport.start()
|
||
self.assertIsNotNone(self.client.tls)
|
||
self.assertIn(("connect_async", "127.0.0.1", 8883, 30), self.client.calls)
|
||
|
||
|
||
class BrokerTest(VFSTestCase):
|
||
"""端到端:真 paho ↔ 自建最小 broker ↔ 统一总线。"""
|
||
|
||
ORG = "acme"
|
||
USER = "alice"
|
||
|
||
def setUp(self):
|
||
super().setUp()
|
||
if not HAVE_PAHO:
|
||
self.skipTest("没装 paho-mqtt,跳过真实 MQTT 端到端")
|
||
self.broker = _MiniBroker()
|
||
self.addCleanup(self.broker.close)
|
||
self.bus = bus_mod.MessageBus(org=self.ORG, user=self.USER)
|
||
self.transport = mqtt_mod.MqttTransport(
|
||
config(host="127.0.0.1", port=self.broker.port, keepalive=5),
|
||
on_message=self.bus.deliver)
|
||
self.addCleanup(self.transport.stop)
|
||
self.assertEqual(self.transport.start(), mqtt_mod.STATE_CONNECTING)
|
||
self.assertTrue(wait_until(lambda: self.transport.is_connected()),
|
||
"客户端没能连上自建 broker")
|
||
|
||
def test_server_push_lands_in_bus(self):
|
||
# broker 记录客户端订阅的主题(验证最小权限订阅)
|
||
self.assertTrue(wait_until(lambda: len(self.broker.subscriptions) >= 2))
|
||
self.assertEqual(sorted(self.broker.subscriptions),
|
||
["svfs/acme/broadcast/#", "svfs/acme/user/alice/#"])
|
||
|
||
# 服务端主动推一条主机消息 + 一条插件消息
|
||
self.broker.publish("svfs/acme/user/alice/message",
|
||
json.dumps({"id": "m1", "title": "备份完成",
|
||
"body": "3 个包"}))
|
||
self.broker.publish("svfs/acme/user/alice/plugin/com.example.dash",
|
||
json.dumps({"cpu": 0.42}))
|
||
self.assertTrue(wait_until(lambda: len(self.bus.log) == 1))
|
||
self.assertTrue(wait_until(lambda: self.bus.pending("com.example.dash") == 1))
|
||
|
||
message = self.bus.log.items()[0]
|
||
self.assertEqual((message["title"], message["body"]), ("备份完成", "3 个包"))
|
||
self.assertTrue(message["unread"])
|
||
# 插件消息没有混进消息中心
|
||
self.assertEqual(len(self.bus.log), 1)
|
||
self.assertEqual(self.bus.drain("com.example.dash")[0]["payload"],
|
||
json.dumps({"cpu": 0.42}))
|
||
|
||
def test_qos1_style_redelivery_is_deduped_end_to_end(self):
|
||
payload = json.dumps({"id": "m2", "body": "同一条"})
|
||
self.broker.publish("svfs/acme/user/alice/message", payload)
|
||
self.broker.publish("svfs/acme/user/alice/message", payload)
|
||
self.assertTrue(wait_until(lambda: len(self.bus.log) == 1))
|
||
time.sleep(0.2)
|
||
self.assertEqual(len(self.bus.log), 1, "重投的两条应该去重成一条")
|
||
self.assertEqual(self.bus.stats()["duplicate"], 1)
|
||
|
||
def test_other_users_message_is_dropped_by_bus(self):
|
||
"""即使 broker 的 ACL 配错、把别人的消息推过来,总线也挡掉。
|
||
|
||
正常情况根本到不了这一步:客户端只订阅 ``user/<自己>/#``,broker 不会
|
||
投别的用户的消息(上一条测试验证的正是这个)。这里用 publish_raw 模拟
|
||
配错的 broker,验证第二道防线。
|
||
"""
|
||
self.broker.publish_raw("svfs/acme/user/bob/message", '{"body": "别人的"}')
|
||
self.assertTrue(wait_until(lambda: self.bus.stats().get("forbidden", 0) == 1))
|
||
self.assertEqual(len(self.bus.log), 0)
|
||
|
||
def test_other_orgs_message_is_dropped_by_bus(self):
|
||
self.broker.publish_raw("svfs/othercorp/user/alice/message",
|
||
'{"body": "别的租户"}')
|
||
self.assertTrue(wait_until(lambda: self.bus.stats().get("forbidden", 0) == 1))
|
||
self.assertEqual(len(self.bus.log), 0)
|
||
|
||
|
||
# ----------------------------------------------------------------------
|
||
# 最小 MQTT broker(只为测试:MQTT 3.1.1、QoS 0、CONNECT/SUBSCRIBE/PUBLISH/
|
||
# PINGREQ/DISCONNECT)。故意不引第三方 broker —— 测试要能离线跑。
|
||
# ----------------------------------------------------------------------
|
||
|
||
|
||
class _MiniBroker:
|
||
"""够用的 MQTT broker:接连接、记订阅、按通配符投递 PUBLISH。"""
|
||
|
||
def __init__(self):
|
||
self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
self._server.bind(("127.0.0.1", 0))
|
||
self._server.listen(4)
|
||
self.port = self._server.getsockname()[1]
|
||
self.subscriptions = [] # broker 收到的订阅(测试断言用)
|
||
self.published = [] # broker 收到的客户端 PUBLISH(本测试没用到)
|
||
self._clients = [] # [(sock, [topics])]
|
||
self._lock = threading.Lock()
|
||
self._closed = False
|
||
threading.Thread(target=self._accept_loop, daemon=True).start()
|
||
|
||
# ---- 对外 -------------------------------------------------------
|
||
|
||
def publish(self, topic, payload):
|
||
"""broker 主动推一条(QoS 0),按订阅投递。"""
|
||
self._send(topic, payload, ignore_subscriptions=False)
|
||
|
||
def publish_raw(self, topic, payload):
|
||
"""无视订阅、推给所有连接(模拟 ACL 配错的 broker)。
|
||
|
||
正常 broker 不会这么干(订阅本身就是 ``user/<自己>/#``),但"broker
|
||
配错"是真实存在的运维事故,总线的归属校验就是为它准备的兜底。
|
||
"""
|
||
self._send(topic, payload, ignore_subscriptions=True)
|
||
|
||
def _send(self, topic, payload, ignore_subscriptions):
|
||
body = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
|
||
packet = _publish_packet(topic, body)
|
||
with self._lock:
|
||
if ignore_subscriptions:
|
||
targets = [sock for sock, _filters in self._clients]
|
||
else:
|
||
targets = [sock for sock, filters in self._clients
|
||
if any(_topic_matches(f, topic) for f in filters)]
|
||
for sock in targets:
|
||
try:
|
||
sock.sendall(packet)
|
||
except OSError:
|
||
pass
|
||
|
||
def close(self):
|
||
self._closed = True
|
||
with self._lock:
|
||
for sock, _filters in self._clients:
|
||
try:
|
||
sock.close()
|
||
except OSError:
|
||
pass
|
||
self._clients = []
|
||
try:
|
||
self._server.close()
|
||
except OSError:
|
||
pass
|
||
|
||
# ---- 内部 -------------------------------------------------------
|
||
|
||
def _accept_loop(self):
|
||
while not self._closed:
|
||
try:
|
||
sock, _addr = self._server.accept()
|
||
except OSError:
|
||
return
|
||
with self._lock:
|
||
self._clients.append((sock, []))
|
||
threading.Thread(target=self._client_loop, args=(sock,),
|
||
daemon=True).start()
|
||
|
||
def _client_loop(self, sock):
|
||
try:
|
||
while not self._closed:
|
||
header = sock.recv(1)
|
||
if not header:
|
||
return
|
||
packet_type = header[0] >> 4
|
||
length, _ = _read_length(sock)
|
||
body = _read_exact(sock, length) if length else b""
|
||
if packet_type == 1: # CONNECT
|
||
sock.sendall(b"\x20\x02\x00\x00")
|
||
elif packet_type == 8: # SUBSCRIBE
|
||
packet_id = body[:2]
|
||
filters = _parse_subscribe(body[2:])
|
||
with self._lock:
|
||
for entry in self._clients:
|
||
if entry[0] is sock:
|
||
entry[1].extend(filters)
|
||
self.subscriptions.extend(filters)
|
||
sock.sendall(b"\x90" + bytes([2 + len(filters)]) + packet_id
|
||
+ b"\x00" * len(filters))
|
||
elif packet_type == 3: # PUBLISH(客户端发的)→ 转投订阅者
|
||
topic, payload = _parse_publish(body)
|
||
self.published.append(topic)
|
||
self._send(topic, payload, ignore_subscriptions=False)
|
||
elif packet_type == 12: # PINGREQ
|
||
sock.sendall(b"\xd0\x00")
|
||
elif packet_type == 14: # DISCONNECT
|
||
return
|
||
except OSError:
|
||
return
|
||
finally:
|
||
with self._lock:
|
||
self._clients = [entry for entry in self._clients if entry[0] is not sock]
|
||
try:
|
||
sock.close()
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _read_length(sock):
|
||
"""MQTT 的可变长度字段(剩下长度)。"""
|
||
multiplier = 1
|
||
value = 0
|
||
for _ in range(4):
|
||
byte = sock.recv(1)
|
||
if not byte:
|
||
raise OSError("连接已关闭")
|
||
digit = byte[0]
|
||
value += (digit & 0x7F) * multiplier
|
||
if not digit & 0x80:
|
||
break
|
||
multiplier *= 128
|
||
return value, multiplier
|
||
|
||
|
||
def _read_exact(sock, count):
|
||
chunks = []
|
||
remaining = count
|
||
while remaining > 0:
|
||
chunk = sock.recv(remaining)
|
||
if not chunk:
|
||
raise OSError("连接已关闭")
|
||
chunks.append(chunk)
|
||
remaining -= len(chunk)
|
||
return b"".join(chunks)
|
||
|
||
|
||
def _parse_subscribe(body):
|
||
"""SUBSCRIBE 载荷:包 id(2) + 若干个 [主题长度(2) + 主题 + QoS(1)]。"""
|
||
filters = []
|
||
offset = 0
|
||
while offset + 2 <= len(body):
|
||
length = struct.unpack(">H", body[offset:offset + 2])[0]
|
||
start = offset + 2
|
||
filters.append(body[start:start + length].decode("utf-8"))
|
||
offset = start + length + 1 # 跳过 QoS 字节(别忘了,否则后面全错位)
|
||
return filters
|
||
|
||
|
||
def _parse_publish(body):
|
||
length = struct.unpack(">H", body[:2])[0]
|
||
topic = body[2:2 + length].decode("utf-8")
|
||
return topic, body[2 + length:]
|
||
|
||
|
||
def _publish_packet(topic, payload):
|
||
encoded = topic.encode("utf-8")
|
||
body = struct.pack(">H", len(encoded)) + encoded + payload
|
||
return b"\x30" + _encode_length(len(body)) + body
|
||
|
||
|
||
def _encode_length(length):
|
||
out = bytearray()
|
||
while True:
|
||
digit = length % 128
|
||
length //= 128
|
||
if length:
|
||
digit |= 0x80
|
||
out.append(digit)
|
||
if not length:
|
||
return bytes(out)
|
||
|
||
|
||
def _topic_matches(topic_filter, topic):
|
||
"""主题过滤器匹配(支持 ``+`` 与末尾的 ``#``)。"""
|
||
filter_parts = topic_filter.split("/")
|
||
topic_parts = topic.split("/")
|
||
for index, part in enumerate(filter_parts):
|
||
if part == "#":
|
||
return True
|
||
if index >= len(topic_parts):
|
||
return False
|
||
if part != "+" and part != topic_parts[index]:
|
||
return False
|
||
return len(filter_parts) == len(topic_parts)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|