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/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()