django-bolt 0.1.0__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_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 django-bolt might be problematic. Click here for more details.

Files changed (128) hide show
  1. django_bolt/__init__.py +147 -0
  2. django_bolt/_core.abi3.so +0 -0
  3. django_bolt/admin/__init__.py +25 -0
  4. django_bolt/admin/admin_detection.py +179 -0
  5. django_bolt/admin/asgi_bridge.py +267 -0
  6. django_bolt/admin/routes.py +91 -0
  7. django_bolt/admin/static.py +155 -0
  8. django_bolt/admin/static_routes.py +111 -0
  9. django_bolt/api.py +1011 -0
  10. django_bolt/apps.py +7 -0
  11. django_bolt/async_collector.py +228 -0
  12. django_bolt/auth/README.md +464 -0
  13. django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
  14. django_bolt/auth/__init__.py +84 -0
  15. django_bolt/auth/backends.py +236 -0
  16. django_bolt/auth/guards.py +224 -0
  17. django_bolt/auth/jwt_utils.py +212 -0
  18. django_bolt/auth/revocation.py +286 -0
  19. django_bolt/auth/token.py +335 -0
  20. django_bolt/binding.py +363 -0
  21. django_bolt/bootstrap.py +77 -0
  22. django_bolt/cli.py +133 -0
  23. django_bolt/compression.py +104 -0
  24. django_bolt/decorators.py +159 -0
  25. django_bolt/dependencies.py +128 -0
  26. django_bolt/error_handlers.py +305 -0
  27. django_bolt/exceptions.py +294 -0
  28. django_bolt/health.py +129 -0
  29. django_bolt/logging/__init__.py +6 -0
  30. django_bolt/logging/config.py +357 -0
  31. django_bolt/logging/middleware.py +296 -0
  32. django_bolt/management/__init__.py +1 -0
  33. django_bolt/management/commands/__init__.py +0 -0
  34. django_bolt/management/commands/runbolt.py +427 -0
  35. django_bolt/middleware/__init__.py +32 -0
  36. django_bolt/middleware/compiler.py +131 -0
  37. django_bolt/middleware/middleware.py +247 -0
  38. django_bolt/openapi/__init__.py +23 -0
  39. django_bolt/openapi/config.py +196 -0
  40. django_bolt/openapi/plugins.py +439 -0
  41. django_bolt/openapi/routes.py +152 -0
  42. django_bolt/openapi/schema_generator.py +581 -0
  43. django_bolt/openapi/spec/__init__.py +68 -0
  44. django_bolt/openapi/spec/base.py +74 -0
  45. django_bolt/openapi/spec/callback.py +24 -0
  46. django_bolt/openapi/spec/components.py +72 -0
  47. django_bolt/openapi/spec/contact.py +21 -0
  48. django_bolt/openapi/spec/discriminator.py +25 -0
  49. django_bolt/openapi/spec/encoding.py +67 -0
  50. django_bolt/openapi/spec/enums.py +41 -0
  51. django_bolt/openapi/spec/example.py +36 -0
  52. django_bolt/openapi/spec/external_documentation.py +21 -0
  53. django_bolt/openapi/spec/header.py +132 -0
  54. django_bolt/openapi/spec/info.py +50 -0
  55. django_bolt/openapi/spec/license.py +28 -0
  56. django_bolt/openapi/spec/link.py +66 -0
  57. django_bolt/openapi/spec/media_type.py +51 -0
  58. django_bolt/openapi/spec/oauth_flow.py +36 -0
  59. django_bolt/openapi/spec/oauth_flows.py +28 -0
  60. django_bolt/openapi/spec/open_api.py +87 -0
  61. django_bolt/openapi/spec/operation.py +105 -0
  62. django_bolt/openapi/spec/parameter.py +147 -0
  63. django_bolt/openapi/spec/path_item.py +78 -0
  64. django_bolt/openapi/spec/paths.py +27 -0
  65. django_bolt/openapi/spec/reference.py +38 -0
  66. django_bolt/openapi/spec/request_body.py +38 -0
  67. django_bolt/openapi/spec/response.py +48 -0
  68. django_bolt/openapi/spec/responses.py +44 -0
  69. django_bolt/openapi/spec/schema.py +678 -0
  70. django_bolt/openapi/spec/security_requirement.py +28 -0
  71. django_bolt/openapi/spec/security_scheme.py +69 -0
  72. django_bolt/openapi/spec/server.py +34 -0
  73. django_bolt/openapi/spec/server_variable.py +32 -0
  74. django_bolt/openapi/spec/tag.py +32 -0
  75. django_bolt/openapi/spec/xml.py +44 -0
  76. django_bolt/pagination.py +669 -0
  77. django_bolt/param_functions.py +49 -0
  78. django_bolt/params.py +337 -0
  79. django_bolt/request_parsing.py +128 -0
  80. django_bolt/responses.py +214 -0
  81. django_bolt/router.py +48 -0
  82. django_bolt/serialization.py +193 -0
  83. django_bolt/status_codes.py +321 -0
  84. django_bolt/testing/__init__.py +10 -0
  85. django_bolt/testing/client.py +274 -0
  86. django_bolt/testing/helpers.py +93 -0
  87. django_bolt/tests/__init__.py +0 -0
  88. django_bolt/tests/admin_tests/__init__.py +1 -0
  89. django_bolt/tests/admin_tests/conftest.py +6 -0
  90. django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
  91. django_bolt/tests/admin_tests/urls.py +9 -0
  92. django_bolt/tests/cbv/__init__.py +0 -0
  93. django_bolt/tests/cbv/test_class_views.py +570 -0
  94. django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
  95. django_bolt/tests/cbv/test_class_views_features.py +1173 -0
  96. django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
  97. django_bolt/tests/conftest.py +165 -0
  98. django_bolt/tests/test_action_decorator.py +399 -0
  99. django_bolt/tests/test_auth_secret_key.py +83 -0
  100. django_bolt/tests/test_decorator_syntax.py +159 -0
  101. django_bolt/tests/test_error_handling.py +481 -0
  102. django_bolt/tests/test_file_response.py +192 -0
  103. django_bolt/tests/test_global_cors.py +172 -0
  104. django_bolt/tests/test_guards_auth.py +441 -0
  105. django_bolt/tests/test_guards_integration.py +303 -0
  106. django_bolt/tests/test_health.py +283 -0
  107. django_bolt/tests/test_integration_validation.py +400 -0
  108. django_bolt/tests/test_json_validation.py +536 -0
  109. django_bolt/tests/test_jwt_auth.py +327 -0
  110. django_bolt/tests/test_jwt_token.py +458 -0
  111. django_bolt/tests/test_logging.py +837 -0
  112. django_bolt/tests/test_logging_merge.py +419 -0
  113. django_bolt/tests/test_middleware.py +492 -0
  114. django_bolt/tests/test_middleware_server.py +230 -0
  115. django_bolt/tests/test_model_viewset.py +323 -0
  116. django_bolt/tests/test_models.py +24 -0
  117. django_bolt/tests/test_pagination.py +1258 -0
  118. django_bolt/tests/test_parameter_validation.py +178 -0
  119. django_bolt/tests/test_syntax.py +626 -0
  120. django_bolt/tests/test_testing_utilities.py +163 -0
  121. django_bolt/tests/test_testing_utilities_simple.py +123 -0
  122. django_bolt/tests/test_viewset_unified.py +346 -0
  123. django_bolt/typing.py +273 -0
  124. django_bolt/views.py +1110 -0
  125. django_bolt-0.1.0.dist-info/METADATA +629 -0
  126. django_bolt-0.1.0.dist-info/RECORD +128 -0
  127. django_bolt-0.1.0.dist-info/WHEEL +4 -0
  128. django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,626 @@
