wsbuilder 0.2.7__tar.gz → 0.3.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.
Files changed (24) hide show
  1. {wsbuilder-0.2.7/src/wsbuilder.egg-info → wsbuilder-0.3.0}/PKG-INFO +49 -1
  2. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/README.md +48 -0
  3. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/pyproject.toml +1 -1
  4. wsbuilder-0.3.0/src/wsbuilder/__init__.py +65 -0
  5. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder/app.py +16 -0
  6. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder/framework.py +3 -1
  7. wsbuilder-0.3.0/src/wsbuilder/metrics.py +264 -0
  8. wsbuilder-0.3.0/src/wsbuilder/orm.py +849 -0
  9. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder/server.py +93 -11
  10. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder/ws_demo.py +248 -66
  11. {wsbuilder-0.2.7 → wsbuilder-0.3.0/src/wsbuilder.egg-info}/PKG-INFO +49 -1
  12. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder.egg-info/SOURCES.txt +5 -1
  13. wsbuilder-0.3.0/tests/test_metrics.py +87 -0
  14. wsbuilder-0.3.0/tests/test_orm.py +102 -0
  15. wsbuilder-0.2.7/src/wsbuilder/__init__.py +0 -26
  16. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/LICENSE +0 -0
  17. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/setup.cfg +0 -0
  18. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder/__main__.py +0 -0
  19. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder/constants.py +0 -0
  20. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder/http.py +0 -0
  21. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder/ws.py +0 -0
  22. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder.egg-info/dependency_links.txt +0 -0
  23. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder.egg-info/entry_points.txt +0 -0
  24. {wsbuilder-0.2.7 → wsbuilder-0.3.0}/src/wsbuilder.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wsbuilder
3
- Version: 0.2.7
3
+ Version: 0.3.0
4
4
  Summary: Framework ligero HTTP + WebSocket.
5
5
  Author: wsbuilder Authors
6
6
  License-Expression: MIT
@@ -25,6 +25,7 @@ Lightweight Python HTTP + WebSocket framework for building real-time APIs and cu
25
25
  ## Incluye
26
26
 
27
27
  - `wsbuilder.framework`: router, request/response, servidor HTTP, handshake WS y utilidades de frames.
28
+ - `wsbuilder.orm`: ORM flexible para SQLite3 (modelos, QuerySet, filtros, transacciones anidadas).
28
29
  - `wsbuilder.ws_demo`: demo completo HTTP + WebSocket + REST + SQLite.
29
30
 
30
31
  ## Instalacion local
@@ -73,6 +74,53 @@ Para CORS sin variables de entorno:
73
74
  app = App(cors_allow_origin="*")
74
75
  ```
75
76
 
77
+ ## ORM SQLite3 rapido
78
+
79
+ ```python
80
+ from wsbuilder import Database, IntegerField, Model, TextField
81
+
82
+ class User(Model):
83
+ id = IntegerField(primary_key=True, auto_increment=True)
84
+ username = TextField(unique=True, index=True, null=False)
85
+ email = TextField(null=False)
86
+
87
+ db = Database("app.db")
88
+ User.create_table(db)
89
+
90
+ u = User(username="alice", email="alice@example.com")
91
+ u.save(db)
92
+
93
+ admins = User.objects(db).filter(username__contains="ali").order_by("-id").all()
94
+ print([x.to_dict() for x in admins])
95
+ ```
96
+
97
+ ## Metrics (JSON stream)
98
+
99
+ ```python
100
+ from wsbuilder import App
101
+
102
+ app = App()
103
+ app.enable_metrics() # crea /api/metrics y /api/metrics/stream
104
+ ```
105
+
106
+ Probar snapshot:
107
+
108
+ ```bash
109
+ curl http://127.0.0.1:8765/api/metrics
110
+ ```
111
+
112
+ Probar stream NDJSON:
113
+
114
+ ```bash
115
+ curl -N "http://127.0.0.1:8765/api/metrics/stream?interval=1&limit=5"
116
+ ```
117
+
118
+ Modo continuo (no termina solo):
119
+
120
+ ```bash
121
+ curl -N "http://127.0.0.1:8765/api/metrics/stream?interval=1&follow=1"
122
+ ```
123
+
76
124
  ## HTTP streaming (chunked)
77
125
 
78
126
  ```python
