konet 0.1.3__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.
konet-0.1.3/PKG-INFO ADDED
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: konet
3
+ Version: 0.1.3
4
+ Summary: Python SDK for Konet realtime infrastructure
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: websockets>=12.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == "dev"
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
12
+
13
+ # konet (Python SDK)
14
+
15
+ Async Python SDK for [Konet](https://github.com/raucheacho/konet), the
16
+ self-hosted realtime engine (channels, presence, broadcast).
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install konet
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ import asyncio
28
+ from konet import KonetClient
29
+
30
+ async def main():
31
+ async with KonetClient("ws://localhost:4000/socket", token="<your-token>") as client:
32
+ channel = client.channel("room:lobby")
33
+ await channel.subscribe()
34
+ channel.on("message", lambda payload: print(payload))
35
+ await channel.send("message", {"text": "Hello!"})
36
+ await asyncio.sleep(60)
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ ## License
42
+
43
+ MIT
konet-0.1.3/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # konet (Python SDK)
2
+
3
+ Async Python SDK for [Konet](https://github.com/raucheacho/konet), the
4
+ self-hosted realtime engine (channels, presence, broadcast).
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install konet
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```python
15
+ import asyncio
16
+ from konet import KonetClient
17
+
18
+ async def main():
19
+ async with KonetClient("ws://localhost:4000/socket", token="<your-token>") as client:
20
+ channel = client.channel("room:lobby")
21
+ await channel.subscribe()
22
+ channel.on("message", lambda payload: print(payload))
23
+ await channel.send("message", {"text": "Hello!"})
24
+ await asyncio.sleep(60)
25
+
26
+ asyncio.run(main())
27
+ ```
28
+
29
+ ## License
30
+
31
+ MIT
@@ -0,0 +1,25 @@
1
+ """
2
+ konet — Python SDK for Konet realtime infrastructure.
3
+
4
+ Quick start::
5
+
6
+ import asyncio
7
+ from konet import KonetClient
8
+
9
+ async def main():
10
+ async with KonetClient("ws://localhost:4000/socket", token="kt_anon_...") as client:
11
+ channel = client.channel("room:lobby")
12
+ await channel.subscribe()
13
+
14
+ channel.on("message", lambda p: print("received:", p))
15
+ await channel.send("message", {"text": "Hello from Python!"})
16
+ await asyncio.sleep(60)
17
+
18
+ asyncio.run(main())
19
+ """
20
+
21
+ from .client import KonetClient
22
+ from .channel import Channel
23
+
24
+ __all__ = ["KonetClient", "Channel"]
25
+ __version__ = "0.1.0"
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections import defaultdict
6
+ from typing import Any, Callable, Awaitable
7
+
8
+ EventHandler = Callable[[Any], Awaitable[None] | None]
9
+
10
+
11
+ class Channel:
12
+ def __init__(
13
+ self,
14
+ topic: str,
15
+ send_fn: Callable,
16
+ next_ref: Callable[[], str],
17
+ ) -> None:
18
+ self.topic = topic
19
+ self._state: str = "idle" # idle | joining | joined | errored
20
+ self._send_fn = send_fn
21
+ self._next_ref = next_ref
22
+ self._join_ref: str | None = None
23
+ self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
24
+ self._reply_futures: dict[str, asyncio.Future] = {}
25
+
26
+ async def subscribe(self) -> None:
27
+ if self._state in ("joined", "joining"):
28
+ return
29
+
30
+ self._state = "joining"
31
+ ref = self._next_ref()
32
+ self._join_ref = ref
33
+
34
+ loop = asyncio.get_event_loop()
35
+ fut: asyncio.Future = loop.create_future()
36
+ self._reply_futures[ref] = fut
37
+
38
+ await self._send_fn([ref, ref, self.topic, "phx_join", {}])
39
+
40
+ reply = await asyncio.wait_for(fut, timeout=10.0)
41
+ if reply.get("status") == "ok":
42
+ self._state = "joined"
43
+ else:
44
+ self._state = "errored"
45
+ raise RuntimeError(f"Failed to join {self.topic}: {reply.get('response')}")
46
+
47
+ async def unsubscribe(self) -> None:
48
+ if self._state != "joined":
49
+ return
50
+ ref = self._next_ref()
51
+ self._state = "idle"
52
+ await self._send_fn([self._join_ref, ref, self.topic, "phx_leave", {}])
53
+ self._join_ref = None
54
+
55
+ def on(self, event: str, handler: EventHandler) -> Callable[[], None]:
56
+ """Register an event handler. Returns an unsubscribe callable."""
57
+ self._handlers[event].append(handler)
58
+
59
+ def off() -> None:
60
+ self._handlers[event].remove(handler)
61
+
62
+ return off
63
+
64
+ async def send(self, event: str, payload: Any = None) -> None:
65
+ if self._state != "joined":
66
+ raise RuntimeError(f"Channel {self.topic} is not joined")
67
+
68
+ ref = self._next_ref()
69
+ await self._send_fn(
70
+ [self._join_ref, ref, self.topic, "broadcast", {"event": event, "payload": payload or {}}]
71
+ )
72
+
73
+ def _receive(self, frame: list) -> None:
74
+ """Called by the client when a message arrives for this topic."""
75
+ _join_ref, ref, _topic, event, payload = frame
76
+
77
+ if event == "phx_reply":
78
+ fut = self._reply_futures.pop(ref, None)
79
+ if fut and not fut.done():
80
+ fut.set_result(payload if isinstance(payload, dict) else {})
81
+ return
82
+
83
+ for handler in list(self._handlers.get(event, [])):
84
+ result = handler(payload)
85
+ if asyncio.iscoroutine(result):
86
+ asyncio.create_task(result)
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import math
6
+ from typing import Any
7
+ from urllib.parse import urlencode
8
+
9
+ import websockets
10
+ from websockets.exceptions import ConnectionClosed, WebSocketException
11
+
12
+ from .channel import Channel
13
+
14
+
15
+ class KonetClient:
16
+ """
17
+ Async Konet client.
18
+
19
+ Usage::
20
+
21
+ async with KonetClient("ws://localhost:4000/socket", token="kt_anon_...") as client:
22
+ channel = client.channel("room:lobby")
23
+ await channel.subscribe()
24
+ channel.on("message", lambda p: print(p))
25
+ await channel.send("message", {"text": "Hello!"})
26
+ await asyncio.sleep(60)
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ url: str,
32
+ *,
33
+ token: str,
34
+ heartbeat_interval: float = 30.0,
35
+ reconnect_delay: float = 1.0,
36
+ max_reconnect_tries: int = 10,
37
+ ) -> None:
38
+ self._url = url
39
+ self._token = token
40
+ self._heartbeat_interval = heartbeat_interval
41
+ self._reconnect_delay = reconnect_delay
42
+ self._max_reconnect_tries = max_reconnect_tries
43
+
44
+ self._ws: websockets.ClientConnection | None = None
45
+ self._channels: dict[str, Channel] = {}
46
+ self._ref_counter = 0
47
+ self._connected = False
48
+ self._tasks: list[asyncio.Task] = []
49
+
50
+ async def __aenter__(self) -> "KonetClient":
51
+ await self.connect()
52
+ return self
53
+
54
+ async def __aexit__(self, *_: Any) -> None:
55
+ await self.disconnect()
56
+
57
+ def _websocket_url(self) -> str:
58
+ # Phoenix mounts the actual websocket transport at "<socket path>/websocket",
59
+ # not at the socket path itself (e.g. "/socket" -> "/socket/websocket").
60
+ base = self._url.rstrip("/")
61
+ params = urlencode({"token": self._token, "vsn": "2.0.0"})
62
+ return f"{base}/websocket?{params}"
63
+
64
+ async def connect(self) -> None:
65
+ self._ws = await websockets.connect(self._websocket_url())
66
+ self._connected = True
67
+
68
+ self._tasks = [
69
+ asyncio.create_task(self._read_loop()),
70
+ asyncio.create_task(self._heartbeat_loop()),
71
+ ]
72
+
73
+ async def disconnect(self) -> None:
74
+ self._connected = False
75
+ for task in self._tasks:
76
+ task.cancel()
77
+ if self._ws:
78
+ await self._ws.close()
79
+ self._ws = None
80
+
81
+ def channel(self, topic: str) -> Channel:
82
+ if topic not in self._channels:
83
+ self._channels[topic] = Channel(topic, self._send, self._next_ref)
84
+ return self._channels[topic]
85
+
86
+ async def _send(self, frame: list) -> None:
87
+ if self._ws is None:
88
+ raise RuntimeError("Not connected")
89
+ await self._ws.send(json.dumps(frame))
90
+
91
+ def _next_ref(self) -> str:
92
+ self._ref_counter += 1
93
+ return str(self._ref_counter)
94
+
95
+ async def _read_loop(self) -> None:
96
+ reconnect_attempts = 0
97
+ while self._connected:
98
+ try:
99
+ if self._ws is None:
100
+ await asyncio.sleep(0.1)
101
+ continue
102
+
103
+ raw = await self._ws.recv()
104
+ frame = json.loads(raw)
105
+
106
+ if not isinstance(frame, list) or len(frame) != 5:
107
+ continue
108
+
109
+ _join_ref, _ref, topic, event, _payload = frame
110
+
111
+ if topic == "phoenix":
112
+ continue
113
+
114
+ ch = self._channels.get(topic)
115
+ if ch:
116
+ ch._receive(frame)
117
+
118
+ reconnect_attempts = 0
119
+
120
+ except ConnectionClosed:
121
+ if not self._connected:
122
+ break
123
+ if reconnect_attempts >= self._max_reconnect_tries:
124
+ break
125
+ delay = min(self._reconnect_delay * (2 ** reconnect_attempts), 30.0)
126
+ reconnect_attempts += 1
127
+ await asyncio.sleep(delay)
128
+ await self._reconnect()
129
+
130
+ except asyncio.CancelledError:
131
+ break
132
+
133
+ async def _heartbeat_loop(self) -> None:
134
+ while self._connected:
135
+ try:
136
+ await asyncio.sleep(self._heartbeat_interval)
137
+ ref = self._next_ref()
138
+ await self._send([None, ref, "phoenix", "heartbeat", {}])
139
+ except asyncio.CancelledError:
140
+ break
141
+ except Exception:
142
+ pass
143
+
144
+ async def _reconnect(self) -> None:
145
+ try:
146
+ self._ws = await websockets.connect(self._websocket_url())
147
+ except Exception:
148
+ self._ws = None
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: konet
3
+ Version: 0.1.3
4
+ Summary: Python SDK for Konet realtime infrastructure
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: websockets>=12.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == "dev"
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
12
+
13
+ # konet (Python SDK)
14
+
15
+ Async Python SDK for [Konet](https://github.com/raucheacho/konet), the
16
+ self-hosted realtime engine (channels, presence, broadcast).
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install konet
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ import asyncio
28
+ from konet import KonetClient
29
+
30
+ async def main():
31
+ async with KonetClient("ws://localhost:4000/socket", token="<your-token>") as client:
32
+ channel = client.channel("room:lobby")
33
+ await channel.subscribe()
34
+ channel.on("message", lambda payload: print(payload))
35
+ await channel.send("message", {"text": "Hello!"})
36
+ await asyncio.sleep(60)
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ ## License
42
+
43
+ MIT
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ konet/__init__.py
4
+ konet/channel.py
5
+ konet/client.py
6
+ konet.egg-info/PKG-INFO
7
+ konet.egg-info/SOURCES.txt
8
+ konet.egg-info/dependency_links.txt
9
+ konet.egg-info/requires.txt
10
+ konet.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ websockets>=12.0
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ pytest-asyncio>=0.23
@@ -0,0 +1 @@
1
+ konet
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "konet"
7
+ version = "0.1.3"
8
+ description = "Python SDK for Konet realtime infrastructure"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "websockets>=12.0",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["."]
21
+ include = ["konet*"]
konet-0.1.3/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+