simple-tcp-server 0.1.0__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.
- simple_tcp_server-0.1.0/LICENSE +21 -0
- simple_tcp_server-0.1.0/PKG-INFO +68 -0
- simple_tcp_server-0.1.0/README.md +49 -0
- simple_tcp_server-0.1.0/pyproject.toml +17 -0
- simple_tcp_server-0.1.0/simple_tcp_server/Utils.py +27 -0
- simple_tcp_server-0.1.0/simple_tcp_server/__init__.py +7 -0
- simple_tcp_server-0.1.0/simple_tcp_server/stc.py +64 -0
- simple_tcp_server-0.1.0/simple_tcp_server/sts.py +195 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GGN_2015
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple-tcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Simple TCP server/client framework.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: GGN_2015
|
|
8
|
+
Author-email: neko@jlulug.org
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# simple_tcp_server
|
|
20
|
+
Simple TCP server framework.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install simple_tcp_server
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from simple_tcp_server import SimpleTcpServer, SimpleTcpClient
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
HOST = "127.0.0.1"
|
|
36
|
+
PORT = 9999
|
|
37
|
+
|
|
38
|
+
def is_prime(msg: bytes) -> bytes:
|
|
39
|
+
n = int(msg.decode())
|
|
40
|
+
if n < 2:
|
|
41
|
+
return b"$"
|
|
42
|
+
for i in range(2, int(n**0.5)+1):
|
|
43
|
+
if n % i == 0:
|
|
44
|
+
return b"$"
|
|
45
|
+
return b"$$"
|
|
46
|
+
|
|
47
|
+
server = SimpleTcpServer(HOST, PORT, is_prime, quit_token=b"quit")
|
|
48
|
+
t = threading.Thread(target=server.mainloop, daemon=True)
|
|
49
|
+
t.start()
|
|
50
|
+
|
|
51
|
+
time.sleep(0.2)
|
|
52
|
+
|
|
53
|
+
client = SimpleTcpClient(HOST, PORT)
|
|
54
|
+
|
|
55
|
+
numbers = [b"2", b"10", b"17", b"97", b"1234567"]
|
|
56
|
+
for n in numbers:
|
|
57
|
+
res = client.request(n)
|
|
58
|
+
print(f"{n}: {res}")
|
|
59
|
+
|
|
60
|
+
# close server by quit_token
|
|
61
|
+
client.request(b"quit")
|
|
62
|
+
for n in numbers:
|
|
63
|
+
res = client.request(n)
|
|
64
|
+
print(f"{n}: {res}")
|
|
65
|
+
|
|
66
|
+
client.close()
|
|
67
|
+
```
|
|
68
|
+
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# simple_tcp_server
|
|
2
|
+
Simple TCP server framework.
|
|
3
|
+
|
|
4
|
+
## Installation
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install simple_tcp_server
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from simple_tcp_server import SimpleTcpServer, SimpleTcpClient
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
HOST = "127.0.0.1"
|
|
18
|
+
PORT = 9999
|
|
19
|
+
|
|
20
|
+
def is_prime(msg: bytes) -> bytes:
|
|
21
|
+
n = int(msg.decode())
|
|
22
|
+
if n < 2:
|
|
23
|
+
return b"$"
|
|
24
|
+
for i in range(2, int(n**0.5)+1):
|
|
25
|
+
if n % i == 0:
|
|
26
|
+
return b"$"
|
|
27
|
+
return b"$$"
|
|
28
|
+
|
|
29
|
+
server = SimpleTcpServer(HOST, PORT, is_prime, quit_token=b"quit")
|
|
30
|
+
t = threading.Thread(target=server.mainloop, daemon=True)
|
|
31
|
+
t.start()
|
|
32
|
+
|
|
33
|
+
time.sleep(0.2)
|
|
34
|
+
|
|
35
|
+
client = SimpleTcpClient(HOST, PORT)
|
|
36
|
+
|
|
37
|
+
numbers = [b"2", b"10", b"17", b"97", b"1234567"]
|
|
38
|
+
for n in numbers:
|
|
39
|
+
res = client.request(n)
|
|
40
|
+
print(f"{n}: {res}")
|
|
41
|
+
|
|
42
|
+
# close server by quit_token
|
|
43
|
+
client.request(b"quit")
|
|
44
|
+
for n in numbers:
|
|
45
|
+
res = client.request(n)
|
|
46
|
+
print(f"{n}: {res}")
|
|
47
|
+
|
|
48
|
+
client.close()
|
|
49
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple-tcp-server"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Simple TCP server/client framework."
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "GGN_2015",email = "neko@jlulug.org"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "MIT"}
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
17
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# 单字节标志符号
|
|
4
|
+
half_eoq = b"$"
|
|
5
|
+
|
|
6
|
+
# 替换转义字符
|
|
7
|
+
def replace_octal_bytes(data: bytes) -> bytes:
|
|
8
|
+
return re.sub(
|
|
9
|
+
re.escape(half_eoq) + rb"(\d{3})",
|
|
10
|
+
lambda m: bytes([int(m[1], 8)]),
|
|
11
|
+
data
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# 处理转义字符
|
|
15
|
+
def anti_escape(msg:bytes) -> bytes:
|
|
16
|
+
return replace_octal_bytes(msg)
|
|
17
|
+
|
|
18
|
+
# 将字符串中的 _eoq() 进行特殊处理
|
|
19
|
+
# 本质上就是补上一个三位八进制数
|
|
20
|
+
def escape(msg:bytes) -> bytes:
|
|
21
|
+
number = f"{half_eoq[0]:03o}".encode()
|
|
22
|
+
return msg.replace(half_eoq, half_eoq + number)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# 获取结束标志(长度总为 2)
|
|
26
|
+
def eoq() -> bytes:
|
|
27
|
+
return half_eoq + half_eoq
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from . import Utils
|
|
4
|
+
|
|
5
|
+
class SimpleTcpClient:
|
|
6
|
+
def __init__(self, host: str, port: int):
|
|
7
|
+
# 协议配置(必须与服务端一致)
|
|
8
|
+
self.max_buffer = 4096
|
|
9
|
+
|
|
10
|
+
# 连接状态
|
|
11
|
+
self.connected = False
|
|
12
|
+
|
|
13
|
+
# 初始化 socket
|
|
14
|
+
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
15
|
+
try:
|
|
16
|
+
self.sock.connect((host, port))
|
|
17
|
+
self.connected = True # 连接成功
|
|
18
|
+
except Exception:
|
|
19
|
+
self.connected = False
|
|
20
|
+
|
|
21
|
+
self.buffer = b""
|
|
22
|
+
|
|
23
|
+
# 析构时自动关闭
|
|
24
|
+
def __del__(self):
|
|
25
|
+
self.close()
|
|
26
|
+
|
|
27
|
+
# 安全关闭
|
|
28
|
+
def close(self):
|
|
29
|
+
self.connected = False
|
|
30
|
+
try:
|
|
31
|
+
self.sock.close()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# 发送并接收(长连接,不抛异常)
|
|
36
|
+
# 如果连接断开,返回 None
|
|
37
|
+
def request(self, msg: bytes) -> Optional[bytes]:
|
|
38
|
+
# 已经断了
|
|
39
|
+
if not self.connected:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# 1. 发送数据(try 保护)
|
|
43
|
+
try:
|
|
44
|
+
send_msg = Utils.escape(msg) + Utils.eoq()
|
|
45
|
+
self.sock.sendall(send_msg)
|
|
46
|
+
except Exception:
|
|
47
|
+
self.close()
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
# 2. 循环接收数据(try 保护)
|
|
51
|
+
while (Utils.eoq() not in self.buffer) and self.connected:
|
|
52
|
+
try:
|
|
53
|
+
data = self.sock.recv(self.max_buffer)
|
|
54
|
+
if not data: # 服务器关闭连接
|
|
55
|
+
self.close()
|
|
56
|
+
return None
|
|
57
|
+
self.buffer += data
|
|
58
|
+
except Exception:
|
|
59
|
+
self.close()
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# 3. 解析消息
|
|
63
|
+
resp, self.buffer = self.buffer.split(Utils.eoq(), 1)
|
|
64
|
+
return Utils.anti_escape(resp)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from typing import Callable, Tuple
|
|
2
|
+
import socket
|
|
3
|
+
import time
|
|
4
|
+
from . import Utils
|
|
5
|
+
|
|
6
|
+
_BytesToBytes = Callable[[bytes], bytes]
|
|
7
|
+
_ClientAddress = Tuple[str, int]
|
|
8
|
+
|
|
9
|
+
class SimpleTcpServer:
|
|
10
|
+
def __init__(self,
|
|
11
|
+
host:str,
|
|
12
|
+
port:int,
|
|
13
|
+
worker_function:_BytesToBytes,
|
|
14
|
+
quit_token:bytes,
|
|
15
|
+
max_listen:int=5,
|
|
16
|
+
client_timeout:float=10.0) -> None:
|
|
17
|
+
|
|
18
|
+
self.host = host
|
|
19
|
+
self.port = port
|
|
20
|
+
self.worker_function = worker_function
|
|
21
|
+
self.quit_token = quit_token
|
|
22
|
+
self.max_listen = max_listen
|
|
23
|
+
self.client_timeout = client_timeout
|
|
24
|
+
self.max_buffer = 1024
|
|
25
|
+
|
|
26
|
+
# 系统运行状态
|
|
27
|
+
self.running = True
|
|
28
|
+
|
|
29
|
+
# 初始化 server_socket
|
|
30
|
+
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
31
|
+
self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
32
|
+
self.server_socket.bind((self.host, self.port))
|
|
33
|
+
self.server_socket.listen(self.max_listen)
|
|
34
|
+
self.server_socket.setblocking(False) # 非阻塞模式
|
|
35
|
+
|
|
36
|
+
# 连接池
|
|
37
|
+
self.conn_pool:dict[_ClientAddress, socket.socket] = dict()
|
|
38
|
+
self.conn_buff:dict[_ClientAddress, bytes] = dict() # 缓冲区池子
|
|
39
|
+
self.last_seen:dict[_ClientAddress, float] = dict() # 记录最晚一次收到消息的时刻
|
|
40
|
+
|
|
41
|
+
# 断开一个现有连接
|
|
42
|
+
# 此连接必须存在
|
|
43
|
+
def _conn_close(self, addr:_ClientAddress):
|
|
44
|
+
self.conn_pool[addr].close()
|
|
45
|
+
del self.conn_pool[addr]
|
|
46
|
+
del self.conn_buff[addr]
|
|
47
|
+
del self.last_seen[addr]
|
|
48
|
+
|
|
49
|
+
# 处理一个用户入项连接
|
|
50
|
+
def _handle(self, conn:socket.socket, addr:_ClientAddress):
|
|
51
|
+
|
|
52
|
+
# 出现了客户端地址冲突
|
|
53
|
+
# 把旧的客户端强制切断
|
|
54
|
+
if self.conn_pool.get(addr) is not None:
|
|
55
|
+
self._conn_close(addr)
|
|
56
|
+
|
|
57
|
+
# 此时一定没有地址重复的问题, 可以将其加入到地址序列中
|
|
58
|
+
conn.setblocking(False)
|
|
59
|
+
self.conn_pool[addr] = conn # 套接字
|
|
60
|
+
self.conn_buff[addr] = b"" # 缓冲区
|
|
61
|
+
self.last_seen[addr] = time.time() # 初次连接视为消息
|
|
62
|
+
|
|
63
|
+
# 试图发现新的连接
|
|
64
|
+
def _try_accept(self):
|
|
65
|
+
try:
|
|
66
|
+
conn, addr = self.server_socket.accept()
|
|
67
|
+
self._handle(conn, addr)
|
|
68
|
+
except BlockingIOError: # 没有发现新的连接
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
# 试图对一个连接获取数据
|
|
72
|
+
# 返回当前连接是不是真的死了
|
|
73
|
+
def _acquire_once(self, addr:_ClientAddress) -> bool:
|
|
74
|
+
conn = self.conn_pool[addr]
|
|
75
|
+
died = False # 假定还没死
|
|
76
|
+
try:
|
|
77
|
+
msg = conn.recv(self.max_buffer)
|
|
78
|
+
if msg == b"": # 读到一个空消息说明对方死掉了
|
|
79
|
+
died = True
|
|
80
|
+
|
|
81
|
+
# 追加非空消息
|
|
82
|
+
else:
|
|
83
|
+
self.conn_buff[addr] += msg
|
|
84
|
+
self.last_seen[addr] = time.time()
|
|
85
|
+
|
|
86
|
+
# 阻塞错误说明对方没写东西,但是还没死, 其他错误说明对方死了
|
|
87
|
+
except Exception as err:
|
|
88
|
+
died = not isinstance(err, BlockingIOError)
|
|
89
|
+
return died
|
|
90
|
+
|
|
91
|
+
# 统计 bytes 串出现次数
|
|
92
|
+
def _count_bytes(self, data: bytes, sub: bytes) -> int:
|
|
93
|
+
if not sub:
|
|
94
|
+
return 0
|
|
95
|
+
i = 0 # 初始化位置
|
|
96
|
+
count = 0
|
|
97
|
+
len_sub = len(sub)
|
|
98
|
+
while (i := data.find(sub, i)) != -1: # 不允许重叠
|
|
99
|
+
count += 1
|
|
100
|
+
i += len_sub
|
|
101
|
+
return count
|
|
102
|
+
|
|
103
|
+
# 计算单次请求结果并发送
|
|
104
|
+
# 如果对方死了返回 True
|
|
105
|
+
def _calc_and_resp(self, addr:_ClientAddress, msg_now:bytes) -> bool:
|
|
106
|
+
msg_get = Utils.anti_escape(msg_now)
|
|
107
|
+
|
|
108
|
+
# 为了避免外包程序报错,需要使用 try 保护
|
|
109
|
+
try:
|
|
110
|
+
ret_ans = self.worker_function(msg_get)
|
|
111
|
+
except Exception as err:
|
|
112
|
+
ret_ans = str(err).encode("utf-8")
|
|
113
|
+
|
|
114
|
+
send_ans = Utils.escape(ret_ans) # 避免数据中 eoq() 无法正常解读
|
|
115
|
+
died = False # 检查对方是否死了
|
|
116
|
+
try:
|
|
117
|
+
self.conn_pool[addr].sendall(send_ans + Utils.eoq())
|
|
118
|
+
self.last_seen[addr] = time.time() # 我们发消息也算对方的行为
|
|
119
|
+
except Exception:
|
|
120
|
+
died = True
|
|
121
|
+
return died # 正常 sendall 了说明对方没死
|
|
122
|
+
|
|
123
|
+
# 试图给一个客户端写回信
|
|
124
|
+
# 返回当前连接是不是真的死了
|
|
125
|
+
def _response_once(self, addr:_ClientAddress) -> bool:
|
|
126
|
+
buff = self.conn_buff[addr]
|
|
127
|
+
|
|
128
|
+
# 当前没有收到任何信息,无从判断这家伙死没死
|
|
129
|
+
if len(buff) == 0:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
# 不是协商消息,检查信息完整性
|
|
133
|
+
# 所有的消息以 $$ 作为结尾,消息内部保证没有连续的 $$
|
|
134
|
+
if self._count_bytes(buff, Utils.eoq()) == 0:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
# 处理当前消息
|
|
138
|
+
msg_now, self.conn_buff[addr] = buff.split(Utils.eoq(), maxsplit=1)
|
|
139
|
+
assert len(msg_now) != 0
|
|
140
|
+
|
|
141
|
+
# 检测到了退出标志
|
|
142
|
+
# 需要准备将服务器关停(但是不用马上关停也行)
|
|
143
|
+
if msg_now == self.quit_token:
|
|
144
|
+
self.running = False
|
|
145
|
+
|
|
146
|
+
# 计算单次请求结果并发送回去
|
|
147
|
+
# 如果对方死了返回 True
|
|
148
|
+
return self._calc_and_resp(addr, msg_now)
|
|
149
|
+
|
|
150
|
+
# 如果想让对方死,那就返回 True
|
|
151
|
+
# 否则就返回 False
|
|
152
|
+
def _chk_timeout(self, addr:_ClientAddress) -> bool:
|
|
153
|
+
if time.time() - self.last_seen[addr] >= self.client_timeout:
|
|
154
|
+
return True # 调用者会自动负责清理资源
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# 对所有客户端进行一次 worker 操作
|
|
158
|
+
# 如果客户端死了 worker 操作必须返回 True
|
|
159
|
+
def _chk_all_temp(self, worker:Callable[[_ClientAddress], bool]):
|
|
160
|
+
for addr in self._all_addr(): # 遍历所有连接
|
|
161
|
+
if worker(addr): # 试图对所有地址对象进行统一操作
|
|
162
|
+
self._conn_close(addr)
|
|
163
|
+
|
|
164
|
+
# 试图从每个池子里获取数据
|
|
165
|
+
# 同时清理池子里的死鱼
|
|
166
|
+
def _acquire_all(self):
|
|
167
|
+
self._chk_all_temp(self._acquire_once)
|
|
168
|
+
|
|
169
|
+
# 试图给所有可以回信的人
|
|
170
|
+
# 写回信,同时清理池子
|
|
171
|
+
def _response_all(self):
|
|
172
|
+
self._chk_all_temp(self._response_once)
|
|
173
|
+
|
|
174
|
+
# 获取当前所有客户端地址
|
|
175
|
+
def _all_addr(self) -> list[_ClientAddress]:
|
|
176
|
+
return list(self.conn_pool.keys())
|
|
177
|
+
|
|
178
|
+
# 回收资源
|
|
179
|
+
# 把所有人踢下线(回收缓冲区资源)
|
|
180
|
+
def _kick_all(self):
|
|
181
|
+
for addr in self._all_addr():
|
|
182
|
+
self._conn_close(addr)
|
|
183
|
+
|
|
184
|
+
# 把所有超时对象踢下线
|
|
185
|
+
def _kick_timeout(self):
|
|
186
|
+
self._chk_all_temp(self._chk_timeout)
|
|
187
|
+
|
|
188
|
+
# 启动服务器主循环(单线程)
|
|
189
|
+
def mainloop(self):
|
|
190
|
+
while self.running:
|
|
191
|
+
self._try_accept() # 试图发现新的链接
|
|
192
|
+
self._acquire_all() # 试图从现有池子中获取数据(我们假定池子里都是活的)
|
|
193
|
+
self._response_all() # 试图给所有人写回信
|
|
194
|
+
self._kick_timeout() # 把所有长时间不吱声的人给踢下线
|
|
195
|
+
self._kick_all()
|