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 ADDED
@@ -0,0 +1,9 @@
1
+ from .server import Server
2
+ from .client import Client
3
+
4
+ server = Server
5
+ client = Client
6
+
7
+ __all__ = [
8
+ "server","Server","client","Client"
9
+ ]
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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
File without changes
@@ -0,0 +1 @@
1
+ qws