@@ -9,6 +9,7 @@ Lightweight Python HTTP + WebSocket framework for building real-time APIs and cu
9
9
  ## Incluye
10
10
 
11
11
  - `wsbuilder.framework`: router, request/response, servidor HTTP, handshake WS y utilidades de frames.
12
+ - `wsbuilder.orm`: ORM flexible para SQLite3 (modelos, QuerySet, filtros, transacciones anidadas).
12
13
  - `wsbuilder.ws_demo`: demo completo HTTP + WebSocket + REST + SQLite.
13
14
 
14
15
  ## Instalacion local
@@ -57,6 +58,53 @@ Para CORS sin variables de entorno:
57
58
  app = App(cors_allow_origin="*")
58
59
  ```
59
60
 
61
+ ## ORM SQLite3 rapido
62
+
63
+ ```python
64
+ from wsbuilder import Database, IntegerField, Model, TextField
65
+
66
+ class User(Model):
67
+ id = IntegerField(primary_key=True, auto_increment=True)
68
+ username = TextField(unique=True, index=True, null=False)
69
+ email = TextField(null=False)
70
+
71
+ db = Database("app.db")
72
+ User.create_table(db)
73
+
74
+ u = User(username="alice", email="alice@example.com")
75
+ u.save(db)
76
+
77
+ admins = User.objects(db).filter(username__contains="ali").order_by("-id").all()
78
+ print([x.to_dict() for x in admins])
79
+ ```
80
+
81
+ ## Metrics (JSON stream)
82
+
83
+ ```python
84
+ from wsbuilder import App
85
+
86
+ app = App()
87
+ app.enable_metrics() # crea /api/metrics y /api/metrics/stream
88
+ ```
89
+
90
+ Probar snapshot:
91
+
92
+ ```bash
93
+ curl http://127.0.0.1:8765/api/metrics
94
+ ```
95
+
96
+ Probar stream NDJSON:
97
+
98
+ ```bash
99
+ curl -N "http://127.0.0.1:8765/api/metrics/stream?interval=1&limit=5"
100
+ ```
101
+
102
+ Modo continuo (no termina solo):
103
+
104
+ ```bash
105
+ curl -N "http://127.0.0.1:8765/api/metrics/stream?interval=1&follow=1"
106
+ ```
107
+
60
108
  ## HTTP streaming (chunked)
61
109
 
62
110
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wsbuilder"
7
- version = "0.2.7"
7
+ version = "0.3.0"
8
8
  description = "Framework ligero HTTP + WebSocket."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,65 @@
1
+ """wsbuilder: mini framework HTTP + WebSocket."""
2
+
3
+ from .framework import (
4
+ App,
5
+ HTTPServer,
6
+ Request,
7
+ Response,
8
+ Route,
9
+ Router,
10
+ WebSocket,
11
+ parse_close_payload,
12
+ parse_query_string,
13
+ )
14
+ from .metrics import AppMetrics, install_metrics
15
+ from .orm import (
16
+ BlobField,
17
+ BooleanField,
18
+ Database,
19
+ DateTimeField,
20
+ Field,
21
+ IntegerField,
22
+ JSONField,
23
+ Model,
24
+ QuerySet,
25
+ RealField,
26
+ SQL,
27
+ TextField,
28
+ Transaction,
29
+ create_tables,
30
+ drop_tables,
31
+ quote_identifier,
32
+ validate_identifier,
33
+ )
34
+
35
+ __version__ = "0.3.0"
36
+ __all__ = [
37
+ "App",
38
+ "HTTPServer",
39
+ "Request",
40
+ "Response",
41
+ "Route",
42
+ "Router",
43
+ "WebSocket",
44
+ "AppMetrics",
45
+ "install_metrics",
46
+ "Database",
47
+ "Model",
48
+ "QuerySet",
49
+ "Transaction",
50
+ "Field",
51
+ "IntegerField",
52
+ "TextField",
53
+ "RealField",
54
+ "BlobField",
55
+ "BooleanField",
56
+ "DateTimeField",
57
+ "JSONField",
58
+ "SQL",
59
+ "create_tables",
60
+ "drop_tables",
61
+ "quote_identifier",
62
+ "validate_identifier",
63
+ "parse_close_payload",
64
+ "parse_query_string",
65
+ ]
@@ -32,6 +32,7 @@ class App:
32
32
  self.ws_routes = {}
33
33
  self.startup_hooks = []
34
34
  self.cors_allow_origin = (cors_allow_origin or "").strip()
35
+ self.metrics = None
35
36
 
36
37
  def route(self, path, methods=("GET",), kind="plain"):
37
38
  def decorator(func):
@@ -56,6 +57,21 @@ class App:
56
57
  def add_startup(self, func):
57
58
  self.startup_hooks.append(func)
58
59
 
60
+ def enable_metrics(
61
+ self,
62
+ path="/api/metrics",
63
+ stream_path="/api/metrics/stream",
64
+ app_name=None,
65
+ ):
66
+ from .metrics import install_metrics
67
+
68
+ return install_metrics(
69
+ self,
70
+ path=path,
71
+ stream_path=stream_path,
72
+ app_name=app_name,
73
+ )
74
+
59
75
  def dispatch(self, request):
60
76
  cors_allow_origin = self.cors_allow_origin
61
77
  if request.method == "OPTIONS":
@@ -3,6 +3,7 @@
3
3
  from .app import App, Route, Router
4
4
  from .constants import DEFAULT_CORS_ALLOW_ORIGIN, MAGIC_WS, STATUS_MESSAGES
5
5
  from .http import Request, Response, parse_http_request, parse_query_string, send_http_response
6
+ from .metrics import AppMetrics, install_metrics
6
7
  from .server import HTTPServer
7
8
  from .ws import (
8
9
  B64_ALPHABET,
@@ -22,6 +23,8 @@ __all__ = [
22
23
  "HTTPServer",
23
24
  "Request",
24
25
  "Response",
26
+ "AppMetrics",
27
+ "install_metrics",
25
28
  "Route",
26
29
  "Router",
27
30
  "WebSocket",
@@ -41,4 +44,3 @@ __all__ = [
41
44
  "send_http_response",
42
45
  "sha1",
43
46
  ]
44
-
@@ -0,0 +1,264 @@
1
+ """Application metrics collector and JSON streaming helpers."""
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from datetime import UTC, datetime
7
+
8
+ from .http import Response
9
+
10
+ DEFAULT_STREAM_POINTS = 5
11
+
12
+
13
+ def _iso_utc(ts):
14
+ return datetime.fromtimestamp(ts, UTC).isoformat()
15
+
16
+
17
+ def _safe_int(value, default):
18
+ try:
19
+ return int(value)
20
+ except Exception:
21
+ return default
22
+
23
+
24
+ def _safe_float(value, default):
25
+ try:
26
+ return float(value)
27
+ except Exception:
28
+ return default
29
+
30
+
31
+ def _is_truthy(value):
32
+ if value is None:
33
+ return False
34
+ text = str(value).strip().lower()
35
+ return text in {"1", "true", "yes", "on"}
36
+
37
+
38
+ class AppMetrics:
39
+ def __init__(self, app_name="wsbuilder-app"):
40
+ self.app_name = app_name
41
+ self.started_at = time.time()
42
+ self._lock = threading.Lock()
43
+
44
+ self.active_tcp_connections = 0
45
+ self.total_tcp_connections = 0
46
+
47
+ self.active_http_requests = 0
48
+ self.total_http_requests = 0
49
+ self.total_http_responses = 0
50
+ self.http_methods = {}
51
+ self.http_paths = {}
52
+ self.http_status = {}
53
+ self.http_total_duration_ms = 0.0
54
+ self.http_max_duration_ms = 0.0
55
+
56
+ self.active_ws_connections = 0
57
+ self.total_ws_upgrades = 0
58
+ self.ws_paths = {}
59
+ self.ws_messages_in = 0
60
+ self.ws_messages_out = 0
61
+ self.ws_bytes_in = 0
62
+ self.ws_bytes_out = 0
63
+
64
+ self.bytes_in = 0
65
+ self.bytes_out = 0
66
+
67
+ self.total_errors = 0
68
+ self.last_error = ""
69
+
70
+ def _inc_map(self, data, key, step=1):
71
+ data[key] = data.get(key, 0) + step
72
+
73
+ def tcp_connection_open(self):
74
+ with self._lock:
75
+ self.active_tcp_connections += 1
76
+ self.total_tcp_connections += 1
77
+
78
+ def tcp_connection_close(self):
79
+ with self._lock:
80
+ if self.active_tcp_connections > 0:
81
+ self.active_tcp_connections -= 1
82
+
83
+ def http_request_started(self, method, path, body_size=0):
84
+ method = (method or "").upper() or "UNKNOWN"
85
+ path = path or "/"
86
+ body_size = max(0, int(body_size or 0))
87
+ with self._lock:
88
+ self.active_http_requests += 1
89
+ self.total_http_requests += 1
90
+ self.bytes_in += body_size
91
+ self._inc_map(self.http_methods, method)
92
+ self._inc_map(self.http_paths, path)
93
+
94
+ def http_response_sent(self, method, path, status, body_size=0, duration_ms=0.0):
95
+ status_key = str(int(status or 0))
96
+ body_size = max(0, int(body_size or 0))
97
+ duration_ms = max(0.0, float(duration_ms or 0.0))
98
+ with self._lock:
99
+ if self.active_http_requests > 0:
100
+ self.active_http_requests -= 1
101
+ self.total_http_responses += 1
102
+ self.bytes_out += body_size
103
+ self._inc_map(self.http_status, status_key)
104
+ self.http_total_duration_ms += duration_ms
105
+ if duration_ms > self.http_max_duration_ms:
106
+ self.http_max_duration_ms = duration_ms
107
+
108
+ def ws_opened(self, path):
109
+ path = path or "/ws/"
110
+ with self._lock:
111
+ self.active_ws_connections += 1
112
+ self.total_ws_upgrades += 1
113
+ self._inc_map(self.ws_paths, path)
114
+
115
+ def ws_closed(self, path):
116
+ with self._lock:
117
+ if self.active_ws_connections > 0:
118
+ self.active_ws_connections -= 1
119
+
120
+ def ws_message_in(self, payload_size=0):
121
+ payload_size = max(0, int(payload_size or 0))
122
+ with self._lock:
123
+ self.ws_messages_in += 1
124
+ self.ws_bytes_in += payload_size
125
+ self.bytes_in += payload_size
126
+
127
+ def ws_message_out(self, payload_size=0):
128
+ payload_size = max(0, int(payload_size or 0))
129
+ with self._lock:
130
+ self.ws_messages_out += 1
131
+ self.ws_bytes_out += payload_size
132
+ self.bytes_out += payload_size
133
+
134
+ def error(self, where, exc):
135
+ msg = f"{where}: {exc}"
136
+ with self._lock:
137
+ self.total_errors += 1
138
+ self.last_error = msg
139
+
140
+ def snapshot(self):
141
+ now = time.time()
142
+ with self._lock:
143
+ methods = dict(self.http_methods)
144
+ paths = dict(self.http_paths)
145
+ status = dict(self.http_status)
146
+ ws_paths = dict(self.ws_paths)
147
+ total_responses = self.total_http_responses
148
+ avg_ms = 0.0
149
+ if total_responses > 0:
150
+ avg_ms = self.http_total_duration_ms / float(total_responses)
151
+
152
+ data = {
153
+ "app_name": self.app_name,
154
+ "timestamp_unix": now,
155
+ "timestamp_utc": _iso_utc(now),
156
+ "started_at_unix": self.started_at,
157
+ "started_at_utc": _iso_utc(self.started_at),
158
+ "uptime_seconds": round(now - self.started_at, 3),
159
+ "connections": {
160
+ "tcp_active": self.active_tcp_connections,
161
+ "tcp_total": self.total_tcp_connections,
162
+ "http_inflight": self.active_http_requests,
163
+ "ws_active": self.active_ws_connections,
164
+ },
165
+ "http": {
166
+ "requests_total": self.total_http_requests,
167
+ "responses_total": self.total_http_responses,
168
+ "methods": methods,
169
+ "paths": paths,
170
+ "status": status,
171
+ "duration_ms_avg": round(avg_ms, 3),
172
+ "duration_ms_max": round(self.http_max_duration_ms, 3),
173
+ },
174
+ "websocket": {
175
+ "upgrades_total": self.total_ws_upgrades,
176
+ "paths": ws_paths,
177
+ "messages_in_total": self.ws_messages_in,
178
+ "messages_out_total": self.ws_messages_out,
179
+ "bytes_in_total": self.ws_bytes_in,
180
+ "bytes_out_total": self.ws_bytes_out,
181
+ },
182
+ "traffic": {
183
+ "bytes_in_total": self.bytes_in,
184
+ "bytes_out_total": self.bytes_out,
185
+ },
186
+ "errors": {
187
+ "total": self.total_errors,
188
+ "last": self.last_error,
189
+ },
190
+ }
191
+ return data
192
+
193
+ def stream_chunks(self, interval_seconds=1.0, max_points=None):
194
+ interval_seconds = _safe_float(interval_seconds, 1.0)
195
+ if interval_seconds < 0.1:
196
+ interval_seconds = 0.1
197
+ if interval_seconds > 60.0:
198
+ interval_seconds = 60.0
199
+ if max_points is not None:
200
+ max_points = _safe_int(max_points, None)
201
+ if max_points is not None and max_points <= 0:
202
+ max_points = None
203
+
204
+ sent = 0
205
+ while True:
206
+ payload = json.dumps(
207
+ self.snapshot(),
208
+ ensure_ascii=False,
209
+ separators=(",", ":"),
210
+ ).encode("utf-8") + b"\n"
211
+ yield payload
212
+ sent += 1
213
+ if max_points is not None and sent >= max_points:
214
+ break
215
+ time.sleep(interval_seconds)
216
+
217
+ def response_snapshot(self):
218
+ return Response.json(self.snapshot())
219
+
220
+ def response_stream(self, interval_seconds=1.0, max_points=None):
221
+ headers = {
222
+ "Cache-Control": "no-cache, no-store, must-revalidate",
223
+ "Pragma": "no-cache",
224
+ "Expires": "0",
225
+ "X-Accel-Buffering": "no",
226
+ }
227
+ return Response.stream(
228
+ self.stream_chunks(interval_seconds=interval_seconds, max_points=max_points),
229
+ content_type="application/x-ndjson; charset=utf-8",
230
+ headers=headers,
231
+ )
232
+
233
+
234
+ def install_metrics(app, path="/api/metrics", stream_path="/api/metrics/stream", app_name=None):
235
+ name = app_name or app.__class__.__name__
236
+ metrics = AppMetrics(app_name=name)
237
+ app.metrics = metrics
238
+
239
+ @app.api(path, methods=("GET",))
240
+ def _metrics_snapshot(request):
241
+ return metrics.response_snapshot()
242
+
243
+ @app.api(stream_path, methods=("GET",))
244
+ def _metrics_stream(request):
245
+ interval = request.query.get("interval", "1.0")
246
+ limit_text = request.query.get("limit", "")
247
+ follow = request.query.get("follow", "")
248
+ if limit_text:
249
+ parsed = _safe_int(limit_text, DEFAULT_STREAM_POINTS)
250
+ if parsed <= 0:
251
+ max_points = None
252
+ else:
253
+ max_points = parsed
254
+ else:
255
+ if _is_truthy(follow):
256
+ max_points = None
257
+ else:
258
+ max_points = DEFAULT_STREAM_POINTS
259
+ return metrics.response_stream(interval_seconds=interval, max_points=max_points)
260
+
261
+ return metrics
262
+
263
+
264
+ __all__ = ["AppMetrics", "install_metrics"]