bustapi 0.1.0__cp311-cp311-manylinux_2_34_x86_64.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.

Potentially problematic release.


This version of bustapi might be problematic. Click here for more details.

bustapi/response.py ADDED
@@ -0,0 +1,406 @@
1
+ """
2
+ Response handling for BustAPI - Flask-compatible response objects
3
+ """
4
+
5
+ import json
6
+ from http import HTTPStatus
7
+ from typing import Any, Dict, Iterable, Optional, Union
8
+
9
+ from werkzeug.datastructures import Headers
10
+
11
+ ResponseType = Union[str, bytes, dict, list, tuple, "Response"]
12
+
13
+
14
+ class Response:
15
+ """
16
+ Flask-compatible response object.
17
+
18
+ This class represents an HTTP response and provides methods to set
19
+ response data, status codes, and headers in a Flask-compatible way.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ response: Any = None,
25
+ status: Optional[int] = None,
26
+ headers: Optional[Union[Dict, Headers]] = None,
27
+ mimetype: Optional[str] = None,
28
+ content_type: Optional[str] = None,
29
+ ):
30
+ """
31
+ Initialize response object.
32
+
33
+ Args:
34
+ response: Response data (string, bytes, dict, etc.)
35
+ status: HTTP status code
36
+ headers: Response headers
37
+ mimetype: MIME type
38
+ content_type: Content type header
39
+ """
40
+ self.status_code = status or 200
41
+ self.headers = Headers(headers) if headers else Headers()
42
+
43
+ # Set response data
44
+ if response is not None:
45
+ self.set_data(response)
46
+ else:
47
+ self.data = b""
48
+
49
+ # Set content type
50
+ if content_type:
51
+ self.content_type = content_type
52
+ elif mimetype:
53
+ self.content_type = mimetype
54
+ elif not self.content_type:
55
+ self.content_type = "text/html; charset=utf-8"
56
+
57
+ @property
58
+ def status(self) -> str:
59
+ """Status code and reason phrase."""
60
+ try:
61
+ status_obj = HTTPStatus(self.status_code)
62
+ return f"{self.status_code} {status_obj.phrase}"
63
+ except ValueError:
64
+ return str(self.status_code)
65
+
66
+ @status.setter
67
+ def status(self, value: Union[str, int]) -> None:
68
+ """Set status code."""
69
+ if isinstance(value, str):
70
+ # Parse "200 OK" format
71
+ self.status_code = int(value.split()[0])
72
+ else:
73
+ self.status_code = value
74
+
75
+ @property
76
+ def content_type(self) -> str:
77
+ """Content type header."""
78
+ return self.headers.get("Content-Type", "")
79
+
80
+ @content_type.setter
81
+ def content_type(self, value: str) -> None:
82
+ """Set content type header."""
83
+ self.headers["Content-Type"] = value
84
+
85
+ def set_data(self, data: Any) -> None:
86
+ """
87
+ Set response data.
88
+
89
+ Args:
90
+ data: Response data to set
91
+ """
92
+ if isinstance(data, str):
93
+ self.data = data.encode("utf-8")
94
+ if not self.content_type:
95
+ self.content_type = "text/html; charset=utf-8"
96
+ elif isinstance(data, bytes):
97
+ self.data = data
98
+ elif isinstance(data, (dict, list)):
99
+ # Serialize as JSON
100
+ self.data = json.dumps(data).encode("utf-8")
101
+ self.content_type = "application/json"
102
+ else:
103
+ # Convert to string and encode
104
+ self.data = str(data).encode("utf-8")
105
+ if not self.content_type:
106
+ self.content_type = "text/html; charset=utf-8"
107
+
108
+ def get_data(self, as_text: bool = False) -> Union[bytes, str]:
109
+ """
110
+ Get response data.
111
+
112
+ Args:
113
+ as_text: Return as text instead of bytes
114
+
115
+ Returns:
116
+ Response data as bytes or text
117
+ """
118
+ if as_text:
119
+ return self.data.decode("utf-8", errors="replace")
120
+ return self.data
121
+
122
+ @property
123
+ def response(self) -> Iterable[bytes]:
124
+ """Response data as iterable of bytes."""
125
+ return [self.data]
126
+
127
+ def set_cookie(
128
+ self,
129
+ key: str,
130
+ value: str = "",
131
+ max_age: Optional[int] = None,
132
+ expires: Optional[str] = None,
133
+ path: str = "/",
134
+ domain: Optional[str] = None,
135
+ secure: bool = False,
136
+ httponly: bool = False,
137
+ samesite: Optional[str] = None,
138
+ ):
139
+ """
140
+ Set a cookie.
141
+
142
+ Args:
143
+ key: Cookie name
144
+ value: Cookie value
145
+ max_age: Maximum age in seconds
146
+ expires: Expiration date
147
+ path: Cookie path
148
+ domain: Cookie domain
149
+ secure: Secure flag
150
+ httponly: HttpOnly flag
151
+ samesite: SameSite attribute
152
+ """
153
+ cookie_parts = [f"{key}={value}"]
154
+
155
+ if max_age is not None:
156
+ cookie_parts.append(f"Max-Age={max_age}")
157
+ if expires:
158
+ cookie_parts.append(f"Expires={expires}")
159
+ if path:
160
+ cookie_parts.append(f"Path={path}")
161
+ if domain:
162
+ cookie_parts.append(f"Domain={domain}")
163
+ if secure:
164
+ cookie_parts.append("Secure")
165
+ if httponly:
166
+ cookie_parts.append("HttpOnly")
167
+ if samesite:
168
+ cookie_parts.append(f"SameSite={samesite}")
169
+
170
+ cookie_string = "; ".join(cookie_parts)
171
+
172
+ # Add to existing Set-Cookie headers
173
+ if "Set-Cookie" in self.headers:
174
+ existing = self.headers.getlist("Set-Cookie")
175
+ existing.append(cookie_string)
176
+ self.headers.setlist("Set-Cookie", existing)
177
+ else:
178
+ self.headers["Set-Cookie"] = cookie_string
179
+
180
+ def delete_cookie(self, key: str, path: str = "/", domain: Optional[str] = None):
181
+ """
182
+ Delete a cookie by setting it to expire.
183
+
184
+ Args:
185
+ key: Cookie name
186
+ path: Cookie path
187
+ domain: Cookie domain
188
+ """
189
+ self.set_cookie(
190
+ key, "", expires="Thu, 01 Jan 1970 00:00:00 GMT", path=path, domain=domain
191
+ )
192
+
193
+ def __repr__(self) -> str:
194
+ return f"<Response {self.status_code} [{self.content_type}]>"
195
+
196
+
197
+ def make_response(*args) -> Response:
198
+ """
199
+ Create a Response object from various input types (Flask-compatible).
200
+
201
+ Args:
202
+ *args: Response arguments - can be:
203
+ - (response,)
204
+ - (response, status)
205
+ - (response, headers)
206
+ - (response, status, headers)
207
+
208
+ Returns:
209
+ Response object
210
+ """
211
+ if not args:
212
+ return Response()
213
+
214
+ if len(args) == 1:
215
+ rv = args[0]
216
+ if isinstance(rv, Response):
217
+ return rv
218
+ return Response(rv)
219
+
220
+ if len(args) == 2:
221
+ rv, status_or_headers = args
222
+ if isinstance(status_or_headers, (int, str)):
223
+ # (response, status)
224
+ return Response(rv, status=status_or_headers)
225
+ else:
226
+ # (response, headers)
227
+ return Response(rv, headers=status_or_headers)
228
+
229
+ if len(args) == 3:
230
+ # (response, status, headers)
231
+ rv, status, headers = args
232
+ return Response(rv, status=status, headers=headers)
233
+
234
+ raise TypeError(f"make_response() takes 1 to 3 arguments ({len(args)} given)")
235
+
236
+
237
+ def jsonify(*args, **kwargs) -> Response:
238
+ """
239
+ Create a JSON response (Flask-compatible).
240
+
241
+ Args:
242
+ *args: Positional arguments for JSON data
243
+ **kwargs: Keyword arguments for JSON data
244
+
245
+ Returns:
246
+ Response object with JSON data
247
+
248
+ Examples:
249
+ jsonify({'key': 'value'})
250
+ jsonify(key='value')
251
+ jsonify([1, 2, 3])
252
+ """
253
+ if args and kwargs:
254
+ raise TypeError("jsonify() behavior with mixed arguments is deprecated")
255
+
256
+ if args:
257
+ if len(args) == 1:
258
+ data = args[0]
259
+ else:
260
+ data = args
261
+ else:
262
+ data = kwargs
263
+
264
+ response = Response()
265
+ response.set_data(data)
266
+ response.content_type = "application/json"
267
+
268
+ return response
269
+
270
+
271
+ # HTTP status code helpers
272
+ def abort(code: int, description: Optional[str] = None, **kwargs):
273
+ """
274
+ Abort request with HTTP error code.
275
+
276
+ Args:
277
+ code: HTTP status code
278
+ description: Error description
279
+ **kwargs: Additional arguments
280
+
281
+ Raises:
282
+ HTTPException: HTTP exception with specified code
283
+ """
284
+ from .exceptions import HTTPException
285
+
286
+ raise HTTPException(code, description=description)
287
+
288
+
289
+ class HTTPException(Exception):
290
+ """HTTP exception for error responses."""
291
+
292
+ def __init__(self, code: int, description: Optional[str] = None):
293
+ self.code = code
294
+ self.description = description or self._get_default_description(code)
295
+ super().__init__(self.description)
296
+
297
+ def _get_default_description(self, code: int) -> str:
298
+ """Get default description for HTTP status code."""
299
+ try:
300
+ return HTTPStatus(code).phrase
301
+ except ValueError:
302
+ return f"HTTP {code}"
303
+
304
+ def get_response(self) -> Response:
305
+ """Get response object for this exception."""
306
+ return Response(self.description, status=self.code)
307
+
308
+
309
+ # Common HTTP exceptions
310
+ class BadRequest(HTTPException):
311
+ def __init__(self, description: Optional[str] = None):
312
+ super().__init__(400, description)
313
+
314
+
315
+ class Unauthorized(HTTPException):
316
+ def __init__(self, description: Optional[str] = None):
317
+ super().__init__(401, description)
318
+
319
+
320
+ class Forbidden(HTTPException):
321
+ def __init__(self, description: Optional[str] = None):
322
+ super().__init__(403, description)
323
+
324
+
325
+ class NotFound(HTTPException):
326
+ def __init__(self, description: Optional[str] = None):
327
+ super().__init__(404, description)
328
+
329
+
330
+ class MethodNotAllowed(HTTPException):
331
+ def __init__(self, description: Optional[str] = None):
332
+ super().__init__(405, description)
333
+
334
+
335
+ class InternalServerError(HTTPException):
336
+ def __init__(self, description: Optional[str] = None):
337
+ super().__init__(500, description)
338
+
339
+
340
+ # Redirect response
341
+ def redirect(location: str, code: int = 302, Response: type = Response) -> Response:
342
+ """
343
+ Create a redirect response.
344
+
345
+ Args:
346
+ location: Redirect URL
347
+ code: HTTP status code (301, 302, etc.)
348
+ Response: Response class to use
349
+
350
+ Returns:
351
+ Redirect response
352
+ """
353
+ response = Response("", status=code)
354
+ response.headers["Location"] = location
355
+ return response
356
+
357
+
358
+ # Static response helpers
359
+ def send_file(
360
+ file_path: str,
361
+ mimetype: Optional[str] = None,
362
+ as_attachment: bool = False,
363
+ attachment_filename: Optional[str] = None,
364
+ ):
365
+ """
366
+ Send a file as response (placeholder implementation).
367
+
368
+ Args:
369
+ file_path: Path to file
370
+ mimetype: MIME type
371
+ as_attachment: Send as attachment
372
+ attachment_filename: Attachment filename
373
+
374
+ Returns:
375
+ Response object
376
+
377
+ Note:
378
+ This is a placeholder implementation. Full file serving
379
+ should be implemented in the Rust backend for performance.
380
+ """
381
+ try:
382
+ with open(file_path, "rb") as f:
383
+ data = f.read()
384
+
385
+ response = Response(data)
386
+
387
+ if mimetype:
388
+ response.content_type = mimetype
389
+ else:
390
+ # Try to guess content type based on file extension
391
+ import mimetypes
392
+
393
+ guessed_type, _ = mimetypes.guess_type(file_path)
394
+ if guessed_type:
395
+ response.content_type = guessed_type
396
+
397
+ if as_attachment:
398
+ filename = attachment_filename or file_path.split("/")[-1]
399
+ response.headers["Content-Disposition"] = f"attachment; filename={filename}"
400
+
401
+ return response
402
+
403
+ except FileNotFoundError:
404
+ abort(404, description="File not found")
405
+ except PermissionError:
406
+ abort(403, description="Permission denied")