1
+ import asyncio
2
+ import os
3
+ import json
4
+
5
+ import msgspec
6
+ import pytest
7
+
8
+ from django_bolt import BoltAPI, JSON, StreamingResponse
9
+ from django_bolt.param_functions import Query, Path, Header, Cookie, Depends, Form, File as FileParam
10
+ from django_bolt.responses import PlainText, HTML, Redirect, File, FileResponse
11
+ from django_bolt.exceptions import HTTPException
12
+ from django_bolt.testing import TestClient
13
+ from typing import Annotated
14
+
15
+
16
+ @pytest.fixture(scope="module")
17
+ def api():
18
+ """Create the test API with all routes"""
19
+ api = BoltAPI()
20
+
21
+ class Item(msgspec.Struct):
22
+ name: str
23
+ price: float
24
+ is_offer: bool | None = None
25
+
26
+ @api.get("/")
27
+ async def root():
28
+ return {"ok": True}
29
+
30
+ @api.get("/items/{item_id}")
31
+ async def get_item(item_id: int, q: str | None = None):
32
+ return {"item_id": item_id, "q": q}
33
+
34
+ @api.get("/types")
35
+ async def get_types(b: bool | None = None, f: float | None = None):
36
+ return {"b": b, "f": f}
37
+
38
+ @api.put("/items/{item_id}")
39
+ async def put_item(item_id: int, item: Item):
40
+ return {"item_id": item_id, "item_name": item.name, "is_offer": item.is_offer}
41
+
42
+ @api.get("/str")
43
+ async def ret_str():
44
+ return "hello"
45
+
46
+ @api.get("/bytes")
47
+ async def ret_bytes():
48
+ return b"abc"
49
+
50
+ @api.get("/json")
51
+ async def ret_json():
52
+ return JSON({"x": 1}, status_code=201, headers={"X-Test": "1"})
53
+
54
+ @api.get("/req/{x}")
55
+ async def req_only(req):
56
+ return {"p": req["params"].get("x"), "q": req["query"].get("y")}
57
+
58
+ @api.post("/m")
59
+ async def post_m():
60
+ return {"m": "post"}
61
+
62
+ @api.patch("/m")
63
+ async def patch_m():
64
+ return {"m": "patch"}
65
+
66
+ @api.delete("/m")
67
+ async def delete_m():
68
+ return {"m": "delete"}
69
+
70
+ @api.head("/m")
71
+ async def head_m():
72
+ return {"m": "head"}
73
+
74
+ # Test HEAD with query params (should work like GET)
75
+ @api.head("/items/{item_id}")
76
+ async def head_item(item_id: int, q: str | None = None):
77
+ return {"item_id": item_id, "q": q}
78
+
79
+ # Response coercion from objects to msgspec.Struct
80
+ class Mini(msgspec.Struct):
81
+ id: int
82
+ username: str
83
+
84
+ class Model:
85
+ def __init__(self, id: int, username: str | None):
86
+ self.id = id
87
+ self.username = username
88
+
89
+ @api.get("/coerce/mini", response_model=list[Mini])
90
+ async def coerce_mini() -> list[Mini]:
91
+ return [Model(1, "a"), Model(2, "b")]
92
+
93
+ @api.get("/coerce/mini-bad", response_model=list[Mini])
94
+ async def coerce_mini_bad() -> list[Mini]:
95
+ return [Model(1, None)]
96
+
97
+ @api.get("/ok-list", response_model=list[Item])
98
+ async def ok_list():
99
+ return [
100
+ {"name": "a", "price": 1.0, "is_offer": True},
101
+ {"name": "b", "price": 2.0, "is_offer": False},
102
+ ]
103
+
104
+ @api.get("/bad-list", response_model=list[Item])
105
+ async def bad_list():
106
+ return [{"name": "x", "is_offer": True}]
107
+
108
+ @api.get("/anno-list")
109
+ async def anno_list() -> list[Item]:
110
+ return [Item(name="c", price=3.0, is_offer=None)]
111
+
112
+ @api.get("/anno-bad")
113
+ async def anno_bad() -> list[Item]:
114
+ return [{"name": "d"}]
115
+
116
+ @api.get("/both-override", response_model=list[Item])
117
+ async def both_override() -> list[str]:
118
+ return [{"name": "o", "price": 1.0, "is_offer": False}]
119
+
120
+ @api.get("/no-validate")
121
+ async def no_validate():
122
+ return [{"anything": 1, "extra": "ok"}]
123
+
124
+ @api.get("/status-default", status_code=201)
125
+ async def status_default():
126
+ return {"ok": True}
127
+
128
+ @api.get("/headers-cookies")
129
+ async def headers_cookies(agent: str = Depends(lambda user_agent: user_agent)):
130
+ return {"ok": True}
131
+
132
+ @api.get("/header")
133
+ async def get_header(x: Annotated[str, Header(alias="x-test")]):
134
+ return PlainText(x)
135
+
136
+ @api.get("/cookie")
137
+ async def get_cookie(val: Annotated[str, Cookie(alias="session")]):
138
+ return PlainText(val)
139
+
140
+ @api.get("/exc")
141
+ async def raise_exc():
142
+ raise HTTPException(418, {"detail": "teapot"}, headers={"X-Err": "1"})
143
+
144
+ @api.get("/html")
145
+ async def get_html():
146
+ return HTML("<h1>Hi</h1>")
147
+
148
+ @api.get("/redirect")
149
+ async def get_redirect():
150
+ return Redirect("/", status_code=302)
151
+
152
+ THIS_FILE = os.path.abspath(__file__)
153
+
154
+ @api.get("/file")
155
+ async def get_file():
156
+ return File(THIS_FILE, filename="test_syntax.py")
157
+
158
+ @api.get("/fileresponse")
159
+ async def get_fileresponse():
160
+ return FileResponse(THIS_FILE, filename="test_syntax.py")
161
+
162
+ @api.get("/stream-plain")
163
+ async def stream_plain():
164
+ def gen():
165
+ for i in range(3):
166
+ yield f"p{i},"
167
+ return StreamingResponse(gen, media_type="text/plain")
168
+
169
+ @api.get("/stream-bytes")
170
+ async def stream_bytes():
171
+ def gen():
172
+ for i in range(2):
173
+ yield str(i).encode()
174
+ return StreamingResponse(gen)
175
+
176
+ @api.get("/sse")
177
+ async def stream_sse():
178
+ def gen():
179
+ yield "event: message\ndata: hello\n\n"
180
+ yield "data: 1\n\n"
181
+ yield ": comment\n\n"
182
+ return StreamingResponse(gen, media_type="text/event-stream")
183
+
184
+ @api.get("/stream-async")
185
+ async def stream_async():
186
+ async def async_gen():
187
+ for i in range(3):
188
+ await asyncio.sleep(0.001)
189
+ yield f"async-{i},".encode()
190
+ return StreamingResponse(async_gen(), media_type="text/plain")
191
+
192
+ @api.get("/stream-async-sse")
193
+ async def stream_async_sse():
194
+ async def async_gen():
195
+ yield "event: start\ndata: beginning\n\n"
196
+ await asyncio.sleep(0.001)
197
+ yield "event: message\ndata: async data\n\n"
198
+ await asyncio.sleep(0.001)
199
+ yield "event: end\ndata: finished\n\n"
200
+ return StreamingResponse(async_gen(), media_type="text/event-stream")
201
+
202
+ @api.get("/stream-async-large")
203
+ async def stream_async_large():
204
+ async def async_gen():
205
+ for i in range(10):
206
+ await asyncio.sleep(0.001)
207
+ chunk = f"chunk-{i:02d}-{'x' * 100}\n".encode()
208
+ yield chunk
209
+ return StreamingResponse(async_gen(), media_type="application/octet-stream")
210
+
211
+ @api.get("/stream-async-mixed-types")
212
+ async def stream_async_mixed_types():
213
+ async def async_gen():
214
+ yield b"bytes-chunk\n"
215
+ await asyncio.sleep(0.001)
216
+ yield "string-chunk\n"
217
+ await asyncio.sleep(0.001)
218
+ yield bytearray(b"bytearray-chunk\n")
219
+ await asyncio.sleep(0.001)
220
+ yield memoryview(b"memoryview-chunk\n")
221
+ return StreamingResponse(async_gen(), media_type="text/plain")
222
+
223
+ @api.get("/stream-async-error")
224
+ async def stream_async_error():
225
+ async def async_gen():
226
+ yield b"chunk1\n"
227
+ await asyncio.sleep(0.001)
228
+ yield b"chunk2\n"
229
+ await asyncio.sleep(0.001)
230
+ raise ValueError("Simulated async error")
231
+ return StreamingResponse(async_gen(), media_type="text/plain")
232
+
233
+ @api.post("/form-urlencoded")
234
+ async def form_urlencoded(a: Annotated[str, Form()], b: Annotated[int, Form()]):
235
+ return {"a": a, "b": b}
236
+
237
+ @api.post("/upload")
238
+ async def upload(files: Annotated[list[dict], FileParam(alias="file")]):
239
+ return {"count": len(files), "names": [f.get("filename") for f in files]}
240
+
241
+ @api.get("/sse-async-test")
242
+ async def sse_async_test():
243
+ async def agen():
244
+ for i in range(3):
245
+ yield f"data: {i}\n\n"
246
+ await asyncio.sleep(0)
247
+ return StreamingResponse(agen(), media_type="text/event-stream")
248
+
249
+ @api.post("/v1/chat/completions-async-test")
250
+ async def chat_completions_async_test(payload: dict):
251
+ if payload.get("stream", True):
252
+ async def agen():
253
+ for i in range(payload.get("n_chunks", 2)):
254
+ data = {"chunk": i, "content": " hello"}
255
+ yield f"data: {json.dumps(data)}\n\n"
256
+ await asyncio.sleep(0)
257
+ yield "data: [DONE]\n\n"
258
+ return StreamingResponse(agen(), media_type="text/event-stream")
259
+ return {"non_streaming": True}
260
+
261
+ return api
262
+
263
+
264
+ @pytest.fixture(scope="module")
265
+ def client(api):
266
+ """Create TestClient for the API"""
267
+ with TestClient(api) as client:
268
+ yield client
269
+
270
+
271
+ def test_root(client):
272
+ response = client.get("/")
273
+ assert response.status_code == 200
274
+ assert response.json() == {"ok": True}
275
+
276
+
277
+ def test_path_and_query_binding(client):
278
+ response = client.get("/items/42?q=hello")
279
+ assert response.status_code == 200
280
+ assert response.json() == {"item_id": 42, "q": "hello"}
281
+
282
+
283
+ def test_bool_and_float_binding(client):
284
+ response = client.get("/types?b=true&f=1.25")
285
+ assert response.status_code == 200
286
+ assert response.json() == {"b": True, "f": 1.25}
287
+
288
+
289
+ def test_body_decoding(client):
290
+ response = client.put("/items/5", json={"name": "x", "price": 1.5, "is_offer": True})
291
+ assert response.status_code == 200
292
+ assert response.json() == {"item_id": 5, "item_name": "x", "is_offer": True}
293
+
294
+
295
+ def test_response_types(client):
296
+ # str
297
+ response = client.get("/str")
298
+ assert response.status_code == 200
299
+ assert response.content == b"hello"
300
+ assert response.headers.get("content-type", "").startswith("text/plain")
301
+ # bytes
302
+ response = client.get("/bytes")
303
+ assert response.status_code == 200
304
+ assert response.content == b"abc"
305
+ assert response.headers.get("content-type", "").startswith("application/octet-stream")
306
+
307
+
308
+ def test_json_response_status_and_headers(client):
309
+ response = client.get("/json")
310
+ assert response.status_code == 201
311
+ assert response.headers.get("x-test") == "1"
312
+ assert response.json() == {"x": 1}
313
+
314
+
315
+ def test_request_only_handler(client):
316
+ response = client.get("/req/9?y=z")
317
+ assert response.status_code == 200
318
+ assert response.json() == {"p": "9", "q": "z"}
319
+
320
+
321
+ def test_methods(client):
322
+ response = client.post("/m")
323
+ assert response.status_code == 200 and response.json() == {"m": "post"}
324
+ response = client.patch("/m")
325
+ assert response.status_code == 200 and response.json() == {"m": "patch"}
326
+ response = client.delete("/m")
327
+ assert response.status_code == 200 and response.json() == {"m": "delete"}
328
+
329
+
330
+ def test_response_model_validation_ok(client):
331
+ response = client.get("/ok-list")
332
+ assert response.status_code == 200
333
+ data = response.json()
334
+ assert isinstance(data, list) and len(data) == 2
335
+ assert data[0]["name"] == "a" and data[0]["price"] == 1.0
336
+
337
+
338
+ def test_response_model_validation_error(client):
339
+ response = client.get("/bad-list")
340
+ # We currently surface server error (500) on validation problems
341
+ assert response.status_code == 500
342
+ assert b"Response validation error" in response.content
343
+
344
+
345
+ def test_return_annotation_validation_ok(client):
346
+ response = client.get("/anno-list")
347
+ assert response.status_code == 200
348
+ data = response.json()
349
+ assert isinstance(data, list) and data[0]["name"] == "c"
350
+
351
+
352
+ def test_return_annotation_validation_error(client):
353
+ response = client.get("/anno-bad")
354
+ assert response.status_code == 500
355
+ assert b"Response validation error" in response.content
356
+
357
+
358
+ def test_response_coercion_from_objects(client):
359
+ response = client.get("/coerce/mini")
360
+ assert response.status_code == 200
361
+ data = response.json()
362
+ assert data == [{"id": 1, "username": "a"}, {"id": 2, "username": "b"}]
363
+
364
+
365
+ def test_response_coercion_error_from_objects(client):
366
+ response = client.get("/coerce/mini-bad")
367
+ assert response.status_code == 500
368
+ assert b"Response validation error" in response.content
369
+
370
+
371
+ def test_response_model_overrides_return_annotation(client):
372
+ response = client.get("/both-override")
373
+ assert response.status_code == 200
374
+ data = response.json()
375
+ assert isinstance(data, list) and data[0]["name"] == "o"
376
+
377
+
378
+ def test_no_validation_without_types(client):
379
+ response = client.get("/no-validate")
380
+ assert response.status_code == 200
381
+ data = response.json()
382
+ # Should return as-is since neither annotation nor response_model provided
383
+ assert data == [{"anything": 1, "extra": "ok"}]
384
+
385
+
386
+ def test_status_code_default(client):
387
+ response = client.get("/status-default")
388
+ assert response.status_code == 201
389
+
390
+
391
+ def test_header_and_cookie(client):
392
+ response = client.get("/header", headers={"x-test": "val"})
393
+ assert response.status_code == 200 and response.content == b"val"
394
+ # set cookie via header
395
+ response = client.get("/cookie", cookies={"session": "abc"})
396
+ assert response.status_code == 200 and response.content == b"abc"
397
+
398
+
399
+ def test_http_exception(client):
400
+ response = client.get("/exc")
401
+ assert response.status_code == 418
402
+ assert response.headers.get("x-err") == "1"
403
+
404
+
405
+ def test_response_helpers(client):
406
+ response = client.get("/html")
407
+ assert response.status_code == 200 and response.headers.get("content-type", "").startswith("text/html")
408
+ response = client.get("/redirect", follow_redirects=False)
409
+ assert response.status_code == 302 and response.headers.get("location") == "/"
410
+ response = client.get("/file")
411
+ assert response.status_code == 200 and response.headers.get("content-type", "").startswith("text/")
412
+ # FileResponse should also succeed and set content-disposition
413
+ response = client.get("/fileresponse")
414
+ assert response.status_code == 200
415
+ assert response.headers.get("content-type", "").startswith("text/")
416
+ assert "attachment;" in (response.headers.get("content-disposition", "").lower())
417
+
418
+
419
+ def test_streaming_plain(client):
420
+ response = client.get("/stream-plain")
421
+ assert response.status_code == 200
422
+ assert response.headers.get("content-type", "").startswith("text/plain")
423
+ assert response.content == b"p0,p1,p2,"
424
+
425
+
426
+ def test_streaming_bytes_default_content_type(client):
427
+ response = client.get("/stream-bytes")
428
+ assert response.status_code == 200
429
+ assert response.headers.get("content-type", "").startswith("application/octet-stream")
430
+ assert response.content == b"01"
431
+
432
+
433
+ def test_streaming_sse_headers(client):
434
+ response = client.get("/sse")
435
+ assert response.status_code == 200
436
+ assert response.headers.get("content-type", "").startswith("text/event-stream")
437
+ # SSE-friendly headers are set by the server
438
+ # Note: Connection header may be managed by the HTTP server automatically
439
+ assert response.headers.get("x-accel-buffering", "").lower() == "no"
440
+ # Body should contain multiple well-formed SSE lines
441
+ text = response.content.decode()
442
+ assert "event: message" in text
443
+ assert "data: hello" in text
444
+ assert "data: 1" in text
445
+ assert ": comment" in text
446
+
447
+
448
+ def test_streaming_async_large(client):
449
+ """Test async streaming with larger payloads."""
450
+ response = client.get("/stream-async-large")
451
+ assert response.status_code == 200
452
+ assert response.headers.get("content-type", "").startswith("application/octet-stream")
453
+
454
+ # Should have 10 chunks
455
+ lines = response.content.decode().strip().split('\n')
456
+ assert len(lines) == 10
457
+
458
+ # Check format of chunks
459
+ for i, line in enumerate(lines):
460
+ expected_prefix = f"chunk-{i:02d}-"
461
+ assert line.startswith(expected_prefix)
462
+ assert len(line) >= 109 # ~109 bytes per line (110 bytes per chunk with \n)
463
+ assert line.endswith('x' * 100)
464
+
465
+
466
+ def test_streaming_async_mixed_types(client):
467
+ """Test async streaming with different data types."""
468
+ response = client.get("/stream-async-mixed-types")
469
+ assert response.status_code == 200
470
+ assert response.headers.get("content-type", "").startswith("text/plain")
471
+
472
+ # Check all data types are properly converted
473
+ text = response.content.decode()
474
+ expected_chunks = [
475
+ "bytes-chunk\n",
476
+ "string-chunk\n",
477
+ "bytearray-chunk\n",
478
+ "memoryview-chunk\n"
479
+ ]
480
+
481
+ for expected in expected_chunks:
482
+ assert expected in text
483
+
484
+
485
+ def test_streaming_async_vs_sync_compatibility(client):
486
+ """Test that async and sync streaming produce the same results for equivalent data."""
487
+
488
+ # Get sync streaming result
489
+ sync_response = client.get("/stream-plain")
490
+
491
+ # Get async streaming result
492
+ async_response = client.get("/stream-async")
493
+
494
+ # Both should succeed
495
+ assert sync_response.status_code == 200
496
+ assert async_response.status_code == 200
497
+
498
+ # Both should be text/plain
499
+ assert sync_response.headers.get("content-type", "").startswith("text/plain")
500
+ assert async_response.headers.get("content-type", "").startswith("text/plain")
501
+
502
+ # Content should be similar format (both have 3 items)
503
+ sync_text = sync_response.content.decode()
504
+ async_text = async_response.content.decode()
505
+
506
+ # Both should have 3 comma-separated items
507
+ assert len(sync_text.split(',')) == 4 # "p0,p1,p2," = 4 parts
508
+ assert len(async_text.split(',')) == 4 # "async-0,async-1,async-2," = 4 parts
509
+
510
+
511
+ def test_async_bridge_endpoints_work(client):
512
+ """Test that async SSE streaming works correctly."""
513
+
514
+ # Test the async SSE endpoint - this should expose the real bug
515
+ response = client.get("/sse-async-test")
516
+ assert response.status_code == 200, f"Async SSE endpoint failed with status {response.status_code}"
517
+ assert len(response.content) > 0, f"Async SSE endpoint returned empty body, got {len(response.content)} bytes"
518
+ # Check that we actually get SSE formatted data
519
+ text = response.content.decode()
520
+ assert "data: 0" in text, f"Expected SSE data not found in response: {text[:100]}"
521
+ assert "data: 1" in text, f"Expected SSE data not found in response: {text[:100]}"
522
+
523
+
524
+ def test_form_and_file(client):
525
+ response = client.post("/form-urlencoded", data={"a": "x", "b": "3"})
526
+ assert response.status_code == 200 and response.json() == {"a": "x", "b": 3}
527
+
528
+ # Test multipart file upload
529
+ response = client.post(
530
+ "/upload",
531
+ data={"note": "hi"},
532
+ files=[
533
+ ("file", ("a.txt", b"abc", "application/octet-stream")),
534
+ ("file", ("b.txt", b"def", "application/octet-stream"))
535
+ ]
536
+ )
537
+ data = response.json()
538
+ assert response.status_code == 200 and data["count"] == 2 and set(data["names"]) == {"a.txt", "b.txt"}
539
+
540
+
541
+ def test_head_method(client):
542
+ """Test HEAD method works correctly"""
543
+ response = client.head("/m")
544
+ assert response.status_code == 200
545
+ # HEAD should return headers but empty body
546
+ assert len(response.content) == 0
547
+
548
+
549
+ def test_head_with_params(client):
550
+ """Test HEAD method with path and query params"""
551
+ response = client.head("/items/42?q=test")
552
+ assert response.status_code == 200
553
+
554
+
555
+ def test_options_method_automatic(client):
556
+ """Test automatic OPTIONS handling - returns Allow header with available methods"""
557
+ response = client.options("/m")
558
+ assert response.status_code == 200
559
+ # Check Allow header is present and contains the methods
560
+ assert "allow" in response.headers or "Allow" in response.headers
561
+ allow_header = response.headers.get("allow") or response.headers.get("Allow")
562
+ assert allow_header is not None
563
+ # Should include all methods registered for /m (POST, PATCH, DELETE, HEAD)
564
+ methods = [m.strip() for m in allow_header.split(",")]
565
+ assert "POST" in methods
566
+ assert "PATCH" in methods
567
+ assert "DELETE" in methods
568
+ assert "HEAD" in methods
569
+ assert "OPTIONS" in methods # Always included for automatic OPTIONS
570
+ # Body should be empty JSON object
571
+ assert response.json() == {}
572
+
573
+
574
+ def test_options_on_nonexistent_route(client):
575
+ """Test OPTIONS on non-existent route returns 404"""
576
+ response = client.options("/does-not-exist")
577
+ assert response.status_code == 404
578
+
579
+
580
+ def test_explicit_options_handler():
581
+ """Test that explicit OPTIONS handler overrides automatic behavior"""
582
+ from django_bolt import Response
583
+
584
+ api = BoltAPI()
585
+
586
+ @api.get("/custom-options")
587
+ async def get_custom():
588
+ return {"result": "data"}
589
+
590
+ @api.options("/custom-options")
591
+ async def options_custom():
592
+ return Response(
593
+ {"custom": "options", "info": "This is a custom OPTIONS handler"},
594
+ headers={"Allow": "GET, OPTIONS", "X-Custom": "header"}
595
+ )
596
+
597
+ from django_bolt.testing import TestClient
598
+ with TestClient(api) as client:
599
+ response = client.options("/custom-options")
600
+ assert response.status_code == 200
601
+ data = response.json()
602
+ assert data["custom"] == "options"
603
+ assert data["info"] == "This is a custom OPTIONS handler"
604
+ # Check custom headers
605
+ assert "allow" in response.headers or "Allow" in response.headers
606
+ assert "x-custom" in response.headers or "X-Custom" in response.headers
607
+
608
+
609
+ def test_method_validation():
610
+ """Test that HEAD and OPTIONS don't accept body parameters"""
611
+ api = BoltAPI()
612
+
613
+ class Body(msgspec.Struct):
614
+ value: str
615
+
616
+ # HEAD should not accept body
617
+ with pytest.raises(TypeError, match="HEAD.*cannot have body parameters"):
618
+ @api.head("/test-head")
619
+ async def head_with_body(body: Body):
620
+ return {"ok": True}
621
+
622
+ # OPTIONS should not accept body
623
+ with pytest.raises(TypeError, match="OPTIONS.*cannot have body parameters"):
624
+ @api.options("/test-options")
625
+ async def options_with_body(body: Body):
626
+ return {"ok": True}