peer-protocol 0.0.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.
@@ -0,0 +1 @@
1
+ dist/
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: peer-protocol
3
+ Version: 0.0.2
4
+ Summary: 既能做客户端也能做服务端
5
+ Project-URL: Repository, https://github.com/XiaoHui2023/peer-protocol
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: aiohttp>=3.9
9
+ Description-Content-Type: text/markdown
10
+
11
+ # peer-protocol
@@ -0,0 +1 @@
1
+ # peer-protocol
File without changes
File without changes
@@ -0,0 +1,5 @@
1
+ from .peer import Peer
2
+
3
+ __all__ = [
4
+ "Peer",
5
+ ]
@@ -0,0 +1,190 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import aiohttp
5
+ from typing import Optional, Any
6
+ from urllib.parse import urlparse, ParseResult
7
+ from .peer import Peer
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class Client(Peer):
12
+ def __init__(
13
+ self,
14
+ url: str="http://127.0.0.1:8080",
15
+ retry_interval: float = 2,
16
+ retry_timeout: float = 5,
17
+ *args,
18
+ **kwargs,
19
+ ):
20
+ """
21
+ Args:
22
+ url: 服务端 URL
23
+ retry_interval: 重试间隔时间
24
+ retry_timeout: 重试超时时间
25
+ Attributes:
26
+ _parsed_url: 解析后的 URL
27
+ _session: HTTP 会话
28
+ _ws: WebSocket 连接
29
+ _ws_task: WebSocket 任务
30
+ _running: 是否运行
31
+ _connected: 是否连接
32
+ """
33
+ super().__init__(*args, **kwargs)
34
+ self.url = url
35
+ self.retry_interval = retry_interval
36
+ self.retry_timeout = retry_timeout
37
+
38
+ self._parsed_url = self._parse_url(url)
39
+ self._session: Optional[aiohttp.ClientSession] = None
40
+ self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
41
+ self._ws_task: Optional[asyncio.Task] = None
42
+ self._running = False
43
+ self._connected = False
44
+
45
+ @property
46
+ def connected(self) -> bool:
47
+ """服务端是否在线"""
48
+ return self._connected
49
+
50
+ def _parse_url(self, url: str) -> ParseResult:
51
+ """解析 URL"""
52
+ url = url.strip().rstrip("/")
53
+ if url and not url.startswith(("http://", "https://")):
54
+ url = "http://" + url
55
+ parsed = urlparse(url)
56
+ if parsed.hostname is None:
57
+ raise ValueError(
58
+ f"无效的 url: {url!r},"
59
+ "请使用完整 URL(如 http://127.0.0.1:8080)或 host:port 格式"
60
+ )
61
+ if parsed.port is None:
62
+ if parsed.scheme == "https":
63
+ port = 443
64
+ else:
65
+ port = 80
66
+ parsed = parsed._replace(port=port)
67
+ return parsed
68
+
69
+ async def _check_server(self) -> bool:
70
+ """纯 TCP 连通性检测:只检查服务端 IP:端口是否可达"""
71
+ host = self._parsed_url.hostname
72
+ port = self._parsed_url.port
73
+ try:
74
+ _, writer = await asyncio.wait_for(
75
+ asyncio.open_connection(host, port), timeout=self.retry_timeout
76
+ )
77
+ writer.close()
78
+ await writer.wait_closed()
79
+ return True
80
+ except Exception:
81
+ return False
82
+
83
+ async def _wait_for_server(self):
84
+ """轮询服务端,直到 TCP 可达(无限重试)"""
85
+ while self._running:
86
+ if await self._check_server():
87
+ return
88
+ await asyncio.sleep(self.retry_interval)
89
+
90
+ async def _ws_loop(self):
91
+ """WebSocket 连接循环:连接 → 收消息 → 断线重连"""
92
+ while self._running:
93
+ await self._wait_for_server()
94
+ if not self._running:
95
+ break
96
+
97
+ try:
98
+ self._ws = await self._session.ws_connect(self.url)
99
+ except Exception:
100
+ logger.exception("WebSocket 连接失败")
101
+ continue
102
+
103
+ self._connected = True
104
+ self._callback(self._on_connect, self._ws)
105
+
106
+ try:
107
+ async for msg in self._ws:
108
+ if msg.type == aiohttp.WSMsgType.TEXT:
109
+ asyncio.create_task(self._handle_message(msg.data))
110
+ elif msg.type == aiohttp.WSMsgType.ERROR:
111
+ logger.warning(f"WebSocket 连接异常: {self._ws.exception()}")
112
+ break
113
+ elif msg.type == aiohttp.WSMsgType.CLOSE:
114
+ break
115
+ except Exception:
116
+ logger.exception("WebSocket 消息循环异常")
117
+ finally:
118
+ self._connected = False
119
+ if self._ws and not self._ws.closed:
120
+ await self._ws.close()
121
+ self._ws = None
122
+ self._callback(self._on_disconnect, self._ws)
123
+
124
+ async def _handle_message(self, raw: str):
125
+ """解析服务端推送的消息,调用回调,发回回复"""
126
+ try:
127
+ data = json.loads(raw)
128
+ except json.JSONDecodeError as e:
129
+ logger.warning(f"收到无效的 JSON: {raw} - {e}")
130
+ return
131
+
132
+ self._callback(self._on_receive, data)
133
+
134
+ async def start(self):
135
+ """启动客户端"""
136
+ self._running = True
137
+ self._session = aiohttp.ClientSession()
138
+ self._ws_task = asyncio.create_task(self._ws_loop())
139
+ self._callback(self._on_start)
140
+
141
+ async def stop(self):
142
+ """停止客户端,清理所有资源"""
143
+ self._running = False
144
+
145
+ if self._ws and not self._ws.closed:
146
+ await self._ws.close()
147
+
148
+ if self._ws_task and not self._ws_task.done():
149
+ self._ws_task.cancel()
150
+ try:
151
+ await self._ws_task
152
+ except asyncio.CancelledError:
153
+ pass
154
+ self._ws_task = None
155
+
156
+ if self._session and not self._session.closed:
157
+ await self._session.close()
158
+ self._session = None
159
+
160
+ self._callback(self._on_stop)
161
+
162
+ async def send(self, payload: Any):
163
+ """发送消息"""
164
+ self._callback(self._on_send, payload)
165
+
166
+ if self._ws and not self._ws.closed:
167
+ try:
168
+ await self._ws.send_str(json.dumps(payload, ensure_ascii=False))
169
+ except Exception:
170
+ logger.exception("发送消息失败")
171
+
172
+ def _render_callback(self):
173
+ """渲染回调"""
174
+ super()._render_callback()
175
+
176
+ @self.on_start
177
+ async def _():
178
+ logger.info(f"正在连接服务端: {self.url}")
179
+
180
+ @self.on_stop
181
+ async def _():
182
+ logger.info(f"停止客户端")
183
+
184
+ @self.on_connect
185
+ async def _(ws: aiohttp.ClientWebSocketResponse):
186
+ logger.info(f"连接到服务端: {self.url}")
187
+
188
+ @self.on_disconnect
189
+ async def _(ws: aiohttp.ClientWebSocketResponse):
190
+ logger.info(f"与服务端断开连接: {self.url}")
@@ -0,0 +1,91 @@
1
+ import asyncio
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from typing import Optional, Callable, Awaitable, Any
5
+ from aiohttp import web
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ class Peer(ABC):
10
+ def __init__(self):
11
+ """
12
+ Attributes:
13
+ _on_start: 启动回调列表
14
+ _on_stop: 停止回调列表
15
+ _on_send: 发送消息回调列表
16
+ _on_receive: 接受消息回调列表
17
+ _on_connect: 连接成功回调列表
18
+ _on_disconnect: 连接断开回调列表
19
+ _runner: 应用运行器
20
+ """
21
+ self._on_start: list[Callable[[], Awaitable[None]]] = []
22
+ self._on_stop: list[Callable[[], Awaitable[None]]] = []
23
+ self._on_send: list[Callable[[Any], Awaitable[None]]] = []
24
+ self._on_receive: list[Callable[[Any], Awaitable[None]]] = []
25
+ self._on_connect: list[Callable[[Any], Awaitable[None]]] = []
26
+ self._on_disconnect: list[Callable[[Any], Awaitable[None]]] = []
27
+ self._runner: Optional[web.AppRunner] = None
28
+
29
+ self._render_callback()
30
+
31
+ def on_start(self, callback: Callable[[], Awaitable[None]]) -> None:
32
+ """注册启动回调"""
33
+ self._on_start.append(callback)
34
+
35
+ def on_stop(self, callback: Callable[[], Awaitable[None]]) -> None:
36
+ """注册停止回调"""
37
+ self._on_stop.append(callback)
38
+
39
+ def on_send(self, callback: Callable[[Any], Awaitable[None]]) -> None:
40
+ """注册发送消息回调"""
41
+ self._on_send.append(callback)
42
+
43
+ def on_receive(self, callback: Callable[[Any], Awaitable[None]]) -> None:
44
+ """注册接受消息回调"""
45
+ self._on_receive.append(callback)
46
+
47
+ def on_connect(self, callback: Callable[[Any], Awaitable[None]]) -> None:
48
+ """注册连接成功回调"""
49
+ self._on_connect.append(callback)
50
+
51
+ def on_disconnect(self, callback: Callable[[Any], Awaitable[None]]) -> None:
52
+ """注册连接断开回调"""
53
+ self._on_disconnect.append(callback)
54
+
55
+ def _callback(self, callbacks: list[Callable[[], Awaitable[None]]], *args: Any, **kwargs: Any) -> None:
56
+ for callback in callbacks:
57
+ try:
58
+ asyncio.create_task(callback(*args, **kwargs))
59
+ except:
60
+ logger.exception(f"回调函数执行失败: {callback}")
61
+ continue
62
+
63
+ @abstractmethod
64
+ def start(self) -> None:
65
+ """启动服务"""
66
+ pass
67
+
68
+ @abstractmethod
69
+ def stop(self) -> None:
70
+ """停止服务"""
71
+ pass
72
+
73
+ async def run(self):
74
+ """启动服务并阻塞,直到中断"""
75
+ await self.start()
76
+ try:
77
+ await asyncio.Event().wait()
78
+ except asyncio.CancelledError:
79
+ pass
80
+ finally:
81
+ await self.stop()
82
+
83
+ def _render_callback(self):
84
+ """渲染回调"""
85
+ @self.on_send
86
+ async def _(payload: Any):
87
+ logger.info(f"发送消息: {payload}")
88
+
89
+ @self.on_receive
90
+ async def _(payload: Any):
91
+ logger.info(f"接收消息: {payload}")
@@ -0,0 +1,135 @@
1
+ from .peer import Peer
2
+ import logging
3
+ import json
4
+ import aiohttp
5
+ from aiohttp import web
6
+ from typing import Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class Server(Peer):
11
+ def __init__(
12
+ self,
13
+ host: str = "0.0.0.0",
14
+ port: int = 8080,
15
+ heartbeat: int = 120,
16
+ *args,
17
+ **kwargs,
18
+ ):
19
+ """
20
+ Args:
21
+ host: 主机地址
22
+ port: 端口号
23
+ heartbeat: 心跳间隔秒数
24
+ Attributes:
25
+ _clients: 客户端集合
26
+ """
27
+ super().__init__(*args, **kwargs)
28
+
29
+ self.host = host
30
+ self.port = port
31
+ self.heartbeat = heartbeat
32
+
33
+ self._clients: set[web.WebSocketResponse] = set()
34
+
35
+ def _disconnect(self, ws: web.WebSocketResponse):
36
+ """断开客户端连接"""
37
+ if ws not in self._clients:
38
+ return
39
+ self._clients.discard(ws)
40
+ self._callback(self._on_disconnect, ws)
41
+
42
+ async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
43
+ """WebSocket连接"""
44
+ ws = web.WebSocketResponse(heartbeat=self.heartbeat)
45
+ await ws.prepare(request)
46
+
47
+ self._clients.add(ws)
48
+ self._callback(self._on_connect, request)
49
+
50
+ try:
51
+ async for msg in ws:
52
+ if msg.type == aiohttp.WSMsgType.TEXT:
53
+ try:
54
+ data = json.loads(msg.data)
55
+ except json.JSONDecodeError as e:
56
+ logger.warning(f"收到无效的 JSON: {msg.data} - {e}")
57
+ continue
58
+ except Exception as e:
59
+ logger.warning(f"收到无效的消息: {msg.data} - {e}")
60
+ continue
61
+
62
+ self._callback(self._on_receive, data)
63
+ elif msg.type == aiohttp.WSMsgType.ERROR:
64
+ logger.warning(f"WebSocket 错误: {ws.exception()}")
65
+ break
66
+ elif msg.type == aiohttp.WSMsgType.CLOSE:
67
+ break
68
+ finally:
69
+ self._disconnect(ws)
70
+
71
+ return ws
72
+
73
+ async def broadcast(self, payload: Any) -> None:
74
+ """将消息广播给所有客户端 """
75
+ if not self._clients:
76
+ logger.warning("没有已连接的 WebSocket 客户端,无法转发消息")
77
+ return
78
+
79
+ self._callback(self._on_send, payload)
80
+
81
+ dead: set[web.WebSocketResponse] = set()
82
+ for ws in self._clients:
83
+ try:
84
+ await ws.send_str(json.dumps(payload, ensure_ascii=False))
85
+ except Exception:
86
+ dead.add(ws)
87
+ for ws in dead:
88
+ self._disconnect(ws)
89
+
90
+ def _create_app(self) -> web.Application:
91
+ """创建应用"""
92
+ app = web.Application()
93
+ app.router.add_get("/ws", self._handle_ws)
94
+ return app
95
+
96
+ async def start(self):
97
+ """启动服务"""
98
+ app = self._create_app()
99
+ self._runner = web.AppRunner(app)
100
+ await self._runner.setup()
101
+ site = web.TCPSite(self._runner, self.host, self.port)
102
+ await site.start()
103
+ self._callback(self._on_start)
104
+
105
+ async def stop(self):
106
+ """停止服务"""
107
+ for ws in list(self._clients):
108
+ await ws.close()
109
+ self._clients.clear()
110
+
111
+ if self._runner:
112
+ await self._runner.cleanup()
113
+ self._runner = None
114
+
115
+ self._callback(self._on_stop)
116
+
117
+ def _render_callback(self):
118
+ """渲染回调"""
119
+ super()._render_callback()
120
+
121
+ @self.on_start
122
+ async def _():
123
+ logger.info(f"服务端已启动: {self.host}:{self.port}")
124
+
125
+ @self.on_stop
126
+ async def _():
127
+ logger.info(f"服务端已停止: {self.host}:{self.port}")
128
+
129
+ @self.on_connect
130
+ async def _(request: web.Request):
131
+ logger.info(f"客户端已连接: {request.remote}")
132
+
133
+ @self.on_disconnect
134
+ async def _(request: web.Request):
135
+ logger.info(f"客户端已断开: {request.remote}")
@@ -0,0 +1,60 @@
1
+ """
2
+ PyPI 一键发布脚本
3
+ 自动递增补丁版本号并发布到 PyPI
4
+ 用法: python publish.py
5
+ """
6
+
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ SCRIPT_DIR = Path(__file__).resolve().parent
14
+ PYPROJECT = SCRIPT_DIR / "pyproject.toml"
15
+ DIST_DIR = SCRIPT_DIR / "dist"
16
+
17
+
18
+ def run(cmd: str):
19
+ print(f"\n>>> {cmd}")
20
+ result = subprocess.run(cmd, shell=True, cwd=SCRIPT_DIR)
21
+ if result.returncode != 0:
22
+ print(f"\n命令失败 (exit code: {result.returncode})")
23
+ sys.exit(result.returncode)
24
+
25
+
26
+ def bump_version() -> str:
27
+ content = PYPROJECT.read_text(encoding="utf-8")
28
+ match = re.search(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', content, re.MULTILINE)
29
+ if not match:
30
+ print("无法在 pyproject.toml 中找到版本号")
31
+ sys.exit(1)
32
+
33
+ major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
34
+ old_version = f"{major}.{minor}.{patch}"
35
+ new_version = f"{major}.{minor}.{patch + 1}"
36
+
37
+ new_content = content.replace(f'version = "{old_version}"', f'version = "{new_version}"')
38
+ PYPROJECT.write_text(new_content, encoding="utf-8")
39
+ return new_version
40
+
41
+
42
+ def main():
43
+ new_version = bump_version()
44
+ print(f"版本号已更新为: {new_version}")
45
+
46
+ if DIST_DIR.exists():
47
+ print(f"清理 {DIST_DIR} ...")
48
+ shutil.rmtree(DIST_DIR)
49
+
50
+ print("开始构建...")
51
+ run("python -m build")
52
+
53
+ print("上传到 PyPI...")
54
+ run("python -m twine upload dist/*")
55
+
56
+ print("\n发布完成!")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "peer-protocol"
7
+ version = "0.0.2"
8
+ description = "既能做客户端也能做服务端"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ dependencies = [
13
+ "aiohttp>=3.9",
14
+ ]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/XiaoHui2023/peer-protocol"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["peer_protocol"]