qws 0.1.0__py3-none-any.whl
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.
- qws/__init__.py +9 -0
- qws/client.py +112 -0
- qws/packet.py +57 -0
- qws/server.py +138 -0
- qws-0.1.0.dist-info/METADATA +25 -0
- qws-0.1.0.dist-info/RECORD +9 -0
- qws-0.1.0.dist-info/WHEEL +5 -0
- qws-0.1.0.dist-info/licenses/LICENSE +0 -0
- qws-0.1.0.dist-info/top_level.txt +1 -0
qws/__init__.py
ADDED
qws/client.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Awaitable, Callable
|
|
3
|
+
|
|
4
|
+
from websockets.asyncio.client import connect, ClientConnection
|
|
5
|
+
from websockets.exceptions import ConnectionClosed
|
|
6
|
+
|
|
7
|
+
from .packet import PacketData, PacketError, make_packet, parse_packet, normalize_event_name
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ClientHandler = Callable[[PacketData], Awaitable[None]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
uri: str = "ws://127.0.0.1:8765",
|
|
17
|
+
warn_on_unknown_event: bool = True,
|
|
18
|
+
) -> None:
|
|
19
|
+
self.uri = uri
|
|
20
|
+
self.warn_on_unknown_event = warn_on_unknown_event
|
|
21
|
+
|
|
22
|
+
self.websocket: ClientConnection | None = None
|
|
23
|
+
self.handlers: dict[str, ClientHandler] = {}
|
|
24
|
+
|
|
25
|
+
self.client_id: str | None = None
|
|
26
|
+
self._connected = asyncio.Event()
|
|
27
|
+
|
|
28
|
+
def on(self, event_name: str):
|
|
29
|
+
"""
|
|
30
|
+
Register an async handler for an event from the server.
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
|
|
34
|
+
@client.on("pong")
|
|
35
|
+
async def pong(data):
|
|
36
|
+
...
|
|
37
|
+
"""
|
|
38
|
+
normalized_name = normalize_event_name(event_name)
|
|
39
|
+
|
|
40
|
+
def decorator(func: ClientHandler):
|
|
41
|
+
self.handlers[normalized_name] = func
|
|
42
|
+
return func
|
|
43
|
+
|
|
44
|
+
return decorator
|
|
45
|
+
|
|
46
|
+
async def wait_until_connected(self) -> None:
|
|
47
|
+
await self._connected.wait()
|
|
48
|
+
|
|
49
|
+
async def send(
|
|
50
|
+
self,
|
|
51
|
+
event_name: str,
|
|
52
|
+
data: PacketData | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Send a packet to the server.
|
|
56
|
+
"""
|
|
57
|
+
if self.websocket is None:
|
|
58
|
+
raise RuntimeError("Client is not connected")
|
|
59
|
+
|
|
60
|
+
await self.websocket.send(make_packet(event_name, data))
|
|
61
|
+
|
|
62
|
+
async def warning(self, message: str, data: PacketData | None = None) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Send a warning packet to the server.
|
|
65
|
+
"""
|
|
66
|
+
await self.send("warning", {
|
|
67
|
+
"message": message,
|
|
68
|
+
"data": data or {},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
async def _dispatch(self, event_name: str, data: PacketData) -> None:
|
|
72
|
+
handler = self.handlers.get(event_name)
|
|
73
|
+
|
|
74
|
+
if handler is None:
|
|
75
|
+
if self.warn_on_unknown_event:
|
|
76
|
+
print(f"[qws client warning] no client handler for event: {event_name}")
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
await handler(data)
|
|
80
|
+
|
|
81
|
+
async def run(self) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Connect to the server and keep listening forever.
|
|
84
|
+
"""
|
|
85
|
+
async with connect(self.uri) as websocket:
|
|
86
|
+
self.websocket = websocket
|
|
87
|
+
self._connected.set()
|
|
88
|
+
|
|
89
|
+
print(f"[qws client] connected to {self.uri}")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
async for raw_message in websocket:
|
|
93
|
+
try:
|
|
94
|
+
event_name, data, timestamp = parse_packet(raw_message)
|
|
95
|
+
except PacketError as error:
|
|
96
|
+
print(f"[qws client warning] {error}")
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
if event_name == "client_connected":
|
|
100
|
+
raw_client_id = data.get("client_id")
|
|
101
|
+
if isinstance(raw_client_id, str):
|
|
102
|
+
self.client_id = raw_client_id
|
|
103
|
+
|
|
104
|
+
await self._dispatch(event_name, data)
|
|
105
|
+
|
|
106
|
+
except ConnectionClosed:
|
|
107
|
+
print("[qws client] disconnected")
|
|
108
|
+
|
|
109
|
+
finally:
|
|
110
|
+
self.websocket = None
|
|
111
|
+
self.client_id = None
|
|
112
|
+
self._connected.clear()
|
qws/packet.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
PacketData = dict[str,Any]
|
|
6
|
+
|
|
7
|
+
class PacketError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
def utc_timestamp() -> str:
|
|
11
|
+
return datetime.now(timezone.utc).isoformat()
|
|
12
|
+
|
|
13
|
+
def normalize_event_name(event_name:str) -> str:
|
|
14
|
+
if not isinstance(event_name, str):
|
|
15
|
+
raise PacketError("event_name must be a string")
|
|
16
|
+
event_name = event_name.strip().lower()
|
|
17
|
+
|
|
18
|
+
if not event_name:
|
|
19
|
+
raise PacketError("event_name cannot be empty")
|
|
20
|
+
|
|
21
|
+
return event_name
|
|
22
|
+
|
|
23
|
+
def make_packet(event_name:str, data:PacketData|None={}) -> str:
|
|
24
|
+
if data is None:
|
|
25
|
+
data = {}
|
|
26
|
+
if not isinstance(data,dict):
|
|
27
|
+
raise PacketError("data must be a dict")
|
|
28
|
+
|
|
29
|
+
return json.dumps({
|
|
30
|
+
"event_name":normalize_event_name(event_name),
|
|
31
|
+
"data":data,
|
|
32
|
+
"timestamp":utc_timestamp(),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
def parse_packet(raw_message:str|bytes) -> tuple[str,PacketData,str]:
|
|
36
|
+
if isinstance(raw_message,bytes):
|
|
37
|
+
raw_message = raw_message.decode('utf-8')
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
packet = json.loads(raw_message)
|
|
41
|
+
except json.JSONDecodeError as error:
|
|
42
|
+
raise PacketError(f"Invalid JSON: {error}") from error
|
|
43
|
+
|
|
44
|
+
if not isinstance(packet, dict):
|
|
45
|
+
raise PacketError("Packet must be a JSON object")
|
|
46
|
+
|
|
47
|
+
event_name = packet.get('event_name')
|
|
48
|
+
data = packet.get('data',{})
|
|
49
|
+
timestamp = packet.get('timestamp',"")
|
|
50
|
+
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
raise PacketError("Packet field 'data' must be a dict/object")
|
|
53
|
+
|
|
54
|
+
if not isinstance(timestamp, str):
|
|
55
|
+
raise PacketError("Packet field 'timestamp' must be a string")
|
|
56
|
+
|
|
57
|
+
return event_name, data, timestamp
|
qws/server.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import uuid
|
|
3
|
+
from typing import Awaitable, Callable, Any
|
|
4
|
+
|
|
5
|
+
from websockets.asyncio.server import serve, ServerConnection
|
|
6
|
+
from websockets.exceptions import ConnectionClosed
|
|
7
|
+
|
|
8
|
+
from .packet import PacketData, PacketError, make_packet, parse_packet, normalize_event_name
|
|
9
|
+
|
|
10
|
+
ServerHandler = Callable[[str, PacketData], Awaitable[None]]
|
|
11
|
+
|
|
12
|
+
class Server:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
host:str='127.0.0.1',
|
|
16
|
+
port:int = 8765,
|
|
17
|
+
warn_on_unknown_event:bool = True
|
|
18
|
+
|
|
19
|
+
):
|
|
20
|
+
self.host:str = host
|
|
21
|
+
self.port:int = port
|
|
22
|
+
self.warn_on_unknown_event:bool = warn_on_unknown_event
|
|
23
|
+
|
|
24
|
+
self.clients:dict[str, ServerConnection] = {}
|
|
25
|
+
self.handlers:dict[str, ServerHandler] = {}
|
|
26
|
+
|
|
27
|
+
def on(self, event_name:str):
|
|
28
|
+
"""
|
|
29
|
+
Register an async handler for an event.
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
|
|
33
|
+
@server.on("ping")
|
|
34
|
+
async def ping(client_id, data):
|
|
35
|
+
...
|
|
36
|
+
"""
|
|
37
|
+
normalized_name = normalize_event_name(event_name)
|
|
38
|
+
def decorator(func:ServerHandler):
|
|
39
|
+
self.handlers[normalized_name] = func
|
|
40
|
+
return func
|
|
41
|
+
return decorator
|
|
42
|
+
|
|
43
|
+
async def send(
|
|
44
|
+
self,
|
|
45
|
+
client_id:str,
|
|
46
|
+
event_name:str,
|
|
47
|
+
data:PacketData|None=None
|
|
48
|
+
) -> bool:
|
|
49
|
+
"""
|
|
50
|
+
Send a packet to one specific client.
|
|
51
|
+
|
|
52
|
+
Returns False if the client does not exist.
|
|
53
|
+
"""
|
|
54
|
+
websocket = self.clients.get(client_id)
|
|
55
|
+
if websocket is None:
|
|
56
|
+
return False
|
|
57
|
+
await websocket.send(make_packet(event_name,data))
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
async def warning(
|
|
61
|
+
self,
|
|
62
|
+
client_id:str,
|
|
63
|
+
message:str,
|
|
64
|
+
data:PacketData|None=None
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""
|
|
67
|
+
Send a warning packet to one specific client.
|
|
68
|
+
"""
|
|
69
|
+
return await self.send(client_id,"warning",{
|
|
70
|
+
'message':message,
|
|
71
|
+
'data':data or {}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
async def broadcast(
|
|
75
|
+
self,
|
|
76
|
+
event_name: str,
|
|
77
|
+
data: PacketData | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Send a packet to every connected client.
|
|
81
|
+
"""
|
|
82
|
+
packet = make_packet(event_name, data)
|
|
83
|
+
dead_clients: list[str] = []
|
|
84
|
+
|
|
85
|
+
for client_id, websocket in self.clients.items():
|
|
86
|
+
try:
|
|
87
|
+
await websocket.send(packet)
|
|
88
|
+
except ConnectionClosed:
|
|
89
|
+
dead_clients.append(client_id)
|
|
90
|
+
|
|
91
|
+
for client_id in dead_clients:
|
|
92
|
+
self.clients.pop(client_id, None)
|
|
93
|
+
|
|
94
|
+
async def _dispatch(self, client_id:str, event_name:str, data:PacketData) -> None:
|
|
95
|
+
handler = self.handlers.get(event_name)
|
|
96
|
+
if handler is None:
|
|
97
|
+
if self.warn_on_unknown_event:
|
|
98
|
+
await self.warning(client_id, f"No server handler for event: {event_name}", {
|
|
99
|
+
"unknown_event": event_name,
|
|
100
|
+
})
|
|
101
|
+
return
|
|
102
|
+
await handler(client_id, data)
|
|
103
|
+
|
|
104
|
+
async def _handle_client(self, websocket: ServerConnection) -> None:
|
|
105
|
+
client_id = str(uuid.uuid4())
|
|
106
|
+
self.clients[client_id] = websocket
|
|
107
|
+
|
|
108
|
+
print(f"[qws server] client connected: {client_id}")
|
|
109
|
+
|
|
110
|
+
await self.send(client_id, "client_connected", {
|
|
111
|
+
"client_id": client_id,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
async for raw_message in websocket:
|
|
116
|
+
try:
|
|
117
|
+
event_name, data, timestamp = parse_packet(raw_message)
|
|
118
|
+
except PacketError as error:
|
|
119
|
+
await self.warning(client_id, str(error))
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
await self._dispatch(client_id, event_name, data)
|
|
123
|
+
|
|
124
|
+
except ConnectionClosed:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
finally:
|
|
128
|
+
self.clients.pop(client_id, None)
|
|
129
|
+
print(f"[qws server] client disconnected: {client_id}")
|
|
130
|
+
|
|
131
|
+
async def start(self) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Start the server and run forever.
|
|
134
|
+
"""
|
|
135
|
+
print(f"[qws server] running at ws://{self.host}:{self.port}")
|
|
136
|
+
|
|
137
|
+
async with serve(self._handle_client, self.host, self.port):
|
|
138
|
+
await asyncio.Future()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qws
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny async JSON event WebSocket wrapper.
|
|
5
|
+
Author: Kevin d'Anunciacao
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/qws
|
|
8
|
+
Project-URL: Repository, https://github.com/yourusername/qws
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: websockets>=16.0
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# qws
|
|
19
|
+
|
|
20
|
+
A tiny async JSON event WebSocket wrapper.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install qws
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
qws/__init__.py,sha256=-mlwUvRY1GuS2aUYSp3DnsgiFjGjJ0KJ9ZM8lOhIX4w,149
|
|
2
|
+
qws/client.py,sha256=gsk0ZkJJGjmGPcVhUPkunKh2519GdiLsuCRnSI6JQhU,3454
|
|
3
|
+
qws/packet.py,sha256=U6iHDI3xdbPNST6WjXaixDGAOyajNjeF1LQgajcRqvk,1720
|
|
4
|
+
qws/server.py,sha256=D4f7uGNI4_14J3tJxdERfvY9iVIH-6A2sjnLA7ag-mA,4256
|
|
5
|
+
qws-0.1.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
qws-0.1.0.dist-info/METADATA,sha256=2l9qqchOiqNWescVBXTnIa0a5_4DBaQ9-27WMD_zp4Y,674
|
|
7
|
+
qws-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
qws-0.1.0.dist-info/top_level.txt,sha256=pBbV9Inwc59HerH-P9NDSAJxpbcUAENjyRJL0yecYaU,4
|
|
9
|
+
qws-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qws
|