wsbuilder 0.1.0__tar.gz → 0.2.2__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,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: wsbuilder
3
+ Version: 0.2.2
4
+ Summary: Framework ligero HTTP + WebSocket extraido de PortHound4.
5
+ Author: wsbuilder Authors
6
+ License-Expression: MIT
7
+ Keywords: websocket,http,framework,server,python
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # wsbuilder
18
+
19
+ `wsbuilder` es un paquete Python extraido de `PortHound4` para construir servidores HTTP + WebSocket sin dependencias externas.
20
+
21
+ Lightweight Python HTTP + WebSocket framework for building real-time APIs and custom servers.
22
+
23
+ **Keywords:** `python`, `http-server`, `websocket`, `framework`, `real-time`, `api`, `socket`.
24
+
25
+ ## Incluye
26
+
27
+ - `wsbuilder.framework`: router, request/response, servidor HTTP, handshake WS y utilidades de frames.
28
+ - `wsbuilder.ws_demo`: demo completo HTTP + WebSocket + REST + SQLite.
29
+
30
+ ## Instalacion local
31
+
32
+ ```bash
33
+ python -m pip install -e .
34
+ ```
35
+
36
+ ## Uso rapido del framework
37
+
38
+ ```python
39
+ from wsbuilder import App, Response, parse_close_payload
40
+
41
+ app = App()
42
+
43
+ @app.view("/")
44
+ def home(_request):
45
+ return Response.html("<h1>wsbuilder</h1>")
46
+
47
+ @app.api("/api/health")
48
+ def health(_request):
49
+ return {"ok": True}
50
+
51
+ @app.ws("/ws/")
52
+ def ws_handler(ws, _request):
53
+ while True:
54
+ fin, opcode, payload, _masked, _mask = ws.recv_frame()
55
+ if opcode == 0x8:
56
+ code, reason = parse_close_payload(payload)
57
+ ws.close(code or 1000, reason or "")
58
+ break
59
+ if opcode == 0x9:
60
+ ws.send_pong(payload)
61
+ continue
62
+ if opcode == 0x1:
63
+ ws.send_text(payload.decode("utf-8", errors="ignore"))
64
+ elif opcode == 0x2:
65
+ ws.send_binary(payload)
66
+
67
+ app.run("0.0.0.0", 8765)
68
+ ```
69
+
70
+ Para CORS sin variables de entorno:
71
+
72
+ ```python
73
+ app = App(cors_allow_origin="*")
74
+ ```
75
+
76
+ ## HTTP streaming (chunked)
77
+
78
+ ```python
79
+ import time
80
+ from wsbuilder import App, Response
81
+
82
+ app = App()
83
+
84
+ @app.view("/stream")
85
+ def stream(_request):
86
+ def chunks():
87
+ for i in range(5):
88
+ yield f"chunk {i}\n"
89
+ time.sleep(1)
90
+ return Response.stream(chunks(), content_type="text/plain; charset=utf-8")
91
+ ```
92
+
93
+ ## Ejecutar demo incluido
94
+
95
+ ```bash
96
+ python -m wsbuilder --host 0.0.0.0 --port 8765
97
+ # o
98
+ wsbuilder --host 0.0.0.0 --port 8765
99
+ ```
100
+
101
+ ## Configuracion CORS
102
+
103
+ - Usa `App(cors_allow_origin="https://tu-dominio.com")`.
104
+ - Usa `App(cors_allow_origin="*")` para permitir cualquier origen.
105
+
106
+ ## CI/CD (GitHub Actions)
107
+
108
+ - `package-build.yml`: construye `sdist` + `wheel`, valida metadata y prueba instalacion/import.
109
+ - `release-from-main.yml` en push a `main`: calcula `semver` automaticamente, crea rama `release/v<version>` desde `main`, actualiza version del paquete, crea tag `v<version>` y publica GitHub Release.
110
+ - `publish-packages.yml` en push de tag `v*`: construye y publica a PyPI/TestPyPI (instalable con `pip`).
111
+ - `publish-packages.yml` en tags `v*`: adjunta los paquetes al GitHub Release.
112
+ - `main-only-from-develop.yml` (workflow `main-pr-source-check`): valida metadata minima del PR hacia `main`.
113
+
114
+ ### Reglas de versionado automatico
115
+
116
+ - `major`: si algun commit trae `BREAKING CHANGE`, `type(scope)!:` o ramas/commits marcados como `breaking`/`major`.
117
+ - `minor`: si hay suficiente peso de `feat`/`feature` (proporcional frente a cambios `patch`) y no hay `major`.
118
+ - `patch`: cualquier otro caso.
119
+
120
+ Recomendado usar Conventional Commits para que el calculo de version sea preciso.
121
+
122
+ ### Secrets requeridos
123
+
124
+ - `RELEASE_BOT_TOKEN` (recomendado): PAT/fine-grained token para crear rama `release/*`, crear tag y publicar GitHub Release.
125
+ - `RULESET_ADMIN_TOKEN` (fallback): usado automaticamente si no existe `RELEASE_BOT_TOKEN`.
126
+ - `PYPI_API_TOKEN`: token de publicacion para PyPI.
127
+ - `TEST_PYPI_API_TOKEN` (opcional): token de publicacion para TestPyPI.
@@ -0,0 +1,111 @@
1
+ # wsbuilder
2
+
3
+ `wsbuilder` es un paquete Python extraido de `PortHound4` para construir servidores HTTP + WebSocket sin dependencias externas.
4
+
5
+ Lightweight Python HTTP + WebSocket framework for building real-time APIs and custom servers.
6
+
7
+ **Keywords:** `python`, `http-server`, `websocket`, `framework`, `real-time`, `api`, `socket`.
8
+
9
+ ## Incluye
10
+
11
+ - `wsbuilder.framework`: router, request/response, servidor HTTP, handshake WS y utilidades de frames.
12
+ - `wsbuilder.ws_demo`: demo completo HTTP + WebSocket + REST + SQLite.
13
+
14
+ ## Instalacion local
15
+
16
+ ```bash
17
+ python -m pip install -e .
18
+ ```
19
+
20
+ ## Uso rapido del framework
21
+
22
+ ```python
23
+ from wsbuilder import App, Response, parse_close_payload
24
+
25
+ app = App()
26
+
27
+ @app.view("/")
28
+ def home(_request):
29
+ return Response.html("<h1>wsbuilder</h1>")
30
+
31
+ @app.api("/api/health")
32
+ def health(_request):
33
+ return {"ok": True}
34
+
35
+ @app.ws("/ws/")
36
+ def ws_handler(ws, _request):
37
+ while True:
38
+ fin, opcode, payload, _masked, _mask = ws.recv_frame()
39
+ if opcode == 0x8:
40
+ code, reason = parse_close_payload(payload)
41
+ ws.close(code or 1000, reason or "")
42
+ break
43
+ if opcode == 0x9:
44
+ ws.send_pong(payload)
45
+ continue
46
+ if opcode == 0x1:
47
+ ws.send_text(payload.decode("utf-8", errors="ignore"))
48
+ elif opcode == 0x2:
49
+ ws.send_binary(payload)
50
+
51
+ app.run("0.0.0.0", 8765)
52
+ ```
53
+
54
+ Para CORS sin variables de entorno:
55
+
56
+ ```python
57
+ app = App(cors_allow_origin="*")
58
+ ```
59
+
60
+ ## HTTP streaming (chunked)
61
+
62
+ ```python
63
+ import time
64
+ from wsbuilder import App, Response
65
+
66
+ app = App()
67
+
68
+ @app.view("/stream")
69
+ def stream(_request):
70
+ def chunks():
71
+ for i in range(5):
72
+ yield f"chunk {i}\n"
73
+ time.sleep(1)
74
+ return Response.stream(chunks(), content_type="text/plain; charset=utf-8")
75
+ ```
76
+
77
+ ## Ejecutar demo incluido
78
+
79
+ ```bash
80
+ python -m wsbuilder --host 0.0.0.0 --port 8765
81
+ # o
82
+ wsbuilder --host 0.0.0.0 --port 8765
83
+ ```
84
+
85
+ ## Configuracion CORS
86
+
87
+ - Usa `App(cors_allow_origin="https://tu-dominio.com")`.
88
+ - Usa `App(cors_allow_origin="*")` para permitir cualquier origen.
89
+
90
+ ## CI/CD (GitHub Actions)
91
+
92
+ - `package-build.yml`: construye `sdist` + `wheel`, valida metadata y prueba instalacion/import.
93
+ - `release-from-main.yml` en push a `main`: calcula `semver` automaticamente, crea rama `release/v<version>` desde `main`, actualiza version del paquete, crea tag `v<version>` y publica GitHub Release.
94
+ - `publish-packages.yml` en push de tag `v*`: construye y publica a PyPI/TestPyPI (instalable con `pip`).
95
+ - `publish-packages.yml` en tags `v*`: adjunta los paquetes al GitHub Release.
96
+ - `main-only-from-develop.yml` (workflow `main-pr-source-check`): valida metadata minima del PR hacia `main`.
97
+
98
+ ### Reglas de versionado automatico
99
+
100
+ - `major`: si algun commit trae `BREAKING CHANGE`, `type(scope)!:` o ramas/commits marcados como `breaking`/`major`.
101
+ - `minor`: si hay suficiente peso de `feat`/`feature` (proporcional frente a cambios `patch`) y no hay `major`.
102
+ - `patch`: cualquier otro caso.
103
+
104
+ Recomendado usar Conventional Commits para que el calculo de version sea preciso.
105
+
106
+ ### Secrets requeridos
107
+
108
+ - `RELEASE_BOT_TOKEN` (recomendado): PAT/fine-grained token para crear rama `release/*`, crear tag y publicar GitHub Release.
109
+ - `RULESET_ADMIN_TOKEN` (fallback): usado automaticamente si no existe `RELEASE_BOT_TOKEN`.
110
+ - `PYPI_API_TOKEN`: token de publicacion para PyPI.
111
+ - `TEST_PYPI_API_TOKEN` (opcional): token de publicacion para TestPyPI.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wsbuilder"
7
- version = "0.1.0"
7
+ version = "0.2.2"
8
8
  description = "Framework ligero HTTP + WebSocket extraido de PortHound4."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -12,8 +12,7 @@ from .framework import (
12
12
  parse_query_string,
13
13
  )
