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/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}>"