bwsgi 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.
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: bwsgi
3
+ Version: 0.1.0
4
+ Summary: Ultra-lightweight, zero-dependency Python WSGI framework
5
+ Author-email: Baraa Ahmed <baraa.runtime@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/BaraaByte/bwsgi
8
+ Project-URL: Repository, https://github.com/BaraaByte/bwsgi
9
+ Project-URL: Issues, https://github.com/BaraaByte/bwsgi/issues
10
+ Keywords: wsgi,framework,zero-dependency,serv00,freebsd,flask-like
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX :: BSD :: FreeBSD
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Dynamic: license-file
25
+
26
+ # Vinod
27
+
28
+ Ultra-lightweight, **zero-dependency** Python WSGI framework.
29
+ Flask-style routing. Django-style auth. Built on the Python Standard Library.
30
+
31
+ ## Why Vinod?
32
+
33
+ - **Zero dependencies** — pure Python stdlib
34
+ - **~5-8 MB idle RAM** — runs happily on 512MB hosts like Serv00
35
+ - **Flask-style routing** — decorator-based, familiar syntax
36
+ - **Secure auth built in** — PBKDF2 password hashing + DB-backed sessions
37
+ - **WSGI native** — works with Passenger, Gunicorn, uWSGI
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install vinod
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ from vinod import Vinod, Response, login_required
49
+
50
+ app = Vinod(db_path="app.db")
51
+
52
+ @app.route("/")
53
+ def home(req):
54
+ return Response.html("<h1>Hello from Vinod</h1>")
55
+
56
+ @app.route("/dashboard")
57
+ @login_required
58
+ def dashboard(req):
59
+ return Response.html(f"<h1>Hi {req.user['username']}</h1>")
60
+
61
+ if __name__ == "__main__":
62
+ app.run()
63
+ ```
64
+
65
+ ## Routing and responses
66
+
67
+ Routes accept named path parameters and one or more HTTP methods:
68
+
69
+ ```python
70
+ @app.route("/users/<username>", methods=("GET",))
71
+ def user(req, username):
72
+ return Response.json({"username": username})
73
+
74
+ profile_url = app.url_for("user", username="Jane Doe")
75
+ ```
76
+
77
+ Path values generated by `url_for` are URL-encoded. Requests to a known path
78
+ with an unsupported method receive `405 Method Not Allowed` and an `Allow`
79
+ header. Static files are served from `static/` at `/static/`; files are
80
+ streamed in chunks and paths are checked against the configured static root.
81
+
82
+ `Request` provides `query`, `headers`, `cookies`, `body`, `form()`, and `json()`.
83
+ Malformed JSON request bodies receive `400 Bad Request`.
84
+ `Response` provides `text()`, `html()`, `json()`, `redirect()`, and `file()`.
85
+
86
+ ## JSON request validation
87
+
88
+ Use `Request.validate_json()` to require a JSON object with specific required
89
+ fields and Python types. Malformed JSON and invalid values return a JSON
90
+ `400 Bad Request` response:
91
+
92
+ ```python
93
+ @app.route("/api/items", methods=("POST",))
94
+ def create_item(req):
95
+ item = req.validate_json({"name": str, "quantity": int})
96
+ return Response.json(item, "201 Created")
97
+ ```
98
+
99
+ ## Sessions and CSRF
100
+
101
+ `req.session` is a server-side dictionary persisted in SQLite and identified by
102
+ a signed, HTTP-only cookie. Set a stable secret key when creating the app so
103
+ session cookies survive process restarts:
104
+
105
+ ```python
106
+ app = Vinod(db_path="app.db", secret_key="load-this-from-your-environment")
107
+ ```
108
+
109
+ Protect state-changing routes with `@csrf_protect`. Render `req.csrf_token()`
110
+ into a hidden form field named `_csrf_token`, or send it in the
111
+ `X-Csrf-Token` header for JSON requests:
112
+
113
+ ```python
114
+ @app.route("/submit", methods=("POST",))
115
+ @csrf_protect
116
+ def submit(req):
117
+ req.session["submitted"] = True
118
+ return Response.json({"ok": True})
119
+ ```
120
+
121
+ Safe methods (`GET`, `HEAD`, `OPTIONS`, and `TRACE`) do not require a token.
122
+ Keep state-changing actions on non-safe methods and use CSRF protection for
123
+ browser-session routes.
124
+
125
+ ## Templates, middleware, and errors
126
+
127
+ Templates are UTF-8 files under `templates/` by default. `{{ name }}` values
128
+ are HTML-escaped automatically; this intentionally supports variable
129
+ substitution only, not template logic:
130
+
131
+ ```html
132
+ <h1>Hello, {{ username }}</h1>
133
+ ```
134
+
135
+ ```python
136
+ return app.render_template("hello.html", username="Ada")
137
+ ```
138
+
139
+ Register standard WSGI middleware with a factory that accepts and returns a
140
+ WSGI callable:
141
+
142
+ ```python
143
+ def request_id_middleware(next_app):
144
+ def middleware(environ, start_response):
145
+ environ["HTTP_X_REQUEST_ID"] = "example"
146
+ return next_app(environ, start_response)
147
+ return middleware
148
+
149
+ app.add_middleware(request_id_middleware)
150
+ ```
151
+
152
+ Error handlers may be registered for HTTP status codes or exception classes:
153
+
154
+ ```python
155
+ @app.errorhandler(ValueError)
156
+ def invalid_value(req):
157
+ return Response.json({"error": "invalid value"}, "400 Bad Request")
158
+ ```
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,11 @@
1
+ bwsgi-0.1.0.dist-info/licenses/LICENSE,sha256=S_fbX1OLfL7GYfxafKhn4E6-9cDMpbxy3-OgQisZr5A,1087
2
+ vinod/__init__.py,sha256=I_yncKbAAbA8w7n53HxzSXu6TF73XVakf2ELMtlQJrM,554
3
+ vinod/auth.py,sha256=AgVAgUJNul9BpO-lHkQ6uvykIxv8ZkUpvtP9Jn-4LpE,1019
4
+ vinod/core.py,sha256=ON_6DcyRkisbyX5wwgga0N8yLTa0cVhP6V8xJglRyHY,16333
5
+ vinod/db.py,sha256=ajV8qkLulei4f1wku4Z78M3Rzn564WT6cdS46pd3rnw,2433
6
+ vinod/decorators.py,sha256=MGCEn-uDFmH4voVNCL2ImGb1KGZV9aKp-Om11YoJq-U,1114
7
+ vinod/py.typed,sha256=a99mtb8qROZYvqLuhmlasVCgbmAL9nzVzOJFrVSWLGE,3
8
+ bwsgi-0.1.0.dist-info/METADATA,sha256=U4m56Wws51eYznUiqUJCeH7tiQwEEfJ4VUllH1Anpow,5071
9
+ bwsgi-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ bwsgi-0.1.0.dist-info/top_level.txt,sha256=IYWQTN9LiU9DRWo5VTyR9tyMaKTrx85SZv3f6WdDb7A,6
11
+ bwsgi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ vinod
vinod/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Vinod - ultra-lightweight zero-dependency WSGI framework."""
2
+ from .core import Request, RequestValidationError, Response, Vinod
3
+ from .decorators import csrf_protect, login_required
4
+ from .auth import (
5
+ hash_password, verify_password,
6
+ new_session_token, session_expiry,
7
+ )
8
+ from .db import Database
9
+
10
+ __version__ = "0.1.0"
11
+ __all__ = [
12
+ "Vinod", "Request", "Response", "Database",
13
+ "login_required", "csrf_protect", "RequestValidationError",
14
+ "hash_password", "verify_password",
15
+ "new_session_token", "session_expiry",
16
+ ]
vinod/auth.py ADDED
@@ -0,0 +1,37 @@
1
+ """Password hashing and session token management. Stdlib only."""
2
+ import hashlib
3
+ import hmac
4
+ import secrets
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+
8
+ PBKDF2_ITERATIONS = 260_000
9
+ SESSION_DAYS = 7
10
+
11
+
12
+ def hash_password(password: str, salt=None):
13
+ """Return (hash_hex, salt_hex). PBKDF2-HMAC-SHA256."""
14
+ if salt is None:
15
+ salt = secrets.token_hex(16)
16
+ dk = hashlib.pbkdf2_hmac(
17
+ "sha256",
18
+ password.encode("utf-8"),
19
+ bytes.fromhex(salt),
20
+ PBKDF2_ITERATIONS,
21
+ )
22
+ return dk.hex(), salt
23
+
24
+
25
+ def verify_password(password: str, stored_hash: str, salt: str) -> bool:
26
+ """Constant-time comparison."""
27
+ computed, _ = hash_password(password, salt)
28
+ return hmac.compare_digest(computed, stored_hash)
29
+
30
+
31
+ def new_session_token() -> str:
32
+ return secrets.token_urlsafe(32)
33
+
34
+
35
+ def session_expiry(days: int = SESSION_DAYS) -> str:
36
+ dt = datetime.now(timezone.utc) + timedelta(days=days)
37
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
vinod/core.py ADDED
@@ -0,0 +1,440 @@
1
+ """Vinod core - WSGI application, router, request, response."""
2
+ import re
3
+ import json
4
+ import traceback
5
+ import mimetypes
6
+ import secrets
7
+ import copy
8
+ import hmac
9
+ import hashlib
10
+ import html
11
+ from pathlib import Path
12
+ from http.cookies import SimpleCookie
13
+ from typing import Any
14
+ from urllib.parse import parse_qs, quote, urlparse
15
+
16
+ from .auth import session_expiry
17
+ from .db import Database
18
+
19
+
20
+ class RequestValidationError(ValueError):
21
+ """Raised when parsed request data does not satisfy a declared shape."""
22
+
23
+ def __init__(self, errors):
24
+ self.errors = errors
25
+ super().__init__("Request validation failed")
26
+
27
+
28
+ class Request:
29
+ """Lightweight request context passed to every view."""
30
+
31
+ def __init__(self, environ, db, app):
32
+ self.environ = environ
33
+ self.db = db
34
+ self.app = app
35
+ self.method = environ["REQUEST_METHOD"].upper()
36
+ parsed = urlparse(environ.get("PATH_INFO", "/"))
37
+ self.path = parsed.path
38
+ self.query = {k: v[0] for k, v in
39
+ parse_qs(environ.get("QUERY_STRING", "")).items()}
40
+ self.headers = {
41
+ k[5:].replace("_", "-").title(): v
42
+ for k, v in environ.items() if k.startswith("HTTP_")
43
+ }
44
+ self.cookies = SimpleCookie()
45
+ if "HTTP_COOKIE" in environ:
46
+ self.cookies.load(environ["HTTP_COOKIE"])
47
+ self.user = None
48
+ self._body = None
49
+ self.session = {}
50
+ self._session_id = None
51
+ self._session_snapshot = {}
52
+
53
+ def load_session(self):
54
+ self.session, self._session_id = self.app._load_session(self)
55
+ self._session_snapshot = copy.deepcopy(self.session)
56
+
57
+ @property
58
+ def body(self) -> bytes:
59
+ if self._body is None:
60
+ try:
61
+ length = int(self.environ.get("CONTENT_LENGTH") or 0)
62
+ except ValueError:
63
+ length = 0
64
+ self._body = (self.environ["wsgi.input"].read(length)
65
+ if length else b"")
66
+ return self._body
67
+
68
+ def json(self) -> Any:
69
+ if not self.body:
70
+ return {}
71
+ return json.loads(self.body.decode("utf-8"))
72
+
73
+ def validate_json(self, schema: dict[str, type]) -> dict:
74
+ """Parse an object and require each key to have the declared type."""
75
+ data = self.json()
76
+ if not isinstance(data, dict):
77
+ raise RequestValidationError({"$": "Expected a JSON object"})
78
+
79
+ errors = {}
80
+ for key, expected_type in schema.items():
81
+ if key not in data:
82
+ errors[key] = "Required field is missing"
83
+ elif (not isinstance(data[key], expected_type)
84
+ or (expected_type is int and isinstance(data[key], bool))):
85
+ errors[key] = f"Expected {expected_type.__name__}"
86
+ if errors:
87
+ raise RequestValidationError(errors)
88
+ return data
89
+
90
+ def csrf_token(self) -> str:
91
+ token = self.session.get("_csrf_token")
92
+ if token is None:
93
+ token = secrets.token_urlsafe(32)
94
+ self.session["_csrf_token"] = token
95
+ return token
96
+
97
+ def form(self) -> dict:
98
+ return {k: v[0] for k, v in
99
+ parse_qs(self.body.decode("utf-8")).items()}
100
+
101
+ def url_for(self, name: str, **params) -> str:
102
+ return self.app.url_for(name, **params)
103
+
104
+ @property
105
+ def response(self):
106
+ return Response
107
+
108
+
109
+ class Response:
110
+ """Convenience response builders."""
111
+
112
+ @staticmethod
113
+ def text(body, status="200 OK", headers=None):
114
+ h = [("Content-Type", "text/plain; charset=utf-8")]
115
+ if headers:
116
+ h.extend(headers)
117
+ return status, h, [body.encode("utf-8")]
118
+
119
+ @staticmethod
120
+ def html(body, status="200 OK", headers=None):
121
+ h = [("Content-Type", "text/html; charset=utf-8")]
122
+ if headers:
123
+ h.extend(headers)
124
+ return status, h, [body.encode("utf-8")]
125
+
126
+ @staticmethod
127
+ def json(data, status="200 OK", headers=None):
128
+ h = [("Content-Type", "application/json")]
129
+ if headers:
130
+ h.extend(headers)
131
+ return status, h, [json.dumps(data).encode("utf-8")]
132
+
133
+ @staticmethod
134
+ def redirect(location, status="302 Found", headers=None):
135
+ h = [("Location", location)]
136
+ if headers:
137
+ h.extend(headers)
138
+ return status, h, [b""]
139
+
140
+ @staticmethod
141
+ def file(path, status="200 OK", headers=None):
142
+ mime, _ = mimetypes.guess_type(path)
143
+ h = [("Content-Type", mime or "application/octet-stream")]
144
+ if headers:
145
+ h.extend(headers)
146
+
147
+ def chunks():
148
+ with open(path, "rb") as f:
149
+ while True:
150
+ chunk = f.read(64 * 1024)
151
+ if not chunk:
152
+ break
153
+ yield chunk
154
+
155
+ return status, h, chunks()
156
+
157
+
158
+ class Vinod:
159
+ """The main WSGI application."""
160
+
161
+ def __init__(self, db_path="app.db", static_dir="static",
162
+ static_url="/static", debug=False, secret_key=None,
163
+ template_dir="templates"):
164
+ self.db = Database(db_path)
165
+ self.routes = []
166
+ self.before_hooks = []
167
+ self.after_hooks = []
168
+ self.error_handlers = {}
169
+ self.extensions = {}
170
+ self._wsgi_app = self._dispatch
171
+ self.config = {
172
+ "DEBUG": debug,
173
+ "SECRET_KEY": secret_key or secrets.token_hex(32),
174
+ "SESSION_DAYS": 7,
175
+ "STATIC_DIR": static_dir,
176
+ "STATIC_URL": static_url,
177
+ "TEMPLATE_DIR": template_dir,
178
+ "SESSION_COOKIE_NAME": "vinod_session",
179
+ "DB_PATH": db_path,
180
+ }
181
+
182
+ def route(self, path, methods=("GET",)):
183
+ def decorator(view):
184
+ regex = self._compile_path(path)
185
+ for m in methods:
186
+ self.routes.append(
187
+ (m.upper(), regex, view, path, view.__name__))
188
+ return view
189
+ return decorator
190
+
191
+ @staticmethod
192
+ def _compile_path(path):
193
+ parts = re.split(r"(<[A-Za-z_]\w*>)", path)
194
+ pattern = "".join(
195
+ f"(?P<{part[1:-1]}>[^/]+)" if part.startswith("<") else re.escape(part)
196
+ for part in parts
197
+ )
198
+ return re.compile(f"^{pattern}$")
199
+
200
+ def _match(self, method, path):
201
+ for m, regex, view, _, _ in self.routes:
202
+ if m != method:
203
+ continue
204
+ match = regex.match(path)
205
+ if match:
206
+ return view, match.groupdict()
207
+ return None, None
208
+
209
+ def url_for(self, name, **params):
210
+ for _, _, _, path, route_name in self.routes:
211
+ if route_name == name:
212
+ return re.sub(
213
+ r"<([A-Za-z_]\w*)>",
214
+ lambda match: quote(str(params[match.group(1)]), safe=""),
215
+ path,
216
+ )
217
+ raise KeyError(f"No route named {name!r}")
218
+
219
+ def before_request(self, f):
220
+ self.before_hooks.append(f)
221
+ return f
222
+
223
+ def after_request(self, f):
224
+ self.after_hooks.append(f)
225
+ return f
226
+
227
+ def add_middleware(self, factory):
228
+ """Wrap the WSGI application with a middleware factory."""
229
+ wrapped = factory(self._wsgi_app)
230
+ if not callable(wrapped):
231
+ raise TypeError("Middleware factory must return a WSGI callable")
232
+ self._wsgi_app = wrapped
233
+ return factory
234
+
235
+ def errorhandler(self, code):
236
+ def decorator(f):
237
+ self.error_handlers[code] = f
238
+ return f
239
+ return decorator
240
+
241
+ def render_template(self, template_name: str, **context) -> tuple:
242
+ """Render a UTF-8 HTML template with HTML-escaped {{ name }} values."""
243
+ root = Path(self.config["TEMPLATE_DIR"]).resolve()
244
+ path = (root / template_name).resolve()
245
+ try:
246
+ path.relative_to(root)
247
+ except ValueError as exc:
248
+ raise ValueError("Template path must stay inside TEMPLATE_DIR") from exc
249
+ source = path.read_text(encoding="utf-8")
250
+
251
+ def replace(match):
252
+ key = match.group(1)
253
+ if key not in context:
254
+ raise KeyError(f"Missing template value {key!r}")
255
+ return html.escape(str(context[key]), quote=True)
256
+
257
+ body = re.sub(r"{{\s*([A-Za-z_]\w*)\s*}}", replace, source)
258
+ return Response.html(body)
259
+
260
+ def register(self, extension):
261
+ """Register a Vinod extension. Extension must expose init_app(app)."""
262
+ name = extension.__class__.__name__.lower()
263
+ if hasattr(extension, "init_app"):
264
+ extension.init_app(self)
265
+ self.extensions[name] = extension
266
+ return extension
267
+
268
+ def _sign_session_id(self, session_id):
269
+ return hmac.new(
270
+ self.config["SECRET_KEY"].encode("utf-8"),
271
+ session_id.encode("ascii"),
272
+ hashlib.sha256,
273
+ ).hexdigest()
274
+
275
+ def _load_session(self, req):
276
+ cookie = req.cookies.get(self.config.get("SESSION_COOKIE_NAME", "vinod_session"))
277
+ if not cookie:
278
+ return {}, None
279
+ try:
280
+ session_id, signature = cookie.value.split(".", 1)
281
+ except ValueError:
282
+ return {}, None
283
+ if (re.fullmatch(r"[A-Za-z0-9_-]{43}", session_id) is None
284
+ or re.fullmatch(r"[a-f0-9]{64}", signature) is None):
285
+ return {}, None
286
+ if not hmac.compare_digest(signature, self._sign_session_id(session_id)):
287
+ return {}, None
288
+ row = self.db.fetchone(
289
+ "SELECT data FROM web_sessions "
290
+ "WHERE id = ? AND expires_at > datetime('now')",
291
+ (session_id,),
292
+ )
293
+ if row is None:
294
+ return {}, None
295
+ data = json.loads(row["data"])
296
+ if not isinstance(data, dict):
297
+ raise ValueError("Stored session data must be a JSON object")
298
+ return data, session_id
299
+
300
+ def _save_session(self, req, result):
301
+ if req.session == req._session_snapshot:
302
+ return result
303
+
304
+ session_id = req._session_id or secrets.token_urlsafe(32)
305
+ expires_at = session_expiry(self.config["SESSION_DAYS"])
306
+ self.db.execute(
307
+ "DELETE FROM web_sessions WHERE expires_at <= datetime('now')"
308
+ )
309
+ self.db.execute(
310
+ "INSERT INTO web_sessions (id, data, expires_at) VALUES (?, ?, ?) "
311
+ "ON CONFLICT(id) DO UPDATE SET data = excluded.data, "
312
+ "expires_at = excluded.expires_at",
313
+ (session_id, json.dumps(req.session), expires_at),
314
+ )
315
+ signature = self._sign_session_id(session_id)
316
+ secure = "; Secure" if req.environ.get("wsgi.url_scheme") == "https" else ""
317
+ cookie = (
318
+ f"{self.config['SESSION_COOKIE_NAME']}={session_id}.{signature}; "
319
+ f"Path=/; HttpOnly; SameSite=Lax; "
320
+ f"Max-Age={self.config['SESSION_DAYS'] * 86400}{secure}"
321
+ )
322
+ status, headers, body = result
323
+ return status, headers + [("Set-Cookie", cookie)], body
324
+
325
+ def _load_user(self, req):
326
+ cookie = req.cookies.get("sessionid")
327
+ if not cookie:
328
+ return
329
+ row = self.db.fetchone(
330
+ "SELECT u.id, u.username FROM sessions s "
331
+ "JOIN users u ON u.id = s.user_id "
332
+ "WHERE s.token = ? AND s.expires_at > datetime('now')",
333
+ (cookie.value,),
334
+ )
335
+ if row:
336
+ req.user = {"id": row["id"], "username": row["username"]}
337
+
338
+ def _serve_static(self, req, start_response):
339
+ static_url = self.config["STATIC_URL"].rstrip("/") or "/"
340
+ rel = (req.path.lstrip("/") if static_url == "/"
341
+ else req.path[len(static_url):].lstrip("/"))
342
+ root = Path(self.config["STATIC_DIR"]).resolve()
343
+ full = (root / rel).resolve()
344
+ try:
345
+ full.relative_to(root)
346
+ except ValueError:
347
+ return self._emit(Response.text("403 Forbidden", "403 Forbidden"),
348
+ start_response)
349
+ if not full.is_file():
350
+ return self._emit(Response.text("404 Not Found", "404 Not Found"),
351
+ start_response)
352
+ return self._emit(Response.file(full), start_response)
353
+
354
+ def __call__(self, environ, start_response):
355
+ return self._wsgi_app(environ, start_response)
356
+
357
+ def _dispatch(self, environ, start_response):
358
+ req = None
359
+ try:
360
+ req = Request(environ, self.db, self)
361
+
362
+ static_url = self.config["STATIC_URL"].rstrip("/") or "/"
363
+ if (static_url == "/" or req.path == static_url
364
+ or req.path.startswith(static_url + "/")):
365
+ return self._serve_static(req, start_response)
366
+
367
+ req.load_session()
368
+ self._load_user(req)
369
+
370
+ for hook in self.before_hooks:
371
+ result = hook(req)
372
+ if result is not None:
373
+ return self._emit_result(result, req, start_response)
374
+
375
+ view, kwargs = self._match(req.method, req.path)
376
+ if view is None:
377
+ allowed = sorted({
378
+ route_method for route_method, regex, _, _, _ in self.routes
379
+ if regex.match(req.path)
380
+ })
381
+ if allowed:
382
+ result = Response.text(
383
+ "405 Method Not Allowed",
384
+ "405 Method Not Allowed",
385
+ headers=[("Allow", ", ".join(allowed))],
386
+ )
387
+ return self._emit_result(result, req, start_response)
388
+ handler = self.error_handlers.get(404)
389
+ result = (handler(req) if handler
390
+ else Response.text("404 Not Found", "404 Not Found"))
391
+ return self._emit_result(result, req, start_response)
392
+
393
+ result = view(req, **kwargs) if kwargs else view(req)
394
+ return self._emit_result(result, req, start_response)
395
+
396
+ except Exception as exc:
397
+ handler = next((
398
+ callback for key, callback in self.error_handlers.items()
399
+ if isinstance(key, type) and isinstance(exc, key)
400
+ ), None)
401
+ if handler and req is not None:
402
+ return self._emit(handler(req), start_response)
403
+ if isinstance(exc, RequestValidationError):
404
+ return self._emit(Response.json(
405
+ {"error": "Invalid request", "details": exc.errors},
406
+ "400 Bad Request",
407
+ ), start_response)
408
+ if isinstance(exc, (json.JSONDecodeError, UnicodeDecodeError)):
409
+ return self._emit(Response.json(
410
+ {"error": "Invalid request body"},
411
+ "400 Bad Request",
412
+ ), start_response)
413
+ tb = traceback.format_exc() if self.config["DEBUG"] else ""
414
+ handler = self.error_handlers.get(500)
415
+ if handler and req is not None:
416
+ result = handler(req)
417
+ else:
418
+ body = "<h1>500 Internal Server Error</h1>"
419
+ if tb:
420
+ body += f"<pre>{html.escape(tb)}</pre>"
421
+ result = Response.html(body, "500 Internal Server Error")
422
+ return self._emit(result, start_response)
423
+
424
+ def _emit_result(self, result, req, start_response):
425
+ for hook in self.after_hooks:
426
+ result = hook(req, result) or result
427
+ result = self._save_session(req, result)
428
+ return self._emit(result, start_response)
429
+
430
+ @staticmethod
431
+ def _emit(result, start_response):
432
+ status, headers, body = result
433
+ start_response(status, headers)
434
+ return body
435
+
436
+ def run(self, host="127.0.0.1", port=8000):
437
+ from wsgiref.simple_server import make_server
438
+ print(f"Vinod running on http://{host}:{port}")
439
+ with make_server(host, port, self) as httpd:
440
+ httpd.serve_forever()
vinod/db.py ADDED
@@ -0,0 +1,67 @@
1
+ """Thread-safe SQLite connection manager. One connection per thread."""
2
+ import sqlite3
3
+ import threading
4
+ from pathlib import Path
5
+
6
+
7
+ class Database:
8
+ def __init__(self, db_path: str = "app.db"):
9
+ self._memory_uri = (
10
+ f"file:vinod-{id(self)}?mode=memory&cache=shared"
11
+ if db_path == ":memory:"
12
+ else None
13
+ )
14
+ self.db_path = self._memory_uri or str(Path(db_path).resolve())
15
+ self._local = threading.local()
16
+ self._init_schema()
17
+
18
+ @property
19
+ def conn(self) -> sqlite3.Connection:
20
+ if not hasattr(self._local, "conn"):
21
+ self._local.conn = sqlite3.connect(
22
+ self.db_path,
23
+ timeout=10.0,
24
+ uri=self._memory_uri is not None,
25
+ )
26
+ self._local.conn.row_factory = sqlite3.Row
27
+ self._local.conn.execute("PRAGMA journal_mode=WAL;")
28
+ self._local.conn.execute("PRAGMA foreign_keys=ON;")
29
+ return self._local.conn
30
+
31
+ def execute(self, sql, params=()):
32
+ cur = self.conn.execute(sql, params)
33
+ self.conn.commit()
34
+ return cur
35
+
36
+ def fetchone(self, sql, params=()):
37
+ return self.conn.execute(sql, params).fetchone()
38
+
39
+ def fetchall(self, sql, params=()):
40
+ return self.conn.execute(sql, params).fetchall()
41
+
42
+ def _init_schema(self):
43
+ self.conn.executescript("""
44
+ CREATE TABLE IF NOT EXISTS users (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ username TEXT UNIQUE NOT NULL,
47
+ password_hash TEXT NOT NULL,
48
+ salt TEXT NOT NULL,
49
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
50
+ );
51
+
52
+ CREATE TABLE IF NOT EXISTS sessions (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ token TEXT UNIQUE NOT NULL,
55
+ user_id INTEGER NOT NULL,
56
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
57
+ expires_at TIMESTAMP NOT NULL,
58
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS web_sessions (
62
+ id TEXT PRIMARY KEY,
63
+ data TEXT NOT NULL,
64
+ expires_at TIMESTAMP NOT NULL
65
+ );
66
+ """)
67
+ self.conn.commit()
vinod/decorators.py ADDED
@@ -0,0 +1,34 @@
1
+ """Auth decorators."""
2
+ from functools import wraps
3
+ import hmac
4
+
5
+ from .core import Response
6
+
7
+
8
+ _SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
9
+
10
+
11
+ def login_required(view):
12
+ """Redirect unauthenticated users to /login."""
13
+ @wraps(view)
14
+ def wrapper(req, *args, **kwargs):
15
+ if req.user is None:
16
+ return Response.redirect("/login")
17
+ return view(req, *args, **kwargs)
18
+ return wrapper
19
+
20
+
21
+ def csrf_protect(view):
22
+ """Require a session CSRF token for requests that can change state."""
23
+ @wraps(view)
24
+ def wrapper(req, *args, **kwargs):
25
+ if req.method not in _SAFE_METHODS:
26
+ supplied = req.headers.get("X-Csrf-Token")
27
+ if supplied is None:
28
+ supplied = req.form().get("_csrf_token")
29
+ expected = req.session.get("_csrf_token")
30
+ if (not supplied or not expected
31
+ or not hmac.compare_digest(supplied, expected)):
32
+ return Response.text("CSRF token missing or invalid", "403 Forbidden")
33
+ return view(req, *args, **kwargs)
34
+ return wrapper
vinod/py.typed ADDED
@@ -0,0 +1 @@
1
+