14
14
 
15
- __version__ = "0.1.0"
16
-
15
+ __version__ = "0.2.2"
17
16
  __all__ = [
18
17
  "App",
19
18
  "HTTPServer",
@@ -0,0 +1,115 @@
1
+ from .constants import DEFAULT_CORS_ALLOW_ORIGIN
2
+ from .http import Response
3
+
4
+
5
+ class Route:
6
+ def __init__(self, path, methods, handler, kind):
7
+ self.path = path
8
+ self.methods = {m.upper() for m in methods}
9
+ self.handler = handler
10
+ self.kind = kind
11
+
12
+
13
+ class Router:
14
+ def __init__(self):
15
+ self.routes = []
16
+
17
+ def add(self, route):
18
+ self.routes.append(route)
19
+
20
+ def resolve(self, path, method=None):
21
+ for route in self.routes:
22
+ if route.path != path:
23
+ continue
24
+ if method is None or method.upper() in route.methods:
25
+ return route
26
+ return None
27
+
28
+
29
+ class App:
30
+ def __init__(self, cors_allow_origin=DEFAULT_CORS_ALLOW_ORIGIN):
31
+ self.router = Router()
32
+ self.ws_routes = {}
33
+ self.startup_hooks = []
34
+ self.cors_allow_origin = (cors_allow_origin or "").strip()
35
+
36
+ def route(self, path, methods=("GET",), kind="plain"):
37
+ def decorator(func):
38
+ self.router.add(Route(path, methods, func, kind))
39
+ return func
40
+
41
+ return decorator
42
+
43
+ def view(self, path, methods=("GET",)):
44
+ return self.route(path, methods=methods, kind="plain")
45
+
46
+ def api(self, path, methods=("GET",)):
47
+ return self.route(path, methods=methods, kind="api")
48
+
49
+ def ws(self, path):
50
+ def decorator(func):
51
+ self.ws_routes[path] = func
52
+ return func
53
+
54
+ return decorator
55
+
56
+ def add_startup(self, func):
57
+ self.startup_hooks.append(func)
58
+
59
+ def dispatch(self, request):
60
+ cors_allow_origin = self.cors_allow_origin
61
+ if request.method == "OPTIONS":
62
+ route = self.router.resolve(request.path, method=None)
63
+ if route:
64
+ allow = sorted(route.methods | {"OPTIONS"})
65
+ headers = {
66
+ "Access-Control-Allow-Methods": ", ".join(allow),
67
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-API-Key",
68
+ }
69
+ if cors_allow_origin:
70
+ headers["Access-Control-Allow-Origin"] = cors_allow_origin
71
+ if cors_allow_origin != "*":
72
+ headers["Vary"] = "Origin"
73
+ return Response(status=200, body=b"", headers=headers)
74
+
75
+ route = self.router.resolve(request.path, request.method)
76
+ if not route:
77
+ return Response.text("Not Found", status=404)
78
+
79
+ try:
80
+ result = route.handler(request)
81
+ except Exception as e:
82
+ print(f"[http] handler error {request.method} {request.path}: {e}")
83
+ if route.kind == "api":
84
+ headers = {}
85
+ if cors_allow_origin:
86
+ headers["Access-Control-Allow-Origin"] = cors_allow_origin
87
+ if cors_allow_origin != "*":
88
+ headers["Vary"] = "Origin"
89
+ return Response.json(
90
+ {"status": "error", "message": "Internal Server Error"},
91
+ status=500,
92
+ headers=headers,
93
+ )
94
+ return Response.text("Internal Server Error", status=500)
95
+
96
+ if isinstance(result, Response):
97
+ resp = result
98
+ elif route.kind == "api" and isinstance(result, (dict, list)):
99
+ resp = Response.json(result)
100
+ else:
101
+ resp = Response.text("" if result is None else str(result))
102
+
103
+ if route.kind == "api":
104
+ if cors_allow_origin:
105
+ resp.headers.setdefault("Access-Control-Allow-Origin", cors_allow_origin)
106
+ if cors_allow_origin != "*":
107
+ resp.headers.setdefault("Vary", "Origin")
108
+
109
+ return resp
110
+
111
+ def run(self, host, port, ssl_context=None):
112
+ from .server import HTTPServer
113
+
114
+ server = HTTPServer(host, port, self, ssl_context=ssl_context)
115
+ server.serve_forever()
@@ -0,0 +1,67 @@
1
+ MAGIC_WS = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
2
+ DEFAULT_CORS_ALLOW_ORIGIN = ""
3
+
4
+ STATUS_MESSAGES = {
5
+ 100: "Continue",
6
+ 101: "Switching Protocols",
7
+ 102: "Processing",
8
+ 103: "Early Hints",
9
+ 200: "OK",
10
+ 201: "Created",
11
+ 202: "Accepted",
12
+ 203: "Non-Authoritative Information",
13
+ 204: "No Content",
14
+ 205: "Reset Content",
15
+ 206: "Partial Content",
16
+ 207: "Multi-Status",
17
+ 208: "Already Reported",
18
+ 226: "IM Used",
19
+ 300: "Multiple Choices",
20
+ 301: "Moved Permanently",
21
+ 302: "Found",
22
+ 303: "See Other",
23
+ 304: "Not Modified",
24
+ 305: "Use Proxy",
25
+ 307: "Temporary Redirect",
26
+ 308: "Permanent Redirect",
27
+ 400: "Bad Request",
28
+ 401: "Unauthorized",
29
+ 402: "Payment Required",
30
+ 403: "Forbidden",
31
+ 404: "Not Found",
32
+ 405: "Method Not Allowed",
33
+ 406: "Not Acceptable",
34
+ 407: "Proxy Authentication Required",
35
+ 408: "Request Timeout",
36
+ 409: "Conflict",
37
+ 410: "Gone",
38
+ 411: "Length Required",
39
+ 412: "Precondition Failed",
40
+ 413: "Payload Too Large",
41
+ 414: "URI Too Long",
42
+ 415: "Unsupported Media Type",
43
+ 416: "Range Not Satisfiable",
44
+ 417: "Expectation Failed",
45
+ 418: "I'm a teapot",
46
+ 421: "Misdirected Request",
47
+ 422: "Unprocessable Entity",
48
+ 423: "Locked",
49
+ 424: "Failed Dependency",
50
+ 425: "Too Early",
51
+ 426: "Upgrade Required",
52
+ 428: "Precondition Required",
53
+ 429: "Too Many Requests",
54
+ 431: "Request Header Fields Too Large",
55
+ 451: "Unavailable For Legal Reasons",
56
+ 500: "Internal Server Error",
57
+ 501: "Not Implemented",
58
+ 502: "Bad Gateway",
59
+ 503: "Service Unavailable",
60
+ 504: "Gateway Timeout",
61
+ 505: "HTTP Version Not Supported",
62
+ 506: "Variant Also Negotiates",
63
+ 507: "Insufficient Storage",
64
+ 508: "Loop Detected",
65
+ 510: "Not Extended",
66
+ 511: "Network Authentication Required",
67
+ }
@@ -0,0 +1,44 @@
1
+ """Compatibility facade for the original single-file framework module."""
2
+
3
+ from .app import App, Route, Router
4
+ from .constants import DEFAULT_CORS_ALLOW_ORIGIN, MAGIC_WS, STATUS_MESSAGES
5
+ from .http import Request, Response, parse_http_request, parse_query_string, send_http_response
6
+ from .server import HTTPServer
7
+ from .ws import (
8
+ B64_ALPHABET,
9
+ WebSocket,
10
+ base64_encode,
11
+ handshake_websocket,
12
+ is_ws_request,
13
+ make_ws_frame_bytes,
14
+ parse_close_payload,
15
+ read_ws_frame_raw,
16
+ recv_exact,
17
+ sha1,
18
+ )
19
+
20
+ __all__ = [
21
+ "App",
22
+ "HTTPServer",
23
+ "Request",
24
+ "Response",
25
+ "Route",
26
+ "Router",
27
+ "WebSocket",
28
+ "DEFAULT_CORS_ALLOW_ORIGIN",
29
+ "MAGIC_WS",
30
+ "STATUS_MESSAGES",
31
+ "B64_ALPHABET",
32
+ "base64_encode",
33
+ "handshake_websocket",
34
+ "is_ws_request",
35
+ "make_ws_frame_bytes",
36
+ "parse_close_payload",
37
+ "parse_http_request",
38
+ "parse_query_string",
39
+ "read_ws_frame_raw",
40
+ "recv_exact",
41
+ "send_http_response",
42
+ "sha1",
43
+ ]
44
+
@@ -0,0 +1,190 @@
1
+ import json
2
+
3
+ from .constants import STATUS_MESSAGES
4
+
5
+
6
+ def parse_query_string(qs):
7
+ params = {}
8
+ if not qs:
9
+ return params
10
+ parts = qs.split("&")
11
+ for part in parts:
12
+ if not part:
13
+ continue
14
+ if "=" in part:
15
+ k, v = part.split("=", 1)
16
+ else:
17
+ k, v = part, ""
18
+ params[k] = v
19
+ return params
20
+
21
+
22
+ class Request:
23
+ def __init__(self, method, path, query_string, headers, body, client, tls=None):
24
+ self.method = (method or "").upper()
25
+ self.path = path or "/"
26
+ self.query_string = query_string or ""
27
+ self.query = parse_query_string(self.query_string)
28
+ self.headers = headers or {}
29
+ self.body = body or b""
30
+ self.client = client
31
+ self.tls = tls or {}
32
+
33
+ def text(self, encoding="utf-8"):
34
+ return self.body.decode(encoding, errors="ignore")
35
+
36
+ def json(self):
37
+ try:
38
+ return json.loads(self.text())
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ class Response:
44
+ def __init__(self, status=200, body=b"", headers=None, reason=None, stream=None):
45
+ self.status = status
46
+ self.reason = reason
47
+ self.stream = stream
48
+ self.is_stream = stream is not None
49
+ if self.is_stream:
50
+ self.body = b""
51
+ elif isinstance(body, (bytes, bytearray)):
52
+ self.body = bytes(body)
53
+ else:
54
+ self.body = str(body).encode("utf-8")
55
+ self.headers = headers or {}
56
+
57
+ @classmethod
58
+ def json(cls, data, status=200, headers=None):
59
+ body = json.dumps(data).encode("utf-8")
60
+ hdrs = {"Content-Type": "application/json; charset=utf-8"}
61
+ if headers:
62
+ hdrs.update(headers)
63
+ return cls(status=status, body=body, headers=hdrs)
64
+
65
+ @classmethod
66
+ def text(cls, text, status=200, headers=None):
67
+ hdrs = {"Content-Type": "text/plain; charset=utf-8"}
68
+ if headers:
69
+ hdrs.update(headers)
70
+ return cls(status=status, body=text, headers=hdrs)
71
+
72
+ @classmethod
73
+ def html(cls, html, status=200, headers=None):
74
+ hdrs = {"Content-Type": "text/html; charset=utf-8"}
75
+ if headers:
76
+ hdrs.update(headers)
77
+ return cls(status=status, body=html, headers=hdrs)
78
+
79
+ @classmethod
80
+ def stream(cls, chunks, status=200, headers=None, content_type=None):
81
+ hdrs = {}
82
+ if content_type:
83
+ hdrs["Content-Type"] = content_type
84
+ if headers:
85
+ hdrs.update(headers)
86
+ return cls(status=status, headers=hdrs, stream=chunks)
87
+
88
+
89
+ def parse_http_request(conn, max_header_bytes=65536):
90
+ data = b""
91
+ while b"\r\n\r\n" not in data:
92
+ chunk = conn.recv(1024)
93
+ if not chunk:
94
+ break
95
+ data += chunk
96
+ if len(data) > int(max_header_bytes):
97
+ raise ValueError("Request headers too large")
98
+ header_text, _, remainder = data.partition(b"\r\n\r\n")
99
+ lines = header_text.decode("utf-8", errors="ignore").split("\r\n")
100
+ if not lines:
101
+ return None
102
+ request_line = lines[0]
103
+ parts = request_line.split()
104
+ if len(parts) != 3:
105
+ return None
106
+ method, path, version = parts
107
+ if not version.startswith("HTTP/"):
108
+ return None
109
+ headers = {}
110
+ for line in lines[1:]:
111
+ if ":" in line:
112
+ k, v = line.split(":", 1)
113
+ headers[k.strip().lower()] = v.strip()
114
+ return {
115
+ "method": method,
116
+ "path": path,
117
+ "version": version,
118
+ "headers": headers,
119
+ "remainder": remainder,
120
+ }
121
+
122
+
123
+ def send_http_response(conn, response):
124
+ status_code = response.status
125
+ reason = response.reason or STATUS_MESSAGES.get(status_code, "OK")
126
+ headers = dict(response.headers or {})
127
+ lowermap = {k.lower(): v for k, v in headers.items()}
128
+ if getattr(response, "is_stream", False):
129
+ if "transfer-encoding" not in lowermap and "content-length" not in lowermap:
130
+ headers["Transfer-Encoding"] = "chunked"
131
+ lowermap = {k.lower(): v for k, v in headers.items()}
132
+ elif "content-length" not in lowermap:
133
+ headers["Content-Length"] = str(len(response.body))
134
+ lowermap = {k.lower(): v for k, v in headers.items()}
135
+ if "connection" not in lowermap:
136
+ headers["Connection"] = "close"
137
+ lowermap = {k.lower(): v for k, v in headers.items()}
138
+ status_line = f"HTTP/1.1 {status_code} {reason}\r\n"
139
+ hdrs = ""
140
+ for k, v in headers.items():
141
+ hdrs += f"{k}: {v}\r\n"
142
+ resp = status_line + hdrs + "\r\n"
143
+ try:
144
+ conn.sendall(resp.encode("utf-8"))
145
+ if getattr(response, "is_stream", False):
146
+ use_chunked = "chunked" in lowermap.get("transfer-encoding", "").lower()
147
+ for chunk in _iter_stream_chunks(response.stream):
148
+ if use_chunked:
149
+ conn.sendall(f"{len(chunk):X}\r\n".encode("utf-8"))
150
+ conn.sendall(chunk)
151
+ conn.sendall(b"\r\n")
152
+ else:
153
+ conn.sendall(chunk)
154
+ if use_chunked:
155
+ conn.sendall(b"0\r\n\r\n")
156
+ else:
157
+ conn.sendall(response.body)
158
+ except Exception as e:
159
+ print(f"[http] send error {status_code}: {e}")
160
+
161
+
162
+ def _iter_stream_chunks(source):
163
+ if source is None:
164
+ return
165
+ if isinstance(source, (bytes, bytearray, str)):
166
+ normalized = _normalize_chunk(source)
167
+ if normalized:
168
+ yield normalized
169
+ return
170
+ if hasattr(source, "read") and callable(source.read):
171
+ while True:
172
+ chunk = source.read(8192)
173
+ if not chunk:
174
+ break
175
+ normalized = _normalize_chunk(chunk)
176
+ if normalized:
177
+ yield normalized
178
+ return
179
+ for chunk in source:
180
+ normalized = _normalize_chunk(chunk)
181
+ if normalized:
182
+ yield normalized
183
+
184
+
185
+ def _normalize_chunk(chunk):
186
+ if chunk is None:
187
+ return b""
188
+ if isinstance(chunk, (bytes, bytearray)):
189
+ return bytes(chunk)
190
+ return str(chunk).encode("utf-8")