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/templating.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
3
|
+
JINJA2_AVAILABLE = True
|
|
4
|
+
except ImportError:
|
|
5
|
+
JINJA2_AVAILABLE = False
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TemplateEngine:
|
|
9
|
+
def __init__(self, template_dir: str = "templates"):
|
|
10
|
+
if not JINJA2_AVAILABLE:
|
|
11
|
+
raise RuntimeError(
|
|
12
|
+
"Jinja2 is required for template rendering.\n"
|
|
13
|
+
"Install it with: pip install jinja2"
|
|
14
|
+
)
|
|
15
|
+
self.env = Environment(
|
|
16
|
+
loader=FileSystemLoader(template_dir),
|
|
17
|
+
autoescape=select_autoescape(["html", "xml"])
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def render(self, template_name: str, **context) -> str:
|
|
21
|
+
tmpl = self.env.get_template(template_name)
|
|
22
|
+
return tmpl.render(**context)
|
|
23
|
+
|
|
24
|
+
def render_string(self, source: str, **context) -> str:
|
|
25
|
+
"""Render a raw template string instead of a file."""
|
|
26
|
+
tmpl = self.env.from_string(source)
|
|
27
|
+
return tmpl.render(**context)
|
|
28
|
+
|
|
29
|
+
def __repr__(self):
|
|
30
|
+
return f"<TemplateEngine dir={self.env.loader.searchpath}>"
|
piu/testing.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json as _json
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
|
|
5
|
+
from .wrappers import Request, Response
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestClient:
|
|
9
|
+
def __init__(self, app):
|
|
10
|
+
self._app = app
|
|
11
|
+
self._cookies: dict[str, str] = {}
|
|
12
|
+
|
|
13
|
+
def _extract_cookies(self, response: Response):
|
|
14
|
+
for key, val in response.headers.items():
|
|
15
|
+
if key.lower() == "set-cookie":
|
|
16
|
+
for cookie_str in val.split("\n"):
|
|
17
|
+
parts = [p.strip() for p in cookie_str.split(";")]
|
|
18
|
+
if not parts:
|
|
19
|
+
continue
|
|
20
|
+
name, _, value = parts[0].partition("=")
|
|
21
|
+
max_age = None
|
|
22
|
+
for part in parts[1:]:
|
|
23
|
+
if part.lower().startswith("max-age="):
|
|
24
|
+
try:
|
|
25
|
+
max_age = int(part.split("=", 1)[1])
|
|
26
|
+
except ValueError:
|
|
27
|
+
pass
|
|
28
|
+
if max_age == 0:
|
|
29
|
+
self._cookies.pop(name.strip(), None)
|
|
30
|
+
else:
|
|
31
|
+
self._cookies[name.strip()] = value.strip()
|
|
32
|
+
|
|
33
|
+
def _cookie_header(self) -> str:
|
|
34
|
+
return "; ".join(f"{k}={v}" for k, v in self._cookies.items())
|
|
35
|
+
|
|
36
|
+
def _request(self, method: str, path: str,
|
|
37
|
+
headers: dict = None,
|
|
38
|
+
body: bytes = b"",
|
|
39
|
+
query: dict = None) -> "TestResponse":
|
|
40
|
+
hdrs = dict(headers or {})
|
|
41
|
+
if self._cookies:
|
|
42
|
+
hdrs.setdefault("Cookie", self._cookie_header())
|
|
43
|
+
|
|
44
|
+
req = Request(
|
|
45
|
+
method=method,
|
|
46
|
+
path=path,
|
|
47
|
+
headers=hdrs,
|
|
48
|
+
body=body,
|
|
49
|
+
query_params=query or {},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
loop = asyncio.new_event_loop()
|
|
53
|
+
try:
|
|
54
|
+
raw: Response = loop.run_until_complete(self._app._dispatch(req))
|
|
55
|
+
finally:
|
|
56
|
+
loop.close()
|
|
57
|
+
|
|
58
|
+
raw = self._app._finalize(raw)
|
|
59
|
+
self._extract_cookies(raw)
|
|
60
|
+
return TestResponse(raw)
|
|
61
|
+
|
|
62
|
+
def get(self, path: str, query: dict = None, headers: dict = None) -> "TestResponse":
|
|
63
|
+
return self._request("GET", path, headers=headers, query=query)
|
|
64
|
+
|
|
65
|
+
def post(self, path: str, json=None, data: dict = None,
|
|
66
|
+
body: bytes = b"", headers: dict = None) -> "TestResponse":
|
|
67
|
+
hdrs = dict(headers or {})
|
|
68
|
+
if json is not None:
|
|
69
|
+
body = _json.dumps(json).encode()
|
|
70
|
+
hdrs["Content-Type"] = "application/json"
|
|
71
|
+
elif data is not None:
|
|
72
|
+
body = urlencode(data).encode()
|
|
73
|
+
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
|
|
74
|
+
return self._request("POST", path, headers=hdrs, body=body)
|
|
75
|
+
|
|
76
|
+
def put(self, path: str, json=None, body: bytes = b"",
|
|
77
|
+
headers: dict = None) -> "TestResponse":
|
|
78
|
+
hdrs = dict(headers or {})
|
|
79
|
+
if json is not None:
|
|
80
|
+
body = _json.dumps(json).encode()
|
|
81
|
+
hdrs["Content-Type"] = "application/json"
|
|
82
|
+
return self._request("PUT", path, headers=hdrs, body=body)
|
|
83
|
+
|
|
84
|
+
def patch(self, path: str, json=None, body: bytes = b"",
|
|
85
|
+
headers: dict = None) -> "TestResponse":
|
|
86
|
+
hdrs = dict(headers or {})
|
|
87
|
+
if json is not None:
|
|
88
|
+
body = _json.dumps(json).encode()
|
|
89
|
+
hdrs["Content-Type"] = "application/json"
|
|
90
|
+
return self._request("PATCH", path, headers=hdrs, body=body)
|
|
91
|
+
|
|
92
|
+
def delete(self, path: str, headers: dict = None) -> "TestResponse":
|
|
93
|
+
return self._request("DELETE", path, headers=headers)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestResponse:
|
|
97
|
+
def __init__(self, response: Response):
|
|
98
|
+
self._raw = response
|
|
99
|
+
self.status = response.status
|
|
100
|
+
self.headers = response.headers
|
|
101
|
+
self.content_type = response.content_type
|
|
102
|
+
self.body = response.body
|
|
103
|
+
|
|
104
|
+
def text(self) -> str:
|
|
105
|
+
return self.body.decode("utf-8")
|
|
106
|
+
|
|
107
|
+
def json(self):
|
|
108
|
+
return _json.loads(self.body)
|
|
109
|
+
|
|
110
|
+
def __repr__(self):
|
|
111
|
+
return f"<TestResponse {self.status}>"
|
piu/websocket.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WebSocket:
|
|
6
|
+
def __init__(self, scope: dict, receive: Callable, send: Callable):
|
|
7
|
+
self._scope = scope
|
|
8
|
+
self._receive = receive
|
|
9
|
+
self._send = send
|
|
10
|
+
self.path = scope.get("path", "/")
|
|
11
|
+
self.headers = {k.decode(): v.decode() for k, v in scope.get("headers", [])}
|
|
12
|
+
self.query_params = scope.get("query_string", b"").decode()
|
|
13
|
+
|
|
14
|
+
async def accept(self):
|
|
15
|
+
await self._send({"type": "websocket.accept"})
|
|
16
|
+
|
|
17
|
+
async def send_text(self, data: str):
|
|
18
|
+
await self._send({"type": "websocket.send", "text": data})
|
|
19
|
+
|
|
20
|
+
async def send_bytes(self, data: bytes):
|
|
21
|
+
await self._send({"type": "websocket.send", "bytes": data})
|
|
22
|
+
|
|
23
|
+
async def receive_text(self) -> str | None:
|
|
24
|
+
event = await self._receive()
|
|
25
|
+
if event["type"] == "websocket.disconnect":
|
|
26
|
+
return None
|
|
27
|
+
return event.get("text", "")
|
|
28
|
+
|
|
29
|
+
async def receive_bytes(self) -> bytes | None:
|
|
30
|
+
event = await self._receive()
|
|
31
|
+
if event["type"] == "websocket.disconnect":
|
|
32
|
+
return None
|
|
33
|
+
return event.get("bytes", b"")
|
|
34
|
+
|
|
35
|
+
async def close(self, code: int = 1000):
|
|
36
|
+
await self._send({"type": "websocket.close", "code": code})
|
|
37
|
+
|
|
38
|
+
def __repr__(self):
|
|
39
|
+
return f"<WebSocket {self.path}>"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WebSocketRoute:
|
|
43
|
+
def __init__(self, path: str, handler: Callable):
|
|
44
|
+
import re
|
|
45
|
+
self.path = path
|
|
46
|
+
self.handler = handler
|
|
47
|
+
regex = re.sub(r"<(\w+)>", r"(?P<\1>[^/]+)", path)
|
|
48
|
+
self.regex = re.compile(f"^{regex}$")
|
|
49
|
+
|
|
50
|
+
def match(self, path: str) -> dict | None:
|
|
51
|
+
m = self.regex.match(path)
|
|
52
|
+
return m.groupdict() if m else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class WebSocketRouter:
|
|
56
|
+
def __init__(self):
|
|
57
|
+
self._routes: list[WebSocketRoute] = []
|
|
58
|
+
|
|
59
|
+
def add(self, path: str, handler: Callable):
|
|
60
|
+
self._routes.append(WebSocketRoute(path, handler))
|
|
61
|
+
|
|
62
|
+
def resolve(self, path: str) -> tuple[Callable | None, dict]:
|
|
63
|
+
for route in self._routes:
|
|
64
|
+
params = route.match(path)
|
|
65
|
+
if params is not None:
|
|
66
|
+
return route.handler, params
|
|
67
|
+
return None, {}
|
piu/wrappers.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from http.cookies import SimpleCookie
|
|
3
|
+
from typing import Any
|
|
4
|
+
from urllib.parse import parse_qs
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Request:
|
|
8
|
+
def __init__(self, method: str, path: str, headers: dict,
|
|
9
|
+
body: bytes = b"", query_params: dict = None):
|
|
10
|
+
self.method = method.upper()
|
|
11
|
+
self.path = path
|
|
12
|
+
self.headers = headers
|
|
13
|
+
self.body = body
|
|
14
|
+
self.query_params = query_params or {}
|
|
15
|
+
self._json = None
|
|
16
|
+
self._cookies = None
|
|
17
|
+
|
|
18
|
+
def json(self) -> Any:
|
|
19
|
+
if self._json is None:
|
|
20
|
+
self._json = json.loads(self.body.decode("utf-8"))
|
|
21
|
+
return self._json
|
|
22
|
+
|
|
23
|
+
def form(self) -> dict:
|
|
24
|
+
return parse_qs(self.body.decode("utf-8"))
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def cookies(self) -> dict:
|
|
28
|
+
if self._cookies is None:
|
|
29
|
+
raw = self.headers.get("Cookie", self.headers.get("cookie", ""))
|
|
30
|
+
sc = SimpleCookie()
|
|
31
|
+
sc.load(raw)
|
|
32
|
+
self._cookies = {k: v.value for k, v in sc.items()}
|
|
33
|
+
return self._cookies
|
|
34
|
+
|
|
35
|
+
def __repr__(self):
|
|
36
|
+
return f"<Request {self.method} {self.path}>"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Response:
|
|
40
|
+
def __init__(self, body: Any = "", status: int = 200,
|
|
41
|
+
content_type: str = "text/html", headers: dict = None):
|
|
42
|
+
self.status = status
|
|
43
|
+
self.headers = headers or {}
|
|
44
|
+
self.content_type = content_type
|
|
45
|
+
self._cookies = SimpleCookie()
|
|
46
|
+
|
|
47
|
+
if isinstance(body, (dict, list)):
|
|
48
|
+
self.body = json.dumps(body).encode("utf-8")
|
|
49
|
+
self.content_type = "application/json"
|
|
50
|
+
elif isinstance(body, str):
|
|
51
|
+
self.body = body.encode("utf-8")
|
|
52
|
+
elif isinstance(body, bytes):
|
|
53
|
+
self.body = body
|
|
54
|
+
else:
|
|
55
|
+
self.body = str(body).encode("utf-8")
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def json(cls, data: Any, status: int = 200) -> "Response":
|
|
59
|
+
return cls(body=data, status=status, content_type="application/json")
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def redirect(cls, location: str, status: int = 302) -> "Response":
|
|
63
|
+
r = cls(body="", status=status)
|
|
64
|
+
r.headers["Location"] = location
|
|
65
|
+
return r
|
|
66
|
+
|
|
67
|
+
def set_cookie(self, key: str, value: str, max_age: int = None,
|
|
68
|
+
path: str = "/", httponly: bool = True,
|
|
69
|
+
secure: bool = False, samesite: str = "Lax"):
|
|
70
|
+
self._cookies[key] = value
|
|
71
|
+
m = self._cookies[key]
|
|
72
|
+
if max_age is not None:
|
|
73
|
+
m["max-age"] = max_age
|
|
74
|
+
m["path"] = path
|
|
75
|
+
m["httponly"] = httponly
|
|
76
|
+
m["secure"] = secure
|
|
77
|
+
m["samesite"] = samesite
|
|
78
|
+
|
|
79
|
+
def delete_cookie(self, key: str, path: str = "/"):
|
|
80
|
+
self.set_cookie(key, "", max_age=0, path=path)
|
|
81
|
+
|
|
82
|
+
def _cookie_headers(self) -> list[tuple]:
|
|
83
|
+
return [("Set-Cookie", m.OutputString()) for m in self._cookies.values()]
|
|
84
|
+
|
|
85
|
+
def __repr__(self):
|
|
86
|
+
return f"<Response {self.status}>"
|