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 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()
@@ -0,0 +1,5 @@
1
+ """Pyrith Auth — session management and route protection."""
2
+ from .session import SessionStore, Session
3
+ from .decorators import login_required, role_required
4
+
5
+ __all__ = ["SessionStore", "Session", "login_required", "role_required"]
@@ -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
@@ -0,0 +1,4 @@
1
+ """Pyrith CLI — project scaffolding and dev-server launcher."""
2
+ from .commands import main
3
+
4
+ __all__ = ["main"]
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)
@@ -0,0 +1,4 @@
1
+ """Pyrith Forms — Python-native HTML form validation."""
2
+ from .form import Form, FormField, StringField, IntegerField, EmailField, BooleanField
3
+
4
+ __all__ = ["Form", "FormField", "StringField", "IntegerField", "EmailField", "BooleanField"]
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)