lenapi 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.
lenapi/request.py ADDED
@@ -0,0 +1,177 @@
1
+ """Convenient access to an incoming ASGI request."""
2
+
3
+ import asyncio
4
+ import json
5
+ from email import policy
6
+ from email.parser import BytesParser
7
+ from http.cookies import SimpleCookie
8
+ from pathlib import Path
9
+ import time
10
+ import uuid
11
+ from urllib.parse import parse_qs
12
+
13
+ from .response import HTTPException
14
+
15
+
16
+ class UploadFile:
17
+ """An uploaded multipart file held in memory for a bounded request."""
18
+
19
+ def __init__(self, filename: str, content_type: str, data: bytes) -> None:
20
+ self.filename = Path(filename).name
21
+ self.content_type = content_type
22
+ self.size = len(data)
23
+ self._data = data
24
+
25
+ async def read(self) -> bytes:
26
+ return self._data
27
+
28
+ async def save_image(self, directory: str | Path, *, filename: str | None = None) -> str:
29
+ """Validate common image signatures and save safely; return the filename."""
30
+ image_types = (
31
+ (self._data.startswith(b"\xff\xd8\xff"), ".jpg", {"image/jpeg", "image/jpg"}),
32
+ (self._data.startswith(b"\x89PNG\r\n\x1a\n"), ".png", {"image/png"}),
33
+ (self._data.startswith((b"GIF87a", b"GIF89a")), ".gif", {"image/gif"}),
34
+ (self._data.startswith(b"RIFF") and self._data[8:12] == b"WEBP", ".webp", {"image/webp"}),
35
+ (self._data.startswith(b"BM"), ".bmp", {"image/bmp", "image/x-ms-bmp"}),
36
+ )
37
+ detected = next((extension for matches, extension, _types in image_types if matches), None)
38
+ allowed_types = next((types for matches, _extension, types in image_types if matches), set())
39
+ if detected is None or self.content_type.lower() not in allowed_types:
40
+ raise ValueError("Uploaded file is not a supported image or its content type does not match")
41
+ if filename:
42
+ requested = Path(filename).name
43
+ suffix = Path(requested).suffix.lower()
44
+ valid_suffixes = {".jpeg", ".jpg"} if detected == ".jpg" else {detected}
45
+ if suffix not in valid_suffixes:
46
+ raise ValueError("Image filename extension does not match its image content")
47
+ safe_name = requested
48
+ else:
49
+ safe_name = f"{uuid.uuid4().hex}{detected}"
50
+ if not safe_name or safe_name in {".", ".."}:
51
+ raise ValueError("Invalid image filename")
52
+ root = Path(directory).expanduser().resolve()
53
+ root.mkdir(parents=True, exist_ok=True)
54
+ destination = (root / safe_name).resolve()
55
+ if not destination.is_relative_to(root):
56
+ raise ValueError("Image destination must stay inside its upload directory")
57
+ await asyncio.to_thread(destination.write_bytes, self._data)
58
+ return safe_name
59
+
60
+
61
+ class Request:
62
+ def __init__(self, scope: dict, receive, *, max_body_size: int | None = 10 * 1024 * 1024,
63
+ body_timeout: float | None = 30.0) -> None:
64
+ self.scope = scope
65
+ self._receive = receive
66
+ self.max_body_size = max_body_size
67
+ self.body_timeout = body_timeout
68
+ self._body: bytes | None = None
69
+
70
+ @property
71
+ def method(self) -> str:
72
+ return self.scope["method"]
73
+
74
+ @property
75
+ def path(self) -> str:
76
+ return self.scope["path"]
77
+
78
+ @property
79
+ def client_ip(self) -> str | None:
80
+ """Client address from the ASGI connection; proxy headers are not trusted."""
81
+ client = self.scope.get("client")
82
+ return client[0] if client else None
83
+
84
+ @property
85
+ def path_params(self) -> dict[str, object]:
86
+ return self.scope.get("path_params", {})
87
+
88
+ @property
89
+ def headers(self) -> dict[str, str]:
90
+ """Request headers keyed by lowercase names."""
91
+ return {
92
+ name.decode("latin-1").lower(): value.decode("latin-1")
93
+ for name, value in self.scope.get("headers", [])
94
+ }
95
+
96
+ @property
97
+ def cookies(self) -> dict[str, str]:
98
+ """Incoming cookies parsed from the Cookie request header."""
99
+ cookie = SimpleCookie()
100
+ cookie.load(self.headers.get("cookie", ""))
101
+ return {name: morsel.value for name, morsel in cookie.items()}
102
+
103
+ @property
104
+ def query_params(self) -> dict[str, str | list[str]]:
105
+ parsed = parse_qs(self.scope.get("query_string", b"").decode("latin-1"), keep_blank_values=True)
106
+ return {key: values[0] if len(values) == 1 else values for key, values in parsed.items()}
107
+
108
+ async def body(self) -> bytes:
109
+ if self._body is None:
110
+ chunks = []
111
+ size = 0
112
+ deadline = time.monotonic() + self.body_timeout if self.body_timeout is not None else None
113
+ while True:
114
+ try:
115
+ if deadline is None:
116
+ message = await self._receive()
117
+ else:
118
+ remaining = deadline - time.monotonic()
119
+ if remaining <= 0:
120
+ raise asyncio.TimeoutError
121
+ message = await asyncio.wait_for(self._receive(), timeout=remaining)
122
+ except asyncio.TimeoutError as exc:
123
+ raise HTTPException(408, "Request body timed out") from exc
124
+ if message["type"] == "http.disconnect":
125
+ break
126
+ chunk = message.get("body", b"")
127
+ size += len(chunk)
128
+ if self.max_body_size is not None and size > self.max_body_size:
129
+ raise HTTPException(413, "Request body too large")
130
+ chunks.append(chunk)
131
+ if not message.get("more_body", False):
132
+ break
133
+ self._body = b"".join(chunks)
134
+ return self._body
135
+
136
+ async def json(self):
137
+ return json.loads((await self.body()).decode("utf-8"))
138
+
139
+ async def form(self) -> dict[str, str | UploadFile | list[str | UploadFile]]:
140
+ """Parse URL-encoded or multipart form data, including file uploads."""
141
+ content_type = self.headers.get("content-type", "")
142
+ if content_type.lower().startswith("application/x-www-form-urlencoded"):
143
+ parsed = parse_qs((await self.body()).decode("utf-8"), keep_blank_values=True)
144
+ return {key: values[0] if len(values) == 1 else values for key, values in parsed.items()}
145
+ if not content_type.lower().startswith("multipart/form-data"):
146
+ raise HTTPException(415, "Expected form data")
147
+ raw = await self.body()
148
+ message = BytesParser(policy=policy.default).parsebytes(
149
+ f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode("latin-1") + raw
150
+ )
151
+ if not message.is_multipart():
152
+ raise HTTPException(400, "Invalid multipart form data")
153
+ values: dict[str, str | UploadFile | list[str | UploadFile]] = {}
154
+ for part in message.iter_parts():
155
+ name = part.get_param("name", header="content-disposition")
156
+ if not name:
157
+ continue
158
+ data = part.get_payload(decode=True) or b""
159
+ filename = part.get_filename()
160
+ value: str | UploadFile
161
+ if filename is not None:
162
+ value = UploadFile(filename, part.get_content_type(), data)
163
+ else:
164
+ charset = part.get_content_charset() or "utf-8"
165
+ try:
166
+ value = data.decode(charset)
167
+ except (LookupError, UnicodeDecodeError) as exc:
168
+ raise HTTPException(400, "Invalid form field encoding") from exc
169
+ if name in values:
170
+ existing = values[name]
171
+ if not isinstance(existing, list):
172
+ values[name] = [existing, value]
173
+ else:
174
+ existing.append(value)
175
+ else:
176
+ values[name] = value
177
+ return values
lenapi/response.py ADDED
@@ -0,0 +1,91 @@
1
+ """HTTP response types."""
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from http.cookies import SimpleCookie
6
+ from typing import Any
7
+ import uuid
8
+
9
+
10
+ def _json_default(value: Any):
11
+ if isinstance(value, uuid.UUID):
12
+ return str(value)
13
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
14
+
15
+
16
+ class Response:
17
+ def __init__(
18
+ self,
19
+ content: bytes | str = b"",
20
+ *,
21
+ status_code: int = 200,
22
+ headers: Mapping[str, str] | None = None,
23
+ media_type: str = "text/plain; charset=utf-8",
24
+ ) -> None:
25
+ self.body = content.encode("utf-8") if isinstance(content, str) else content
26
+ self.status_code = status_code
27
+ self.headers = {"content-type": media_type, **(headers or {})}
28
+ self._cookies: list[str] = []
29
+
30
+ def set_cookie(
31
+ self,
32
+ key: str,
33
+ value: str,
34
+ *,
35
+ max_age: int | None = None,
36
+ expires: str | None = None,
37
+ path: str = "/",
38
+ domain: str | None = None,
39
+ secure: bool = False,
40
+ httponly: bool = False,
41
+ samesite: str | None = "lax",
42
+ ) -> None:
43
+ """Add a Set-Cookie header to this response."""
44
+ if not key or any(char in key for char in "=;, \t\r\n"):
45
+ raise ValueError("Invalid cookie name")
46
+ if samesite is not None and samesite.lower() not in {"lax", "strict", "none"}:
47
+ raise ValueError("samesite must be 'lax', 'strict', 'none', or None")
48
+ cookie = SimpleCookie()
49
+ cookie[key] = value
50
+ morsel = cookie[key]
51
+ morsel["path"] = path
52
+ if domain is not None:
53
+ morsel["domain"] = domain
54
+ if max_age is not None:
55
+ morsel["max-age"] = str(max_age)
56
+ if expires is not None:
57
+ morsel["expires"] = expires
58
+ if secure:
59
+ morsel["secure"] = True
60
+ if httponly:
61
+ morsel["httponly"] = True
62
+ if samesite is not None:
63
+ morsel["samesite"] = samesite.capitalize()
64
+ self._cookies.append(morsel.OutputString())
65
+
66
+ def delete_cookie(self, key: str, *, path: str = "/", domain: str | None = None) -> None:
67
+ """Expire a cookie by adding a matching deletion cookie."""
68
+ self.set_cookie(key, "", max_age=0, expires="Thu, 01 Jan 1970 00:00:00 GMT", path=path, domain=domain)
69
+
70
+ async def __call__(self, scope: dict, receive, send) -> None:
71
+ headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in self.headers.items()]
72
+ headers.extend((b"set-cookie", value.encode("latin-1")) for value in self._cookies)
73
+ headers.append((b"content-length", str(len(self.body)).encode("ascii")))
74
+ await send({"type": "http.response.start", "status": self.status_code, "headers": headers})
75
+ await send({"type": "http.response.body", "body": self.body})
76
+
77
+
78
+ class JSONResponse(Response):
79
+ def __init__(self, content: Any, *, status_code: int = 200, headers: Mapping[str, str] | None = None) -> None:
80
+ body = json.dumps(content, ensure_ascii=False, separators=(",", ":"), default=_json_default).encode("utf-8")
81
+ super().__init__(body, status_code=status_code, headers=headers, media_type="application/json")
82
+
83
+
84
+ class HTTPException(Exception):
85
+ """Raise an HTTP error from a route or framework helper."""
86
+
87
+ def __init__(self, status_code: int, detail: str, *, headers: Mapping[str, str] | None = None) -> None:
88
+ super().__init__(detail)
89
+ self.status_code = status_code
90
+ self.detail = detail
91
+ self.headers = headers or {}