python-in-underwear 0.5.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.
- piu/__init__.py +36 -0
- piu/__main__.py +3 -0
- piu/app.py +226 -0
- piu/auth.py +114 -0
- piu/cli.py +161 -0
- piu/config.py +85 -0
- piu/csrf.py +65 -0
- piu/helpers.py +26 -0
- piu/middleware.py +40 -0
- piu/openapi.py +129 -0
- piu/plugins.py +14 -0
- piu/ratelimit.py +119 -0
- piu/routing.py +70 -0
- piu/serving.py +119 -0
- piu/sessions.py +86 -0
- piu/static.py +39 -0
- piu/tasks.py +46 -0
- piu/templating.py +30 -0
- piu/testing.py +111 -0
- piu/websocket.py +67 -0
- piu/wrappers.py +86 -0
- python_in_underwear-0.5.0.dist-info/METADATA +461 -0
- python_in_underwear-0.5.0.dist-info/RECORD +27 -0
- python_in_underwear-0.5.0.dist-info/WHEEL +5 -0
- python_in_underwear-0.5.0.dist-info/entry_points.txt +2 -0
- python_in_underwear-0.5.0.dist-info/licenses/LICENSE +21 -0
- python_in_underwear-0.5.0.dist-info/top_level.txt +1 -0
piu/helpers.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
_STATUS_CODES = {
|
|
2
|
+
200: "OK",
|
|
3
|
+
201: "Created",
|
|
4
|
+
204: "No Content",
|
|
5
|
+
301: "Moved Permanently",
|
|
6
|
+
302: "Found",
|
|
7
|
+
304: "Not Modified",
|
|
8
|
+
400: "Bad Request",
|
|
9
|
+
401: "Unauthorized",
|
|
10
|
+
403: "Forbidden",
|
|
11
|
+
404: "Not Found",
|
|
12
|
+
405: "Method Not Allowed",
|
|
13
|
+
409: "Conflict",
|
|
14
|
+
410: "Gone",
|
|
15
|
+
422: "Unprocessable Entity",
|
|
16
|
+
429: "Too Many Requests",
|
|
17
|
+
500: "Internal Server Error",
|
|
18
|
+
501: "Not Implemented",
|
|
19
|
+
502: "Bad Gateway",
|
|
20
|
+
503: "Service Unavailable",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def status_text(code: int) -> str:
|
|
25
|
+
"""Return the standard HTTP reason phrase for a status code."""
|
|
26
|
+
return _STATUS_CODES.get(code, "Unknown")
|
piu/middleware.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from .wrappers import Request, Response
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MiddlewareStack:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._middlewares: list[Callable] = []
|
|
10
|
+
|
|
11
|
+
def use(self, fn: Callable):
|
|
12
|
+
self._middlewares.append(fn)
|
|
13
|
+
|
|
14
|
+
async def run(self, request: Request, final_handler: Callable) -> Response:
|
|
15
|
+
middlewares = self._middlewares
|
|
16
|
+
n = len(middlewares)
|
|
17
|
+
|
|
18
|
+
async def call(idx: int, req: Request) -> Response:
|
|
19
|
+
if idx == n:
|
|
20
|
+
if inspect.iscoroutinefunction(final_handler):
|
|
21
|
+
return await final_handler(req)
|
|
22
|
+
return final_handler(req)
|
|
23
|
+
|
|
24
|
+
mw = middlewares[idx]
|
|
25
|
+
|
|
26
|
+
async def next_fn(r: Request) -> Response:
|
|
27
|
+
return await call(idx + 1, r)
|
|
28
|
+
|
|
29
|
+
if inspect.iscoroutinefunction(mw):
|
|
30
|
+
return await mw(req, next_fn)
|
|
31
|
+
|
|
32
|
+
result = mw(req, next_fn)
|
|
33
|
+
if inspect.isawaitable(result):
|
|
34
|
+
return await result
|
|
35
|
+
return result
|
|
36
|
+
|
|
37
|
+
return await call(0, request)
|
|
38
|
+
|
|
39
|
+
def __repr__(self):
|
|
40
|
+
return f"<MiddlewareStack count={len(self._middlewares)}>"
|
piu/openapi.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from typing import Callable, get_type_hints
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _python_type_to_json(annotation) -> dict:
|
|
8
|
+
mapping = {
|
|
9
|
+
int: {"type": "integer"},
|
|
10
|
+
float: {"type": "number"},
|
|
11
|
+
bool: {"type": "boolean"},
|
|
12
|
+
str: {"type": "string"},
|
|
13
|
+
bytes: {"type": "string", "format": "binary"},
|
|
14
|
+
}
|
|
15
|
+
return mapping.get(annotation, {"type": "string"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _extract_path_params(pattern: str) -> list[dict]:
|
|
19
|
+
params = []
|
|
20
|
+
for name in re.findall(r"\(\?P<(\w+)>", pattern):
|
|
21
|
+
params.append({
|
|
22
|
+
"name": name,
|
|
23
|
+
"in": "path",
|
|
24
|
+
"required": True,
|
|
25
|
+
"schema": {"type": "string"},
|
|
26
|
+
})
|
|
27
|
+
return params
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _route_pattern_to_openapi(pattern: str) -> str:
|
|
31
|
+
clean = pattern.lstrip("^").rstrip("$")
|
|
32
|
+
clean = re.sub(r"\(\?P<(\w+)>[^)]+\)", r"{\1}", clean)
|
|
33
|
+
return clean
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_schema(router, title: str = "PIU API", version: str = "0.1.0",
|
|
37
|
+
description: str = "") -> dict:
|
|
38
|
+
paths = {}
|
|
39
|
+
|
|
40
|
+
for route in router._routes:
|
|
41
|
+
raw = route.regex.pattern
|
|
42
|
+
oapi_path = _route_pattern_to_openapi(raw)
|
|
43
|
+
path_params = _extract_path_params(raw)
|
|
44
|
+
|
|
45
|
+
if oapi_path not in paths:
|
|
46
|
+
paths[oapi_path] = {}
|
|
47
|
+
|
|
48
|
+
for method in route.methods:
|
|
49
|
+
op: dict = {
|
|
50
|
+
"operationId": f"{method.lower()}_{route.handler.__name__}",
|
|
51
|
+
"summary": route.handler.__name__.replace("_", " ").title(),
|
|
52
|
+
"responses": {
|
|
53
|
+
"200": {"description": "Success"},
|
|
54
|
+
"400": {"description": "Bad Request"},
|
|
55
|
+
"401": {"description": "Unauthorized"},
|
|
56
|
+
"404": {"description": "Not Found"},
|
|
57
|
+
"500": {"description": "Internal Server Error"},
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if path_params:
|
|
62
|
+
op["parameters"] = path_params
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
hints = get_type_hints(route.handler)
|
|
66
|
+
hints.pop("return", None)
|
|
67
|
+
hints.pop("request", None)
|
|
68
|
+
query_params = []
|
|
69
|
+
for param_name, annotation in hints.items():
|
|
70
|
+
if param_name not in [p["name"] for p in path_params]:
|
|
71
|
+
query_params.append({
|
|
72
|
+
"name": param_name,
|
|
73
|
+
"in": "query",
|
|
74
|
+
"required": False,
|
|
75
|
+
"schema": _python_type_to_json(annotation),
|
|
76
|
+
})
|
|
77
|
+
if query_params:
|
|
78
|
+
op.setdefault("parameters", []).extend(query_params)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
if method in ("POST", "PUT", "PATCH"):
|
|
83
|
+
op["requestBody"] = {
|
|
84
|
+
"content": {
|
|
85
|
+
"application/json": {
|
|
86
|
+
"schema": {"type": "object"}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
doc = inspect.getdoc(route.handler)
|
|
92
|
+
if doc:
|
|
93
|
+
op["description"] = doc
|
|
94
|
+
|
|
95
|
+
paths[oapi_path][method.lower()] = op
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"openapi": "3.0.3",
|
|
99
|
+
"info": {
|
|
100
|
+
"title": title,
|
|
101
|
+
"version": version,
|
|
102
|
+
"description": description,
|
|
103
|
+
},
|
|
104
|
+
"paths": paths,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
SWAGGER_HTML = """<!DOCTYPE html>
|
|
109
|
+
<html>
|
|
110
|
+
<head>
|
|
111
|
+
<title>{title} — API Docs</title>
|
|
112
|
+
<meta charset="utf-8"/>
|
|
113
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
114
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui.min.css">
|
|
115
|
+
</head>
|
|
116
|
+
<body>
|
|
117
|
+
<div id="swagger-ui"></div>
|
|
118
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-bundle.min.js"></script>
|
|
119
|
+
<script>
|
|
120
|
+
SwaggerUIBundle({{
|
|
121
|
+
url: "/openapi.json",
|
|
122
|
+
dom_id: "#swagger-ui",
|
|
123
|
+
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
|
124
|
+
layout: "BaseLayout",
|
|
125
|
+
deepLinking: true,
|
|
126
|
+
}});
|
|
127
|
+
</script>
|
|
128
|
+
</body>
|
|
129
|
+
</html>"""
|
piu/plugins.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
|
|
3
|
+
if TYPE_CHECKING:
|
|
4
|
+
from .app import PIU
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Plugin:
|
|
8
|
+
name: str = "unnamed"
|
|
9
|
+
|
|
10
|
+
def setup(self, app: "PIU"):
|
|
11
|
+
raise NotImplementedError(f"Plugin '{self.name}' must implement setup(app).")
|
|
12
|
+
|
|
13
|
+
def __repr__(self):
|
|
14
|
+
return f"<Plugin '{self.name}'>"
|
piu/ratelimit.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from .wrappers import Request, Response
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AbstractRateLimitStore(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def hit(self, key: str, window: int) -> int:
|
|
14
|
+
"""Record a hit for key within the window. Returns current hit count."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def reset(self, key: str):
|
|
18
|
+
"""Clear all hits for a key."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InMemoryStore(AbstractRateLimitStore):
|
|
22
|
+
"""Sliding window counter stored in-process. Not suitable for multi-process deployments."""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
|
|
26
|
+
self._hits: dict[str, list[float]] = defaultdict(list)
|
|
27
|
+
|
|
28
|
+
def hit(self, key: str, window: int) -> int:
|
|
29
|
+
now = time.monotonic()
|
|
30
|
+
cutoff = now - window
|
|
31
|
+
hits = self._hits[key]
|
|
32
|
+
|
|
33
|
+
self._hits[key] = [t for t in hits if t > cutoff]
|
|
34
|
+
self._hits[key].append(now)
|
|
35
|
+
return len(self._hits[key])
|
|
36
|
+
|
|
37
|
+
def reset(self, key: str):
|
|
38
|
+
self._hits.pop(key, None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_client_ip(request: Request) -> str:
|
|
42
|
+
forwarded = request.headers.get("X-Forwarded-For", "")
|
|
43
|
+
if forwarded:
|
|
44
|
+
return forwarded.split(",")[0].strip()
|
|
45
|
+
return request.headers.get("Remote-Addr", "unknown")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rate_limit_response(limit: int, window: int) -> Response:
|
|
49
|
+
resp = Response(
|
|
50
|
+
body=f"429 Too Many Requests — limit is {limit} per {window}s.",
|
|
51
|
+
status=429,
|
|
52
|
+
)
|
|
53
|
+
resp.headers["Retry-After"] = str(window)
|
|
54
|
+
return resp
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RateLimitMiddleware:
|
|
60
|
+
def __init__(self, limit: int, window: int,
|
|
61
|
+
store: AbstractRateLimitStore = None,
|
|
62
|
+
key_func: Callable = None):
|
|
63
|
+
"""
|
|
64
|
+
Args:
|
|
65
|
+
limit: Max requests allowed per window.
|
|
66
|
+
window: Time window in seconds.
|
|
67
|
+
store: Custom store (defaults to InMemoryStore).
|
|
68
|
+
key_func: fn(request) -> str to derive rate limit key.
|
|
69
|
+
Defaults to client IP.
|
|
70
|
+
"""
|
|
71
|
+
self._limit = limit
|
|
72
|
+
self._window = window
|
|
73
|
+
self._store = store or InMemoryStore()
|
|
74
|
+
self._key_func = key_func or _get_client_ip
|
|
75
|
+
|
|
76
|
+
async def __call__(self, request: Request, next: Callable) -> Response:
|
|
77
|
+
key = f"global:{self._key_func(request)}"
|
|
78
|
+
count = self._store.hit(key, self._window)
|
|
79
|
+
if count > self._limit:
|
|
80
|
+
return _rate_limit_response(self._limit, self._window)
|
|
81
|
+
return await next(request)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_route_store = InMemoryStore()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def rate_limit(limit: int, window: int,
|
|
90
|
+
store: AbstractRateLimitStore = None,
|
|
91
|
+
key_func: Callable = None):
|
|
92
|
+
"""Decorator to apply a rate limit to a specific route handler.
|
|
93
|
+
|
|
94
|
+
Example::
|
|
95
|
+
|
|
96
|
+
@app.post("/login")
|
|
97
|
+
@rate_limit(limit=5, window=60)
|
|
98
|
+
def login(request):
|
|
99
|
+
...
|
|
100
|
+
"""
|
|
101
|
+
_store = store or _route_store
|
|
102
|
+
_key_fn = key_func or _get_client_ip
|
|
103
|
+
|
|
104
|
+
def decorator(fn: Callable):
|
|
105
|
+
import inspect
|
|
106
|
+
import functools
|
|
107
|
+
|
|
108
|
+
@functools.wraps(fn)
|
|
109
|
+
async def wrapper(request: Request, *args, **kwargs):
|
|
110
|
+
key = f"{fn.__name__}:{_key_fn(request)}"
|
|
111
|
+
count = _store.hit(key, window)
|
|
112
|
+
if count > limit:
|
|
113
|
+
return _rate_limit_response(limit, window)
|
|
114
|
+
if inspect.iscoroutinefunction(fn):
|
|
115
|
+
return await fn(request, *args, **kwargs)
|
|
116
|
+
return fn(request, *args, **kwargs)
|
|
117
|
+
|
|
118
|
+
return wrapper
|
|
119
|
+
return decorator
|
piu/routing.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Callable, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Route:
|
|
6
|
+
def __init__(self, pattern: str, handler: Callable, methods: list[str]):
|
|
7
|
+
self.methods = [m.upper() for m in methods]
|
|
8
|
+
self.handler = handler
|
|
9
|
+
regex = re.sub(r"<(\w+)>", r"(?P<\1>[^/]+)", pattern)
|
|
10
|
+
self.regex = re.compile(f"^{regex}$")
|
|
11
|
+
|
|
12
|
+
def match(self, path: str, method: str) -> Optional[dict]:
|
|
13
|
+
if method.upper() not in self.methods:
|
|
14
|
+
return None
|
|
15
|
+
m = self.regex.match(path)
|
|
16
|
+
return m.groupdict() if m else None
|
|
17
|
+
|
|
18
|
+
def __repr__(self):
|
|
19
|
+
return f"<Route {self.methods} {self.regex.pattern}>"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Router:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self._routes: list[Route] = []
|
|
25
|
+
|
|
26
|
+
def add_route(self, pattern: str, handler: Callable, methods: list[str]):
|
|
27
|
+
self._routes.append(Route(pattern, handler, methods))
|
|
28
|
+
|
|
29
|
+
def resolve(self, path: str, method: str) -> tuple[Optional[Callable], dict]:
|
|
30
|
+
for route in self._routes:
|
|
31
|
+
params = route.match(path, method)
|
|
32
|
+
if params is not None:
|
|
33
|
+
return route.handler, params
|
|
34
|
+
return None, {}
|
|
35
|
+
|
|
36
|
+
def __repr__(self):
|
|
37
|
+
return f"<Router routes={len(self._routes)}>"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Blueprint:
|
|
41
|
+
"""A group of routes that can be registered on a PIU app with a URL prefix."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, name: str, prefix: str = ""):
|
|
44
|
+
self.name = name
|
|
45
|
+
self.prefix = prefix.rstrip("/")
|
|
46
|
+
self._routes: list[tuple[str, Callable, list[str]]] = []
|
|
47
|
+
|
|
48
|
+
def route(self, path: str, methods: list[str] = ["GET"]):
|
|
49
|
+
def decorator(fn: Callable):
|
|
50
|
+
self._routes.append((path, fn, methods))
|
|
51
|
+
return fn
|
|
52
|
+
return decorator
|
|
53
|
+
|
|
54
|
+
def get(self, path: str):
|
|
55
|
+
return self.route(path, methods=["GET"])
|
|
56
|
+
|
|
57
|
+
def post(self, path: str):
|
|
58
|
+
return self.route(path, methods=["POST"])
|
|
59
|
+
|
|
60
|
+
def put(self, path: str):
|
|
61
|
+
return self.route(path, methods=["PUT"])
|
|
62
|
+
|
|
63
|
+
def patch(self, path: str):
|
|
64
|
+
return self.route(path, methods=["PATCH"])
|
|
65
|
+
|
|
66
|
+
def delete(self, path: str):
|
|
67
|
+
return self.route(path, methods=["DELETE"])
|
|
68
|
+
|
|
69
|
+
def __repr__(self):
|
|
70
|
+
return f"<Blueprint '{self.name}' prefix='{self.prefix}' routes={len(self._routes)}>"
|
piu/serving.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
from urllib.parse import parse_qs, urlparse
|
|
8
|
+
|
|
9
|
+
from .wrappers import Request
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_dev_server(app, host: str = "127.0.0.1", port: int = 5000,
|
|
13
|
+
reload: bool = False):
|
|
14
|
+
if reload and os.environ.get("PIU_RELOADER_CHILD") != "1":
|
|
15
|
+
_run_with_reload(host, port)
|
|
16
|
+
return
|
|
17
|
+
_serve(app, host, port)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _make_handler(app, loop):
|
|
21
|
+
class Handler(BaseHTTPRequestHandler):
|
|
22
|
+
def _handle(self):
|
|
23
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
24
|
+
body = self.rfile.read(length) if length else b""
|
|
25
|
+
parsed = urlparse(self.path)
|
|
26
|
+
query = parse_qs(parsed.query)
|
|
27
|
+
|
|
28
|
+
req = Request(
|
|
29
|
+
method=self.command,
|
|
30
|
+
path=parsed.path,
|
|
31
|
+
headers=dict(self.headers),
|
|
32
|
+
body=body,
|
|
33
|
+
query_params=query,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
resp = loop.run_until_complete(app._dispatch(req))
|
|
37
|
+
resp = app._finalize(resp)
|
|
38
|
+
|
|
39
|
+
self.send_response(resp.status)
|
|
40
|
+
self.send_header("Content-Type", resp.content_type)
|
|
41
|
+
for k, v in resp.headers.items():
|
|
42
|
+
self.send_header(k, v)
|
|
43
|
+
self.end_headers()
|
|
44
|
+
self.wfile.write(resp.body)
|
|
45
|
+
|
|
46
|
+
def do_GET(self): self._handle()
|
|
47
|
+
def do_POST(self): self._handle()
|
|
48
|
+
def do_PUT(self): self._handle()
|
|
49
|
+
def do_DELETE(self): self._handle()
|
|
50
|
+
def do_PATCH(self): self._handle()
|
|
51
|
+
|
|
52
|
+
def log_message(self, fmt, *args):
|
|
53
|
+
print(f"[PIU] {self.address_string()} - {fmt % args}")
|
|
54
|
+
|
|
55
|
+
return Handler
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _serve(app, host: str, port: int):
|
|
59
|
+
loop = asyncio.new_event_loop()
|
|
60
|
+
asyncio.set_event_loop(loop)
|
|
61
|
+
Handler = _make_handler(app, loop)
|
|
62
|
+
server = HTTPServer((host, port), Handler)
|
|
63
|
+
print(f"[PIU] 🩲 Dev server running on http://{host}:{port} (Ctrl+C to stop)")
|
|
64
|
+
try:
|
|
65
|
+
server.serve_forever()
|
|
66
|
+
except KeyboardInterrupt:
|
|
67
|
+
print("\n[PIU] Shutting down. Bye 🩲")
|
|
68
|
+
finally:
|
|
69
|
+
server.server_close()
|
|
70
|
+
loop.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _run_with_reload(host: str, port: int):
|
|
74
|
+
try:
|
|
75
|
+
from watchdog.events import FileSystemEventHandler
|
|
76
|
+
from watchdog.observers import Observer
|
|
77
|
+
except ImportError:
|
|
78
|
+
print("[PIU] Hot reload requires watchdog: pip install watchdog")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
env = os.environ.copy()
|
|
82
|
+
env["PIU_RELOADER_CHILD"] = "1"
|
|
83
|
+
|
|
84
|
+
process: list[subprocess.Popen] = [None]
|
|
85
|
+
|
|
86
|
+
def start():
|
|
87
|
+
process[0] = subprocess.Popen([sys.executable] + sys.argv, env=env)
|
|
88
|
+
|
|
89
|
+
def restart():
|
|
90
|
+
if process[0] and process[0].poll() is None:
|
|
91
|
+
print("[PIU] 🔄 Change detected — restarting...")
|
|
92
|
+
process[0].terminate()
|
|
93
|
+
process[0].wait()
|
|
94
|
+
start()
|
|
95
|
+
|
|
96
|
+
class ChangeHandler(FileSystemEventHandler):
|
|
97
|
+
def on_modified(self, event):
|
|
98
|
+
if not event.is_directory and event.src_path.endswith(".py"):
|
|
99
|
+
restart()
|
|
100
|
+
|
|
101
|
+
start()
|
|
102
|
+
|
|
103
|
+
observer = Observer()
|
|
104
|
+
observer.schedule(ChangeHandler(), path=".", recursive=True)
|
|
105
|
+
observer.start()
|
|
106
|
+
print("[PIU] 👀 Watching for changes (hot reload active)...")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
while True:
|
|
110
|
+
time.sleep(1)
|
|
111
|
+
if process[0] and process[0].poll() is not None:
|
|
112
|
+
start()
|
|
113
|
+
except KeyboardInterrupt:
|
|
114
|
+
print("\n[PIU] Shutting down. Bye 🩲")
|
|
115
|
+
observer.stop()
|
|
116
|
+
if process[0] and process[0].poll() is None:
|
|
117
|
+
process[0].terminate()
|
|
118
|
+
finally:
|
|
119
|
+
observer.join()
|
piu/sessions.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session middleware for PIU.
|
|
3
|
+
Sessions are stored as signed, base64-encoded JSON cookies.
|
|
4
|
+
No server-side storage needed — the cookie IS the session.
|
|
5
|
+
|
|
6
|
+
Requires: pip install cryptography
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from .wrappers import Request, Response
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Session(dict):
|
|
20
|
+
"""A dict subclass that tracks whether it has been modified."""
|
|
21
|
+
def __init__(self, *args, **kwargs):
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
self.modified = False
|
|
24
|
+
|
|
25
|
+
def __setitem__(self, key, value):
|
|
26
|
+
super().__setitem__(key, value)
|
|
27
|
+
self.modified = True
|
|
28
|
+
|
|
29
|
+
def __delitem__(self, key):
|
|
30
|
+
super().__delitem__(key)
|
|
31
|
+
self.modified = True
|
|
32
|
+
|
|
33
|
+
def clear(self):
|
|
34
|
+
super().clear()
|
|
35
|
+
self.modified = True
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SessionMiddleware:
|
|
39
|
+
COOKIE_NAME = "piu_session"
|
|
40
|
+
|
|
41
|
+
def __init__(self, secret_key: str, max_age: int = 86400,
|
|
42
|
+
httponly: bool = True, secure: bool = False,
|
|
43
|
+
samesite: str = "Lax"):
|
|
44
|
+
if not secret_key:
|
|
45
|
+
raise ValueError("SessionMiddleware requires a non-empty secret_key.")
|
|
46
|
+
self._secret = secret_key.encode()
|
|
47
|
+
self._max_age = max_age
|
|
48
|
+
self._httponly = httponly
|
|
49
|
+
self._secure = secure
|
|
50
|
+
self._samesite = samesite
|
|
51
|
+
|
|
52
|
+
def _sign(self, data: str) -> str:
|
|
53
|
+
sig = hmac.new(self._secret, data.encode(), hashlib.sha256).hexdigest()
|
|
54
|
+
payload = base64.urlsafe_b64encode(data.encode()).decode()
|
|
55
|
+
return f"{payload}.{sig}"
|
|
56
|
+
|
|
57
|
+
def _unsign(self, token: str) -> dict | None:
|
|
58
|
+
try:
|
|
59
|
+
payload, sig = token.rsplit(".", 1)
|
|
60
|
+
data = base64.urlsafe_b64decode(payload.encode()).decode()
|
|
61
|
+
expected = hmac.new(self._secret, data.encode(), hashlib.sha256).hexdigest()
|
|
62
|
+
if not hmac.compare_digest(sig, expected):
|
|
63
|
+
return None
|
|
64
|
+
return json.loads(data)
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
async def __call__(self, request: Request, next: Callable) -> Response:
|
|
69
|
+
raw = request.cookies.get(self.COOKIE_NAME)
|
|
70
|
+
data = self._unsign(raw) if raw else None
|
|
71
|
+
request.session = Session(data or {})
|
|
72
|
+
|
|
73
|
+
response = await next(request)
|
|
74
|
+
|
|
75
|
+
if request.session.modified:
|
|
76
|
+
payload = json.dumps(dict(request.session), separators=(",", ":"))
|
|
77
|
+
token = self._sign(payload)
|
|
78
|
+
response.set_cookie(
|
|
79
|
+
self.COOKIE_NAME, token,
|
|
80
|
+
max_age=self._max_age,
|
|
81
|
+
httponly=self._httponly,
|
|
82
|
+
secure=self._secure,
|
|
83
|
+
samesite=self._samesite,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return response
|
piu/static.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import mimetypes
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from .wrappers import Response
|
|
5
|
+
|
|
6
|
+
mimetypes.add_type("text/javascript", ".js")
|
|
7
|
+
mimetypes.add_type("text/css", ".css")
|
|
8
|
+
mimetypes.add_type("image/svg+xml", ".svg")
|
|
9
|
+
mimetypes.add_type("application/json", ".json")
|
|
10
|
+
mimetypes.add_type("font/woff2", ".woff2")
|
|
11
|
+
mimetypes.add_type("font/woff", ".woff")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def serve_static(path: str, static_dir: str, url_prefix: str = "/static") -> Response | None:
|
|
15
|
+
"""
|
|
16
|
+
Attempt to serve a static file for the given request path.
|
|
17
|
+
Returns a Response if the file exists, otherwise None.
|
|
18
|
+
"""
|
|
19
|
+
if not path.startswith(url_prefix):
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
rel = path[len(url_prefix):].lstrip("/")
|
|
23
|
+
|
|
24
|
+
static_dir = os.path.realpath(static_dir)
|
|
25
|
+
file_path = os.path.realpath(os.path.join(static_dir, rel))
|
|
26
|
+
|
|
27
|
+
if not file_path.startswith(static_dir):
|
|
28
|
+
return Response(body="403 Forbidden", status=403)
|
|
29
|
+
|
|
30
|
+
if not os.path.isfile(file_path):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
with open(file_path, "rb") as f:
|
|
34
|
+
content = f.read()
|
|
35
|
+
|
|
36
|
+
mime, _ = mimetypes.guess_type(file_path)
|
|
37
|
+
content_type = mime or "application/octet-stream"
|
|
38
|
+
|
|
39
|
+
return Response(body=content, status=200, content_type=content_type)
|
piu/tasks.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import traceback
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BackgroundTaskRunner:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._tasks: list[asyncio.Task] = []
|
|
10
|
+
|
|
11
|
+
def add(self, fn: Callable, *args, **kwargs):
|
|
12
|
+
loop = asyncio.get_event_loop()
|
|
13
|
+
|
|
14
|
+
async def _run():
|
|
15
|
+
try:
|
|
16
|
+
if inspect.iscoroutinefunction(fn):
|
|
17
|
+
await fn(*args, **kwargs)
|
|
18
|
+
else:
|
|
19
|
+
await loop.run_in_executor(None, lambda: fn(*args, **kwargs))
|
|
20
|
+
except Exception:
|
|
21
|
+
traceback.print_exc()
|
|
22
|
+
|
|
23
|
+
task = loop.create_task(_run())
|
|
24
|
+
self._tasks.append(task)
|
|
25
|
+
task.add_done_callback(lambda t: self._tasks.remove(t) if t in self._tasks else None)
|
|
26
|
+
|
|
27
|
+
async def wait(self):
|
|
28
|
+
if self._tasks:
|
|
29
|
+
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
30
|
+
|
|
31
|
+
def __repr__(self):
|
|
32
|
+
return f"<BackgroundTaskRunner pending={len(self._tasks)}>"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BackgroundTasks:
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._fns: list[tuple[Callable, tuple, dict]] = []
|
|
38
|
+
|
|
39
|
+
def add(self, fn: Callable, *args, **kwargs):
|
|
40
|
+
self._fns.append((fn, args, kwargs))
|
|
41
|
+
|
|
42
|
+
async def run_all(self):
|
|
43
|
+
runner = BackgroundTaskRunner()
|
|
44
|
+
for fn, args, kwargs in self._fns:
|
|
45
|
+
runner.add(fn, *args, **kwargs)
|
|
46
|
+
await runner.wait()
|