rilweb 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.
rilweb/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .rilweb import RilWeb
2
+ from .response import HTTPResponse, JSONResponse
3
+ from .request import HTTPRequest
rilweb/cookies.py ADDED
@@ -0,0 +1,30 @@
1
+ import hmac
2
+ import hashlib
3
+ import base64
4
+
5
+
6
+ def sign_cookie(value: str, secret: str) -> str:
7
+ signature = hmac.new(
8
+ secret.encode(),
9
+ value.encode(),
10
+ hashlib.sha256
11
+ ).digest()
12
+
13
+ signature = base64.urlsafe_b64encode(signature).decode().rstrip("=")
14
+
15
+ return f"{value}.{signature}"
16
+
17
+
18
+ def unsign_cookie(signed_value: str, secret: str):
19
+ try:
20
+ value, signature = signed_value.rsplit(".", 1)
21
+
22
+ expected = sign_cookie(value, secret).rsplit(".", 1)[1]
23
+
24
+ if not hmac.compare_digest(signature, expected):
25
+ return None
26
+
27
+ return value
28
+
29
+ except (ValueError, AttributeError):
30
+ return None
rilweb/proto.py ADDED
@@ -0,0 +1,30 @@
1
+ # === IMPORTS ===
2
+ from typing import Dict, List, Optional, Protocol, Self, Callable
3
+
4
+ # === REQUEST ===
5
+
6
+ class RequestLike(Protocol):
7
+ method: str
8
+ path: str
9
+ headers: Dict[str, str]
10
+ query: Dict[str, List[str]]
11
+ body: Optional[bytes]
12
+
13
+ @classmethod
14
+ def from_raw(cls, data: bytes) -> Self:
15
+ ...
16
+
17
+ # === RESPONSE ===
18
+
19
+ class ResponseLike(Protocol):
20
+ status_code: int
21
+ body: bytes
22
+ headers: Optional[Dict[str, str]]
23
+
24
+ def to_bytes(self) -> bytes:
25
+ ...
26
+
27
+ # === MIDDLEWARE ===
28
+
29
+ class MiddlewareLike(Protocol):
30
+ async def __call__(self, request: RequestLike, call_next: Callable) -> Optional[ResponseLike]: ...
rilweb/request.py ADDED
@@ -0,0 +1,58 @@
1
+ # === IMPORTS ===
2
+ from dataclasses import dataclass, field
3
+ from typing import Dict, List, Self, Optional
4
+ from urllib.parse import urlsplit, parse_qs
5
+
6
+ # === HTTP REQUEST ===
7
+
8
+ @dataclass
9
+ class HTTPRequest:
10
+ method: str
11
+ path: str
12
+ headers: Dict[str, str]
13
+ query: Dict[str, List[str]]
14
+ body: Optional[bytes]
15
+ cookies: Dict[str, str] = field(default_factory=dict)
16
+
17
+ @classmethod
18
+ def from_raw(cls, data: bytes) -> Self:
19
+ header_part, _, body = data.partition(b'\r\n\r\n')
20
+ lines = header_part.split(b'\r\n')
21
+
22
+ req_line_parts = lines[0].split(b' ')
23
+
24
+ try:
25
+ method = req_line_parts[0].decode()
26
+ url = req_line_parts[1].decode()
27
+ urlsplitted = urlsplit(url)
28
+
29
+ except (IndexError, UnicodeDecodeError):
30
+ raise ValueError("Invalid HTTP request line")
31
+
32
+ headers = {}
33
+
34
+ for line in lines[1:]:
35
+ if b':' in line:
36
+ key, val = line.split(b':', 1)
37
+
38
+ headers[key.decode('latin-1').strip().lower()] = val.decode('latin-1').strip()
39
+
40
+
41
+ return cls(
42
+ method=method,
43
+ path=urlsplitted.path,
44
+ headers=headers,
45
+ query=parse_qs(urlsplitted.query) if urlsplitted.query else {},
46
+ body=body if body else None,
47
+ cookies=cls._parse_cookies(headers)
48
+ )
49
+
50
+ @staticmethod
51
+ def _parse_cookies(headers: Dict[str, str]) -> Dict[str, str]:
52
+ cookies = {}
53
+ cookie_header = headers.get("cookie", "")
54
+ if cookie_header:
55
+ for cookie in cookie_header.split(";"):
56
+ name, _, value = cookie.strip().partition("=")
57
+ cookies[name] = value
58
+ return cookies
rilweb/response.py ADDED
@@ -0,0 +1,82 @@
1
+ # === IMPORTS ===
2
+ from dataclasses import dataclass
3
+ from typing import Any, Dict, Optional
4
+ import json
5
+
6
+ # === HTTP RESPONSE ===
7
+
8
+ @dataclass
9
+ class HTTPResponse:
10
+ status_code: int
11
+ body: bytes
12
+ headers: Optional[Dict[str, str]] = None
13
+
14
+ def __post_init__(self):
15
+ if self.headers is None:
16
+ self.headers = {}
17
+ self.cookies = {}
18
+
19
+ def to_bytes(self) -> bytes:
20
+ headers = self.headers.copy()
21
+
22
+ if self.body is not None:
23
+ headers.setdefault("Content-Length", str(len(self.body)))
24
+ else:
25
+ headers.setdefault("Content-Length", "0")
26
+
27
+ headers.setdefault("Connection", "close")
28
+
29
+ status_line = f"HTTP/1.1 {self.status_code}\r\n"
30
+
31
+ header_lines = list((
32
+ f"{key}: {value}\r\n"
33
+ for key, value in headers.items()
34
+ ))
35
+
36
+ for name, (value, options) in self.cookies.items():
37
+ cookie = f"{name}={value}"
38
+ if "expires" in options:
39
+ cookie += f"; Expires={options['expires']}"
40
+ if "max_age" in options:
41
+ cookie += f"; Max-Age={options['max_age']}"
42
+ if "domain" in options:
43
+ cookie += f"; Domain={options['domain']}"
44
+ if "path" in options:
45
+ cookie += f"; Path={options['path']}"
46
+ if "samesite" in options:
47
+ cookie += f"; SameSite={options['samesite']}"
48
+ if options.get("secure"):
49
+ cookie += "; Secure"
50
+ if options.get("httponly"):
51
+ cookie += "; HttpOnly"
52
+
53
+ header_lines.append(f"Set-Cookie: {cookie}\r\n")
54
+
55
+ return (
56
+ status_line.encode("ascii")
57
+ + "".join(header_lines).encode("latin-1")
58
+ + b"\r\n"
59
+ + (self.body or b"")
60
+ )
61
+
62
+ @classmethod
63
+ def from_file(cls, file_path: str, **kw) -> "HTTPResponse":
64
+ with open(file_path, "rb") as f:
65
+ body = f.read()
66
+
67
+ kw['body'] = body
68
+ return cls(**kw)
69
+
70
+ def set_cookie(self, name: str, value: str, **options):
71
+ if not all([option in ("expires", "max_age", "domain", "path", "secure", "httponly") for option in options]):
72
+ raise ValueError("Invalid cookie option")
73
+
74
+ self.cookies[name] = (value, options)
75
+
76
+ class JSONResponse(HTTPResponse):
77
+ def __init__(self, status_code: int, headers: Dict[str, str], body: Any, **json_kw):
78
+ headers = headers.copy()
79
+ headers.setdefault("Content-Type", "application/json; charset=utf-8")
80
+
81
+ body = json.dumps(body, **json_kw).encode("utf-8")
82
+ super().__init__(status_code, body, headers)
rilweb/rilweb.py ADDED
@@ -0,0 +1,284 @@
1
+ # === IMPORTS ===
2
+ import socket
3
+ from .proto import ResponseLike, MiddlewareLike
4
+ from .request import HTTPRequest
5
+ from .response import HTTPResponse
6
+ from typing import Callable, Tuple, Dict, Awaitable, TypeAlias, Optional
7
+ from pathlib import Path
8
+ import traceback
9
+ import asyncio
10
+ from .cookies import sign_cookie as _sign_cookie, unsign_cookie as _unsign_cookie
11
+
12
+ # === TYPES ===
13
+
14
+ Handler: TypeAlias = Callable[[HTTPRequest], Awaitable[ResponseLike]]
15
+
16
+ # === MAIN CLASS ===
17
+
18
+ class RilWeb:
19
+ def __init__(self, host: str, port: int, dev: bool = False, secret_key: Optional[str] = None):
20
+ """
21
+ :param host: The host address to bind the server to.
22
+ :param port: The port number to bind the server to.
23
+ :param dev: Whether to run the server in development mode (default is False).
24
+ """
25
+ self.host = host
26
+ self.port = port
27
+ self.dev = dev
28
+ self.secret_key = secret_key
29
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # create and configure
30
+ # socket options
31
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # allow reuse of the address
32
+ self.sock.bind((self.host, self.port))
33
+ self.routes: Dict[Tuple[str, str], Handler] = {}
34
+ self.error_handlers: Dict[int, Handler] = {}
35
+ self.middlewares: list[MiddlewareLike] = []
36
+
37
+ self.tasks: set[asyncio.Task] = set()
38
+
39
+ self.set_error_handler(404, self._404) # set up default error handlers
40
+ self.set_error_handler(500, self._500)
41
+
42
+ # route management
43
+
44
+ def add_route(self, path: str, handler: Handler, method: str = "GET"):
45
+ """
46
+ :param path: The URL path to route.
47
+ :param handler: The handler function to call for this route.
48
+ :param method: The HTTP method for this route (default is "GET").
49
+ """
50
+ self.routes[(path, method)] = handler
51
+
52
+ def route(self, path: str, method: str = "GET"):
53
+ """
54
+ Decorator to add a route to the server.
55
+
56
+ :param path: The URL path to route.
57
+ :param method: The HTTP method for this route (default is "GET").
58
+ """
59
+ def decorator(handler):
60
+ self.add_route(path, handler, method)
61
+ return handler
62
+ return decorator
63
+
64
+ # error handlers
65
+
66
+ def set_error_handler(self, status_code: int, handler: Handler):
67
+ """
68
+ Set an error handler for a specific HTTP status code.
69
+
70
+ :param status_code: The HTTP status code to handle.
71
+ :param handler: The handler function to call for this error.
72
+ """
73
+ self.error_handlers[status_code] = handler
74
+
75
+ def error_handler(self, status_code: int):
76
+ """
77
+ Decorator to set an error handler for a specific HTTP status code.
78
+
79
+ :param status_code: The HTTP status code to handle.
80
+ """
81
+ def decorator(handler):
82
+ self.set_error_handler(status_code, handler)
83
+ return handler
84
+ return decorator
85
+
86
+ # default error handler helpers
87
+
88
+ async def _404(self, request: HTTPRequest) -> ResponseLike:
89
+ html_path = Path(__file__).parent / "404.html"
90
+ return HTTPResponse.from_file(str(html_path), status_code=404)
91
+
92
+ async def _500(self, request: HTTPRequest) -> ResponseLike:
93
+ html_path = Path(__file__).parent / "500.html"
94
+ return HTTPResponse.from_file(str(html_path), status_code=500)
95
+
96
+ async def _make_500(self, request, traceback_str):
97
+ if self.dev and self.error_handlers[500] is self._500:
98
+ return HTTPResponse(500, traceback_str.encode())
99
+
100
+ return await self.error_handlers[500](request)
101
+
102
+
103
+ # request handling
104
+
105
+ async def _handle_req(self, request: HTTPRequest, client_addr: str, client_port: int) -> ResponseLike:
106
+ key = (request.path, request.method)
107
+ handler = self.routes.get(key)
108
+
109
+ print(f'[INCOMING] from {client_addr}:{client_port}')
110
+
111
+ # 404 handling
112
+ if handler is None:
113
+ res = await self.error_handlers[404](request)
114
+ print(f'[{res.status_code}] {request.method} to {request.path}') # logging
115
+ return res
116
+
117
+ # define middleware caller
118
+
119
+ async def call_middleware(index: int, req: HTTPRequest):
120
+ if index >= len(self.middlewares):
121
+ return await handler(req)
122
+
123
+ middleware = self.middlewares[index]
124
+
125
+ return await middleware(
126
+ req,
127
+ lambda r: call_middleware(index + 1, r)
128
+ )
129
+
130
+ # run the route handler
131
+ try:
132
+ res = await call_middleware(0, request)
133
+ except Exception:
134
+ traceback.print_exc()
135
+ res = await self._make_500(request, traceback.format_exc())
136
+
137
+ print(f'[{res.status_code}] {request.method} to {request.path!r}') # logging
138
+
139
+ return res
140
+
141
+ # low-level request reception
142
+ async def _receive_request(self, client_sock: socket.socket) -> bytes:
143
+ loop = asyncio.get_running_loop()
144
+ data = bytearray()
145
+
146
+ # define constants
147
+
148
+ MAX_HEADER_SIZE = 64 * 1024
149
+ MAX_BODY_SIZE = 10 * 1024 * 1024
150
+
151
+ # read http headers
152
+
153
+ while b"\r\n\r\n" not in data:
154
+ chunk = await loop.sock_recv(client_sock, 4096)
155
+
156
+ if not chunk:
157
+ raise ConnectionError("Client disconnected before sending headers")
158
+
159
+ data.extend(chunk)
160
+
161
+ if len(data) > MAX_HEADER_SIZE:
162
+ raise ValueError("HTTP headers are too large")
163
+
164
+ header_end = data.index(b"\r\n\r\n")
165
+ header_data = bytes(data[:header_end])
166
+
167
+ content_length = 0
168
+
169
+ # parse headers
170
+
171
+ for line in header_data.split(b"\r\n")[1:]:
172
+ name, separator, value = line.partition(b":")
173
+
174
+ if not separator:
175
+ continue
176
+
177
+ name = name.strip().lower()
178
+ value = value.strip()
179
+
180
+ if name == b"transfer-encoding":
181
+ raise NotImplementedError("Chunked transfer encoding is not supported yet")
182
+
183
+ if name == b"content-length":
184
+ content_length = int(value)
185
+
186
+ if content_length < 0:
187
+ raise ValueError("Invalid Content-Length")
188
+
189
+ if content_length > MAX_BODY_SIZE:
190
+ raise ValueError("HTTP request body is too large")
191
+
192
+ body_start = header_end + 4
193
+ body_end = body_start + content_length
194
+
195
+ while len(data) < body_end:
196
+ chunk = await loop.sock_recv(client_sock, 4096)
197
+
198
+ if not chunk:
199
+ raise ConnectionError("Client disconnected before sending the complete body")
200
+
201
+ data.extend(chunk)
202
+
203
+ return bytes(data[:body_end])
204
+
205
+ # internal run function
206
+
207
+ async def _run(self):
208
+ self.sock.setblocking(False)
209
+ self.sock.listen(5)
210
+
211
+ loop = asyncio.get_running_loop()
212
+ self.tasks = set()
213
+
214
+ try:
215
+ while True:
216
+ client_sock, client_addr = await loop.sock_accept(self.sock)
217
+
218
+ task = asyncio.create_task(
219
+ self._handle_client(client_sock, client_addr)
220
+ )
221
+
222
+ self.tasks.add(task)
223
+ task.add_done_callback(self.tasks.discard)
224
+
225
+ finally:
226
+ self.sock.close()
227
+
228
+ for task in self.tasks:
229
+ task.cancel()
230
+
231
+ await asyncio.gather(*self.tasks, return_exceptions=True)
232
+ self.tasks.clear()
233
+
234
+ # client handling
235
+
236
+ async def _handle_client(self, client_sock, client_addr):
237
+ client_sock.setblocking(False)
238
+
239
+ try:
240
+ loop = asyncio.get_running_loop()
241
+
242
+ request_data = await self._receive_request(client_sock)
243
+ request = HTTPRequest.from_raw(request_data)
244
+
245
+ response = await self._handle_req(request, *client_addr)
246
+
247
+ await loop.sock_sendall(client_sock, response.to_bytes())
248
+
249
+ except Exception:
250
+ traceback.print_exc()
251
+
252
+ finally:
253
+ client_sock.close()
254
+
255
+ # redirects
256
+ def redirect(self, url: str, status_code: int = 302):
257
+ return HTTPResponse(status_code, b"", headers={"Location": url})
258
+
259
+ # middleware management
260
+
261
+ def add_middleware(self, middleware: MiddlewareLike):
262
+ self.middlewares.append(middleware)
263
+
264
+ def run(self):
265
+ try:
266
+ asyncio.run(self._run())
267
+ except KeyboardInterrupt:
268
+ print("\nRilweb: server stopped.")
269
+
270
+ def sign_cookie(self, value: str) -> str:
271
+ if self.secret_key is None:
272
+ raise RuntimeError("Secret key is not set")
273
+
274
+ return _sign_cookie(value, self.secret_key)
275
+
276
+ def verify_cookie(self, request: HTTPRequest, name: str):
277
+ if self.secret_key is None:
278
+ raise RuntimeError("Secret key is not set")
279
+
280
+ signed_value = request.cookies.get(name)
281
+ if not signed_value:
282
+ return None
283
+
284
+ return _unsign_cookie(signed_value, self.secret_key)
rilweb/templating.py ADDED
@@ -0,0 +1,34 @@
1
+ try:
2
+ import jinja2
3
+ except ImportError as e:
4
+ raise RuntimeError(
5
+ "Jinja2 is not installed. "
6
+ "Please install it with `pip install jinja2` "
7
+ "to use templating features."
8
+ ) from e
9
+
10
+ from .response import HTTPResponse
11
+ from typing import Optional, Dict
12
+
13
+
14
+ class TemplateResponse(HTTPResponse):
15
+ def __init__(
16
+ self,
17
+ fp: str,
18
+ status_code: int = 200,
19
+ headers: Optional[Dict[str, str]] = None,
20
+ **context
21
+ ):
22
+ env = jinja2.Environment(
23
+ loader=jinja2.FileSystemLoader("."),
24
+ autoescape=True
25
+ )
26
+
27
+ template = env.get_template(fp)
28
+ rendered = template.render(**context)
29
+
30
+ super().__init__(
31
+ status_code,
32
+ rendered.encode("utf-8"),
33
+ headers=headers
34
+ )
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: rilweb
3
+ Version: 0.1.0
4
+ Summary: A simple http framework built in python using `socket` and `asyncio`.
5
+ Author-email: Arlo Talkington <mongpod67@gmail.com>
6
+ License-Expression: MIT
7
+ Keywords: example,package,template
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Provides-Extra: templating
13
+ Requires-Dist: jinja2>=3.1.2; extra == "templating"
@@ -0,0 +1,11 @@
1
+ rilweb/__init__.py,sha256=KCnbpSn2v8Me506_2tGT7CRMZPabQAlCQtR66br4DtY,110
2
+ rilweb/cookies.py,sha256=lIXuAap78JAVm_3SCQYBP_clSCjyW7jSjJ4C6hhjiek,690
3
+ rilweb/proto.py,sha256=s0nMC-EdzH6poF-r60Nx45oGZd7t67-Rsl2tw4NkOD8,685
4
+ rilweb/request.py,sha256=YXsExbtJJxWSMyo2P1OpB_Okef9Z6jAGjoMK1SiFsu0,1773
5
+ rilweb/response.py,sha256=gvSGiYIt03wokb3uNHBKFg61t8p0ugWNtUj0Dv_BXw4,2741
6
+ rilweb/rilweb.py,sha256=kpk54U2K_l1fxKIJkK9xrWYQVyhTjMl6eOBulxrZNOQ,9239
7
+ rilweb/templating.py,sha256=fiYvxkYJXXuxUSwj8lzRaJjRmi8WDR1MUaKfsAY4-L4,848
8
+ rilweb-0.1.0.dist-info/METADATA,sha256=SiBrUIOGjp_C_df5jSIi6zteui4_Lev-lhgCa12Ruac,494
9
+ rilweb-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ rilweb-0.1.0.dist-info/top_level.txt,sha256=H4yJFVQTb11llvNoivbTV7ogOF_p4jsRAKYiMrqdYvY,7
11
+ rilweb-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ rilweb