pyrith 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.
- pyrith/__init__.py +18 -0
- pyrith/app.py +165 -0
- pyrith/auth/__init__.py +5 -0
- pyrith/auth/decorators.py +46 -0
- pyrith/auth/session.py +124 -0
- pyrith/cli/__init__.py +4 -0
- pyrith/cli/commands.py +95 -0
- pyrith/forms/__init__.py +4 -0
- pyrith/forms/form.py +195 -0
- pyrith/middleware.py +119 -0
- pyrith/orm/__init__.py +12 -0
- pyrith/orm/fields.py +104 -0
- pyrith/orm/model.py +164 -0
- pyrith/orm/query.py +101 -0
- pyrith/request.py +60 -0
- pyrith/response.py +101 -0
- pyrith/router.py +103 -0
- pyrith/style/__init__.py +12 -0
- pyrith/style/css.py +95 -0
- pyrith/style/stylesheet.py +24 -0
- pyrith/style/tailwind.py +180 -0
- pyrith/style/units.py +34 -0
- pyrith/template.py +218 -0
- pyrith/ui/__init__.py +23 -0
- pyrith/ui/component.py +64 -0
- pyrith/ui/html.py +110 -0
- pyrith/ui/page.py +69 -0
- pyrith/websocket.py +124 -0
- pyrith-0.1.0.dist-info/METADATA +706 -0
- pyrith-0.1.0.dist-info/RECORD +34 -0
- pyrith-0.1.0.dist-info/WHEEL +5 -0
- pyrith-0.1.0.dist-info/entry_points.txt +2 -0
- pyrith-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyrith-0.1.0.dist-info/top_level.txt +1 -0
pyrith/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Pyrith — Pure-Python full-stack web framework."""
|
|
2
|
+
|
|
3
|
+
from .app import Pyrith
|
|
4
|
+
from .request import Request
|
|
5
|
+
from .response import Response, HTMLResponse, JSONResponse, RedirectResponse, StreamResponse
|
|
6
|
+
from .router import Router, RouteGroup
|
|
7
|
+
from . import ui
|
|
8
|
+
from . import style
|
|
9
|
+
from . import orm
|
|
10
|
+
from . import auth
|
|
11
|
+
from . import forms
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Pyrith", "Request", "Response", "HTMLResponse", "JSONResponse",
|
|
16
|
+
"RedirectResponse", "StreamResponse", "Router", "RouteGroup",
|
|
17
|
+
"ui", "style", "orm", "auth", "forms",
|
|
18
|
+
]
|
pyrith/app.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Pyrith application — HTTP server, routing, middleware pipeline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import http.server
|
|
4
|
+
import urllib.parse
|
|
5
|
+
import traceback
|
|
6
|
+
from typing import Callable, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from .request import Request
|
|
9
|
+
from .response import Response, HTMLResponse, JSONResponse
|
|
10
|
+
from .router import Router, RouteGroup
|
|
11
|
+
from .middleware import Middleware
|
|
12
|
+
from .websocket import WebSocketHandler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Pyrith:
|
|
16
|
+
"""The core Pyrith application."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, name: Optional[str] = None) -> None:
|
|
19
|
+
self.name = name or "pyrith"
|
|
20
|
+
self._router = Router()
|
|
21
|
+
self._middlewares: List[Middleware] = []
|
|
22
|
+
self._error_handlers: Dict[int, Callable] = {}
|
|
23
|
+
self._ws_routes: Dict[str, Callable] = {}
|
|
24
|
+
|
|
25
|
+
# ── Routing ──────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def route(self, path: str, methods: Optional[List[str]] = None):
|
|
28
|
+
return self._router.route(path, methods or ["GET"])
|
|
29
|
+
|
|
30
|
+
def get(self, path: str):
|
|
31
|
+
return self._router.get(path)
|
|
32
|
+
|
|
33
|
+
def post(self, path: str):
|
|
34
|
+
return self._router.post(path)
|
|
35
|
+
|
|
36
|
+
def put(self, path: str):
|
|
37
|
+
return self._router.put(path)
|
|
38
|
+
|
|
39
|
+
def delete(self, path: str):
|
|
40
|
+
return self._router.delete(path)
|
|
41
|
+
|
|
42
|
+
def patch(self, path: str):
|
|
43
|
+
return self._router.patch(path)
|
|
44
|
+
|
|
45
|
+
def websocket(self, path: str):
|
|
46
|
+
def decorator(fn: Callable) -> Callable:
|
|
47
|
+
self._ws_routes[path] = fn
|
|
48
|
+
return fn
|
|
49
|
+
return decorator
|
|
50
|
+
|
|
51
|
+
def group(self, prefix: str) -> RouteGroup:
|
|
52
|
+
return RouteGroup(prefix, self)
|
|
53
|
+
|
|
54
|
+
# ── Middleware ───────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def use(self, middleware: Middleware) -> None:
|
|
57
|
+
self._middlewares.append(middleware)
|
|
58
|
+
|
|
59
|
+
# ── Error handling ───────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def error_handler(self, status_code: int):
|
|
62
|
+
def decorator(fn: Callable) -> Callable:
|
|
63
|
+
self._error_handlers[status_code] = fn
|
|
64
|
+
return fn
|
|
65
|
+
return decorator
|
|
66
|
+
|
|
67
|
+
# ── Request handling ─────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def handle(self, request: Request) -> Response:
|
|
70
|
+
# Run before_request middlewares
|
|
71
|
+
for mw in self._middlewares:
|
|
72
|
+
early = mw.before_request(request)
|
|
73
|
+
if early is not None:
|
|
74
|
+
return self._run_after(request, early)
|
|
75
|
+
|
|
76
|
+
# Resolve route
|
|
77
|
+
handler, params = self._router.resolve(request.path, request.method)
|
|
78
|
+
request.params = params
|
|
79
|
+
|
|
80
|
+
if handler is None:
|
|
81
|
+
response = self._make_error(404, request, "Not Found")
|
|
82
|
+
else:
|
|
83
|
+
try:
|
|
84
|
+
result = handler(request)
|
|
85
|
+
response = self._coerce(result)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
traceback.print_exc()
|
|
88
|
+
response = self._make_error(500, request, f"Internal Server Error: {exc}")
|
|
89
|
+
|
|
90
|
+
return self._run_after(request, response)
|
|
91
|
+
|
|
92
|
+
def _run_after(self, request: Request, response: Response) -> Response:
|
|
93
|
+
for mw in reversed(self._middlewares):
|
|
94
|
+
response = mw.after_request(request, response)
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
def _coerce(self, result) -> Response:
|
|
98
|
+
if isinstance(result, Response):
|
|
99
|
+
return result
|
|
100
|
+
if isinstance(result, dict) or isinstance(result, list):
|
|
101
|
+
return JSONResponse(result)
|
|
102
|
+
if isinstance(result, str):
|
|
103
|
+
if result.lstrip().startswith("<"):
|
|
104
|
+
return HTMLResponse(result)
|
|
105
|
+
return Response(result, content_type="text/plain; charset=utf-8")
|
|
106
|
+
return Response(str(result))
|
|
107
|
+
|
|
108
|
+
def _make_error(self, status: int, request: Request, message: str) -> Response:
|
|
109
|
+
if status in self._error_handlers:
|
|
110
|
+
try:
|
|
111
|
+
result = self._error_handlers[status](request)
|
|
112
|
+
return self._coerce(result)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return Response(message, status=status)
|
|
116
|
+
|
|
117
|
+
# ── Server ───────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
def run(self, host: str = "127.0.0.1", port: int = 8080, debug: bool = False) -> None:
|
|
120
|
+
app = self
|
|
121
|
+
|
|
122
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
123
|
+
def log_message(self, fmt, *args):
|
|
124
|
+
if debug:
|
|
125
|
+
super().log_message(fmt, *args)
|
|
126
|
+
|
|
127
|
+
def _handle(self):
|
|
128
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
129
|
+
path = parsed.path or "/"
|
|
130
|
+
query_string = parsed.query or ""
|
|
131
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
132
|
+
body = self.rfile.read(length) if length else b""
|
|
133
|
+
headers = dict(self.headers)
|
|
134
|
+
request = Request(
|
|
135
|
+
method=self.command,
|
|
136
|
+
path=path,
|
|
137
|
+
headers=headers,
|
|
138
|
+
body=body,
|
|
139
|
+
query_string=query_string,
|
|
140
|
+
)
|
|
141
|
+
response = app.handle(request)
|
|
142
|
+
body_bytes = response.body_bytes()
|
|
143
|
+
self.send_response(response.status)
|
|
144
|
+
for k, v in response.all_headers().items():
|
|
145
|
+
self.send_header(k, v)
|
|
146
|
+
for cookie in response._cookies:
|
|
147
|
+
self.send_header("Set-Cookie", cookie)
|
|
148
|
+
self.end_headers()
|
|
149
|
+
self.wfile.write(body_bytes)
|
|
150
|
+
|
|
151
|
+
def do_GET(self): self._handle()
|
|
152
|
+
def do_POST(self): self._handle()
|
|
153
|
+
def do_PUT(self): self._handle()
|
|
154
|
+
def do_DELETE(self): self._handle()
|
|
155
|
+
def do_PATCH(self): self._handle()
|
|
156
|
+
def do_OPTIONS(self): self._handle()
|
|
157
|
+
def do_HEAD(self): self._handle()
|
|
158
|
+
|
|
159
|
+
server = http.server.ThreadingHTTPServer((host, port), _Handler)
|
|
160
|
+
print(f"[Pyrith] Running on http://{host}:{port} (debug={debug})")
|
|
161
|
+
try:
|
|
162
|
+
server.serve_forever()
|
|
163
|
+
except KeyboardInterrupt:
|
|
164
|
+
print("\n[Pyrith] Stopped.")
|
|
165
|
+
server.shutdown()
|
pyrith/auth/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Pyrith Auth — route guard decorators."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
from ..response import RedirectResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def login_required(redirect_to: str = "/login"):
|
|
8
|
+
"""
|
|
9
|
+
Redirect unauthenticated requests.
|
|
10
|
+
|
|
11
|
+
Expects ``request.session`` to be set (by SessionStore middleware)
|
|
12
|
+
and ``request.session.get("user_id")`` to be non-None when authenticated.
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
@app.get("/dashboard")
|
|
17
|
+
@login_required()
|
|
18
|
+
def dashboard(request):
|
|
19
|
+
...
|
|
20
|
+
"""
|
|
21
|
+
def decorator(fn: Callable) -> Callable:
|
|
22
|
+
def wrapper(request):
|
|
23
|
+
session = getattr(request, "session", None)
|
|
24
|
+
if session is None or session.get("user_id") is None:
|
|
25
|
+
return RedirectResponse(redirect_to)
|
|
26
|
+
return fn(request)
|
|
27
|
+
wrapper.__name__ = fn.__name__
|
|
28
|
+
return wrapper
|
|
29
|
+
return decorator
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def role_required(role: str, redirect_to: str = "/forbidden"):
|
|
33
|
+
"""
|
|
34
|
+
Guard a route to users with a specific role stored in the session.
|
|
35
|
+
|
|
36
|
+
Expects ``request.session.get("role")`` to equal *role*.
|
|
37
|
+
"""
|
|
38
|
+
def decorator(fn: Callable) -> Callable:
|
|
39
|
+
def wrapper(request):
|
|
40
|
+
session = getattr(request, "session", None)
|
|
41
|
+
if session is None or session.get("role") != role:
|
|
42
|
+
return RedirectResponse(redirect_to)
|
|
43
|
+
return fn(request)
|
|
44
|
+
wrapper.__name__ = fn.__name__
|
|
45
|
+
return wrapper
|
|
46
|
+
return decorator
|
pyrith/auth/session.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Pyrith Auth — session store (in-memory + cookie-based)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Session:
|
|
10
|
+
def __init__(self, session_id: str) -> None:
|
|
11
|
+
self.id = session_id
|
|
12
|
+
self._data: Dict[str, Any] = {}
|
|
13
|
+
self.created_at = time.time()
|
|
14
|
+
self.last_accessed = time.time()
|
|
15
|
+
|
|
16
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
17
|
+
return self._data.get(key, default)
|
|
18
|
+
|
|
19
|
+
def set(self, key: str, value: Any) -> None:
|
|
20
|
+
self._data[key] = value
|
|
21
|
+
self.last_accessed = time.time()
|
|
22
|
+
|
|
23
|
+
def delete(self, key: str) -> None:
|
|
24
|
+
self._data.pop(key, None)
|
|
25
|
+
|
|
26
|
+
def clear(self) -> None:
|
|
27
|
+
self._data.clear()
|
|
28
|
+
|
|
29
|
+
def __contains__(self, key: str) -> bool:
|
|
30
|
+
return key in self._data
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return f"<Session {self.id[:8]}...>"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionStore:
|
|
37
|
+
"""
|
|
38
|
+
Thread-safe in-memory session store with HMAC-signed cookie tokens.
|
|
39
|
+
|
|
40
|
+
Usage::
|
|
41
|
+
|
|
42
|
+
store = SessionStore(secret="change-me", max_age=3600)
|
|
43
|
+
app.use(store.middleware())
|
|
44
|
+
|
|
45
|
+
@app.get("/login")
|
|
46
|
+
def login(request):
|
|
47
|
+
session = store.get_or_create(request)
|
|
48
|
+
session.set("user_id", 42)
|
|
49
|
+
response = RedirectResponse("/dashboard")
|
|
50
|
+
store.save(session, response)
|
|
51
|
+
return response
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
COOKIE_NAME = "pyrith_session"
|
|
55
|
+
|
|
56
|
+
def __init__(self, secret: str, max_age: int = 86400) -> None:
|
|
57
|
+
self._secret = secret.encode()
|
|
58
|
+
self._max_age = max_age
|
|
59
|
+
self._sessions: Dict[str, Session] = {}
|
|
60
|
+
|
|
61
|
+
def _sign(self, session_id: str) -> str:
|
|
62
|
+
import hmac
|
|
63
|
+
sig = hmac.new(self._secret, session_id.encode(), hashlib.sha256).hexdigest()
|
|
64
|
+
return f"{session_id}.{sig}"
|
|
65
|
+
|
|
66
|
+
def _verify(self, token: str) -> Optional[str]:
|
|
67
|
+
import hmac
|
|
68
|
+
parts = token.rsplit(".", 1)
|
|
69
|
+
if len(parts) != 2:
|
|
70
|
+
return None
|
|
71
|
+
session_id, sig = parts
|
|
72
|
+
expected = hmac.new(self._secret, session_id.encode(), hashlib.sha256).hexdigest()
|
|
73
|
+
if hmac.compare_digest(sig, expected):
|
|
74
|
+
return session_id
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _new_id(self) -> str:
|
|
78
|
+
return hashlib.sha256(os.urandom(32)).hexdigest()
|
|
79
|
+
|
|
80
|
+
def get_or_create(self, request) -> Session:
|
|
81
|
+
cookies = request.cookies() if callable(request.cookies) else {}
|
|
82
|
+
token = cookies.get(self.COOKIE_NAME, "")
|
|
83
|
+
session_id = self._verify(token) if token else None
|
|
84
|
+
if session_id and session_id in self._sessions:
|
|
85
|
+
session = self._sessions[session_id]
|
|
86
|
+
if time.time() - session.last_accessed < self._max_age:
|
|
87
|
+
session.last_accessed = time.time()
|
|
88
|
+
return session
|
|
89
|
+
session_id = self._new_id()
|
|
90
|
+
session = Session(session_id)
|
|
91
|
+
self._sessions[session_id] = session
|
|
92
|
+
return session
|
|
93
|
+
|
|
94
|
+
def save(self, session: Session, response) -> None:
|
|
95
|
+
token = self._sign(session.id)
|
|
96
|
+
response.set_cookie(
|
|
97
|
+
self.COOKIE_NAME,
|
|
98
|
+
token,
|
|
99
|
+
max_age=self._max_age,
|
|
100
|
+
http_only=True,
|
|
101
|
+
same_site="Lax",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def destroy(self, session: Session, response) -> None:
|
|
105
|
+
self._sessions.pop(session.id, None)
|
|
106
|
+
response.delete_cookie(self.COOKIE_NAME)
|
|
107
|
+
|
|
108
|
+
def middleware(self):
|
|
109
|
+
"""Return a Pyrith Middleware that attaches a session to every request."""
|
|
110
|
+
from ..middleware import Middleware
|
|
111
|
+
|
|
112
|
+
store = self
|
|
113
|
+
|
|
114
|
+
class _SessionMiddleware(Middleware):
|
|
115
|
+
def before_request(self, request):
|
|
116
|
+
request.session = store.get_or_create(request)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def after_request(self, request, response):
|
|
120
|
+
if hasattr(request, "session"):
|
|
121
|
+
store.save(request.session, response)
|
|
122
|
+
return response
|
|
123
|
+
|
|
124
|
+
return _SessionMiddleware()
|
pyrith/cli/__init__.py
ADDED
pyrith/cli/commands.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Pyrith CLI commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
_HELP = """\
|
|
8
|
+
Pyrith — pure-Python full-stack web framework
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
pyrith new <project> Scaffold a new Pyrith project
|
|
12
|
+
pyrith run [file] Run an app file (default: app.py)
|
|
13
|
+
pyrith version Show Pyrith version
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
pyrith new myapp
|
|
17
|
+
pyrith run app.py
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_APP_TEMPLATE = '''\
|
|
21
|
+
from pyrith import Pyrith
|
|
22
|
+
from pyrith import ui, style
|
|
23
|
+
|
|
24
|
+
app = Pyrith(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.get("/")
|
|
28
|
+
def index(request):
|
|
29
|
+
page = ui.Page(title="Welcome to Pyrith")
|
|
30
|
+
page.head_add(f"<style>{style.TailwindSheet().add('flex','items-center','justify-center','min-h-100vh')}</style>")
|
|
31
|
+
page.body_add(
|
|
32
|
+
ui.div(
|
|
33
|
+
ui.h1("Hello from Pyrith", class_="text-4xl font-bold"),
|
|
34
|
+
ui.p("Edit app.py to get started.", class_="text-gray-500 mt-4"),
|
|
35
|
+
class_="flex flex-col items-center justify-center min-h-100vh",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
from pyrith.response import HTMLResponse
|
|
39
|
+
return HTMLResponse(page.to_html())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
app.run(debug=True)
|
|
44
|
+
'''
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _new_project(name: str) -> None:
|
|
48
|
+
if os.path.exists(name):
|
|
49
|
+
print(f"Error: '{name}' already exists.", file=sys.stderr)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
os.makedirs(name)
|
|
52
|
+
with open(os.path.join(name, "app.py"), "w") as f:
|
|
53
|
+
f.write(_APP_TEMPLATE)
|
|
54
|
+
with open(os.path.join(name, "requirements.txt"), "w") as f:
|
|
55
|
+
f.write("pyrith\n")
|
|
56
|
+
print(f"Created project '{name}'.")
|
|
57
|
+
print(f" cd {name}")
|
|
58
|
+
print(" python app.py")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_app(file: str) -> None:
|
|
62
|
+
if not os.path.exists(file):
|
|
63
|
+
print(f"Error: '{file}' not found.", file=sys.stderr)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
import importlib.util
|
|
66
|
+
spec = importlib.util.spec_from_file_location("__pyrith_app__", file)
|
|
67
|
+
mod = importlib.util.module_from_spec(spec)
|
|
68
|
+
spec.loader.exec_module(mod)
|
|
69
|
+
if hasattr(mod, "app"):
|
|
70
|
+
mod.app.run(debug=True)
|
|
71
|
+
else:
|
|
72
|
+
print("Error: no 'app' variable found in module.", file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> None:
|
|
77
|
+
args = sys.argv[1:]
|
|
78
|
+
if not args or args[0] in ("-h", "--help", "help"):
|
|
79
|
+
print(_HELP)
|
|
80
|
+
return
|
|
81
|
+
cmd = args[0]
|
|
82
|
+
if cmd == "new":
|
|
83
|
+
if len(args) < 2:
|
|
84
|
+
print("Usage: pyrith new <project>", file=sys.stderr)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
_new_project(args[1])
|
|
87
|
+
elif cmd == "run":
|
|
88
|
+
file = args[1] if len(args) > 1 else "app.py"
|
|
89
|
+
_run_app(file)
|
|
90
|
+
elif cmd == "version":
|
|
91
|
+
from pyrith import __version__
|
|
92
|
+
print(f"Pyrith {__version__}")
|
|
93
|
+
else:
|
|
94
|
+
print(f"Unknown command: {cmd}\n{_HELP}", file=sys.stderr)
|
|
95
|
+
sys.exit(1)
|
pyrith/forms/__init__.py
ADDED
pyrith/forms/form.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Pyrith Forms — form validation and HTML rendering."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FormField:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
label: str = "",
|
|
11
|
+
required: bool = False,
|
|
12
|
+
default: Any = None,
|
|
13
|
+
validators: Optional[List] = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
self.label = label
|
|
16
|
+
self.required = required
|
|
17
|
+
self.default = default
|
|
18
|
+
self.validators = validators or []
|
|
19
|
+
self._name: str = ""
|
|
20
|
+
|
|
21
|
+
def __set_name__(self, owner, name: str) -> None:
|
|
22
|
+
self._name = name
|
|
23
|
+
if not self.label:
|
|
24
|
+
self.label = name.replace("_", " ").title()
|
|
25
|
+
|
|
26
|
+
def coerce(self, value: str) -> Any:
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
def validate(self, value: Any) -> List[str]:
|
|
30
|
+
errors = []
|
|
31
|
+
if self.required and (value is None or value == ""):
|
|
32
|
+
errors.append(f"{self.label} is required.")
|
|
33
|
+
for validator in self.validators:
|
|
34
|
+
err = validator(value)
|
|
35
|
+
if err:
|
|
36
|
+
errors.append(err)
|
|
37
|
+
return errors
|
|
38
|
+
|
|
39
|
+
def render(self, value: Any = "") -> str:
|
|
40
|
+
return (
|
|
41
|
+
f'<label for="{self._name}">{self.label}</label>'
|
|
42
|
+
f'<input type="text" id="{self._name}" name="{self._name}" value="{value}">'
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StringField(FormField):
|
|
47
|
+
def __init__(self, min_length: int = 0, max_length: Optional[int] = None, **kwargs) -> None:
|
|
48
|
+
super().__init__(**kwargs)
|
|
49
|
+
self.min_length = min_length
|
|
50
|
+
self.max_length = max_length
|
|
51
|
+
|
|
52
|
+
def validate(self, value: Any) -> List[str]:
|
|
53
|
+
errors = super().validate(value)
|
|
54
|
+
if value and len(str(value)) < self.min_length:
|
|
55
|
+
errors.append(f"{self.label} must be at least {self.min_length} characters.")
|
|
56
|
+
if value and self.max_length and len(str(value)) > self.max_length:
|
|
57
|
+
errors.append(f"{self.label} must be at most {self.max_length} characters.")
|
|
58
|
+
return errors
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IntegerField(FormField):
|
|
62
|
+
def __init__(self, min_val: Optional[int] = None, max_val: Optional[int] = None, **kwargs) -> None:
|
|
63
|
+
super().__init__(**kwargs)
|
|
64
|
+
self.min_val = min_val
|
|
65
|
+
self.max_val = max_val
|
|
66
|
+
|
|
67
|
+
def coerce(self, value: str) -> Optional[int]:
|
|
68
|
+
try:
|
|
69
|
+
return int(value)
|
|
70
|
+
except (ValueError, TypeError):
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def validate(self, value: Any) -> List[str]:
|
|
74
|
+
errors = super().validate(value)
|
|
75
|
+
if value is not None and value != "":
|
|
76
|
+
try:
|
|
77
|
+
n = int(value)
|
|
78
|
+
if self.min_val is not None and n < self.min_val:
|
|
79
|
+
errors.append(f"{self.label} must be at least {self.min_val}.")
|
|
80
|
+
if self.max_val is not None and n > self.max_val:
|
|
81
|
+
errors.append(f"{self.label} must be at most {self.max_val}.")
|
|
82
|
+
except (ValueError, TypeError):
|
|
83
|
+
errors.append(f"{self.label} must be an integer.")
|
|
84
|
+
return errors
|
|
85
|
+
|
|
86
|
+
def render(self, value: Any = "") -> str:
|
|
87
|
+
return (
|
|
88
|
+
f'<label for="{self._name}">{self.label}</label>'
|
|
89
|
+
f'<input type="number" id="{self._name}" name="{self._name}" value="{value}">'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class EmailField(StringField):
|
|
94
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
95
|
+
|
|
96
|
+
def validate(self, value: Any) -> List[str]:
|
|
97
|
+
errors = super().validate(value)
|
|
98
|
+
if value and not self._EMAIL_RE.match(str(value)):
|
|
99
|
+
errors.append(f"{self.label} must be a valid email address.")
|
|
100
|
+
return errors
|
|
101
|
+
|
|
102
|
+
def render(self, value: Any = "") -> str:
|
|
103
|
+
return (
|
|
104
|
+
f'<label for="{self._name}">{self.label}</label>'
|
|
105
|
+
f'<input type="email" id="{self._name}" name="{self._name}" value="{value}">'
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class BooleanField(FormField):
|
|
110
|
+
def coerce(self, value: str) -> bool:
|
|
111
|
+
return value in ("1", "true", "on", "yes")
|
|
112
|
+
|
|
113
|
+
def render(self, value: Any = False) -> str:
|
|
114
|
+
checked = ' checked' if value else ''
|
|
115
|
+
return (
|
|
116
|
+
f'<label><input type="checkbox" id="{self._name}" name="{self._name}"'
|
|
117
|
+
f'{checked}> {self.label}</label>'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class FormMeta(type):
|
|
122
|
+
def __new__(mcs, name, bases, namespace):
|
|
123
|
+
fields: Dict[str, FormField] = {}
|
|
124
|
+
for base in bases:
|
|
125
|
+
if hasattr(base, "_form_fields"):
|
|
126
|
+
fields.update(base._form_fields)
|
|
127
|
+
for k, v in namespace.items():
|
|
128
|
+
if isinstance(v, FormField):
|
|
129
|
+
v._name = k
|
|
130
|
+
if not v.label:
|
|
131
|
+
v.label = k.replace("_", " ").title()
|
|
132
|
+
fields[k] = v
|
|
133
|
+
namespace["_form_fields"] = fields
|
|
134
|
+
return super().__new__(mcs, name, bases, namespace)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class Form(metaclass=FormMeta):
|
|
138
|
+
"""
|
|
139
|
+
Declarative form with validation and HTML rendering.
|
|
140
|
+
|
|
141
|
+
Usage::
|
|
142
|
+
|
|
143
|
+
class LoginForm(Form):
|
|
144
|
+
username = StringField(required=True, min_length=3)
|
|
145
|
+
password = StringField(required=True, min_length=8)
|
|
146
|
+
|
|
147
|
+
@app.post("/login")
|
|
148
|
+
def login(request):
|
|
149
|
+
form = LoginForm(request.form())
|
|
150
|
+
if form.is_valid():
|
|
151
|
+
...
|
|
152
|
+
return HTMLResponse(form.render())
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
_form_fields: Dict[str, FormField] = {}
|
|
156
|
+
|
|
157
|
+
def __init__(self, data: Optional[Dict[str, str]] = None) -> None:
|
|
158
|
+
self._data = data or {}
|
|
159
|
+
self._errors: Dict[str, List[str]] = {}
|
|
160
|
+
self._cleaned: Dict[str, Any] = {}
|
|
161
|
+
|
|
162
|
+
def is_valid(self) -> bool:
|
|
163
|
+
self._errors = {}
|
|
164
|
+
self._cleaned = {}
|
|
165
|
+
for name, field in self._form_fields.items():
|
|
166
|
+
raw = self._data.get(name, "")
|
|
167
|
+
value = field.coerce(raw)
|
|
168
|
+
errors = field.validate(value)
|
|
169
|
+
if errors:
|
|
170
|
+
self._errors[name] = errors
|
|
171
|
+
else:
|
|
172
|
+
self._cleaned[name] = value
|
|
173
|
+
return len(self._errors) == 0
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def errors(self) -> Dict[str, List[str]]:
|
|
177
|
+
return self._errors
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def cleaned_data(self) -> Dict[str, Any]:
|
|
181
|
+
return self._cleaned
|
|
182
|
+
|
|
183
|
+
def render(self, action: str = "", method: str = "post") -> str:
|
|
184
|
+
lines = [f'<form action="{action}" method="{method}">']
|
|
185
|
+
for name, field in self._form_fields.items():
|
|
186
|
+
value = self._data.get(name, field.default or "")
|
|
187
|
+
lines.append(f' <div class="form-field">')
|
|
188
|
+
lines.append(f" {field.render(value)}")
|
|
189
|
+
if name in self._errors:
|
|
190
|
+
for err in self._errors[name]:
|
|
191
|
+
lines.append(f' <span class="form-error">{err}</span>')
|
|
192
|
+
lines.append(" </div>")
|
|
193
|
+
lines.append(' <button type="submit">Submit</button>')
|
|
194
|
+
lines.append("</form>")
|
|
195
|
+
return "\n".join(lines)
|