fastapi-uploadstream 0.1.0rc1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT Licence
2
+
3
+ Copyright 2026 whichoneiwonder
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi_uploadstream
3
+ Version: 0.1.0rc1
4
+ Summary: 3rd party extension for streaming uploads in FastAPI
5
+ Keywords: fastapi,upload,streaming,http,asynchronous
6
+ Author-email: whichoneiwonder <whichoneiwonder@github.com>
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.15
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Framework :: FastAPI
24
+ Classifier: Framework :: AnyIO
25
+ License-File: LICENSE
26
+ Requires-Dist: anyio>=3.6.2,<5
27
+ Requires-Dist: fastapi>=0.116.1
28
+ Requires-Dist: starlette>=1.0.1
29
+ Requires-Dist: typing-extensions>=4.14.1; python_version < '3.11'
30
+
31
+ # [UploadStream](https://whichoneiwonder.github.io/fastapi_uploadstream)
32
+
33
+ Streaming raw request-body helpers for FastAPI that enable efficient handling of large binary uploads without multipart complexity.
34
+
35
+ This is a third-party project, not created by the FastAPI team.
36
+
37
+ ## Features
38
+
39
+ - **Streaming uploads** - Handle large files without loading them entirely into memory
40
+ - **Binary body support** - Accept raw binary data directly in request bodies
41
+ - **Content-Length aware** - Automatically extract file size from request headers
42
+ - **OpenAPI documentation** - Auto-generate schema documentation for streaming endpoints
43
+ - **File-like interface** - Use familiar `read()` methods for consuming streams
44
+
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ pip install fastapi_uploadstream
50
+ ```
51
+
52
+ ## Quick Start
53
+
54
+ ```python
55
+ from typing import Annotated
56
+ from fastapi import FastAPI
57
+ from fastapi_uploadstream import UploadStream, StreamBody, install_uploadstream_openapi
58
+
59
+ app = FastAPI()
60
+ install_uploadstream_openapi(app)
61
+
62
+
63
+ @app.post("/upload")
64
+ async def upload_file(
65
+ body_content: Annotated[
66
+ UploadStream,
67
+ StreamBody(
68
+ media_types=["application/octet-stream"],
69
+ title="File upload",
70
+ ),
71
+ ],
72
+ ):
73
+ # Read the streamed content
74
+ data = await body_content.read()
75
+ return {
76
+ "size": body_content.size,
77
+ "content_type": body_content.content_type,
78
+ }
79
+ ```
80
+
81
+ ## Usage
82
+
83
+ ### StreamBody Dependency
84
+
85
+ The `StreamBody` dependency configures how your endpoint accepts streaming uploads:
86
+
87
+ ```python
88
+ StreamBody(
89
+ media_types=["application/octet-stream", "text/plain"], # Accepted content types
90
+ include_in_schema=True, # Include in OpenAPI schema
91
+ title="Upload content", # OpenAPI title
92
+ )
93
+ ```
94
+
95
+ ### UploadStream Object
96
+
97
+ Once received, you interact with the stream through `UploadStream`:
98
+
99
+ ```python
100
+ @app.post("/upload")
101
+ async def handle_upload(body: Annotated[UploadStream, StreamBody(media_types=["*/*"])]):
102
+ # Access file metadata
103
+ print(f"Size: {body.size}") # From Content-Length header
104
+ print(f"Type: {body.content_type}")
105
+ print(f"Name: {body.filename}") # From x-filename header
106
+
107
+ # Read the stream in chunks
108
+ chunk = await body.read(1024) # Read up to 1024 bytes
109
+
110
+ # Read remaining data at once
111
+ remaining = await body.read() # Read all remaining data
112
+
113
+ # Read everything (resets on first call)
114
+ all_data = await body.read(-1) # Read all from beginning
115
+ ```
116
+
117
+ ### OpenAPI Documentation
118
+
119
+ Call `install_uploadstream_openapi()` to add automatic schema documentation:
120
+
121
+ ```python
122
+ app = FastAPI()
123
+ install_uploadstream_openapi(app)
124
+ ```
125
+
126
+ This adds proper OpenAPI definitions for binary request bodies on your streaming endpoints.
127
+
128
+ ## When to Use
129
+
130
+ ### ✅ Use UploadStream for:
131
+ - Large file uploads (to avoid memory overhead)
132
+ - Proxying streaming content to a backend API or storage
133
+ - Binary data streams
134
+ - Direct binary body requests
135
+ - Maintaining Compatibility with non-form-based legacy endpoints
136
+
137
+ ### ❌ Use FastAPI's UploadFile for:
138
+ - Multipart form uploads
139
+ - Multiple files in one request
140
+ - Form fields + file combinations
141
+
142
+ ## Example: File Upload with Size Tracking
143
+
144
+ ```python
145
+ @app.post("/upload-large")
146
+ async def upload_large_file(body: Annotated[UploadStream, StreamBody()]):
147
+ bytes_received = 0
148
+ chunk_size = 65536 # 64KB chunks
149
+
150
+ while True:
151
+ chunk = await body.read(chunk_size)
152
+ if not chunk:
153
+ break
154
+ bytes_received += len(chunk)
155
+ # Process chunk...
156
+
157
+ return {"total_bytes": bytes_received}
158
+ ```
159
+
160
+ ## Requirements
161
+
162
+ - Python 3.10+
163
+ - FastAPI 0.116.1+
164
+ - anyio (for async streaming primitives)
165
+
166
+ ## License
167
+
168
+ MIT
169
+
@@ -0,0 +1,138 @@
1
+ # [UploadStream](https://whichoneiwonder.github.io/fastapi_uploadstream)
2
+
3
+ Streaming raw request-body helpers for FastAPI that enable efficient handling of large binary uploads without multipart complexity.
4
+
5
+ This is a third-party project, not created by the FastAPI team.
6
+
7
+ ## Features
8
+
9
+ - **Streaming uploads** - Handle large files without loading them entirely into memory
10
+ - **Binary body support** - Accept raw binary data directly in request bodies
11
+ - **Content-Length aware** - Automatically extract file size from request headers
12
+ - **OpenAPI documentation** - Auto-generate schema documentation for streaming endpoints
13
+ - **File-like interface** - Use familiar `read()` methods for consuming streams
14
+
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install fastapi_uploadstream
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```python
25
+ from typing import Annotated
26
+ from fastapi import FastAPI
27
+ from fastapi_uploadstream import UploadStream, StreamBody, install_uploadstream_openapi
28
+
29
+ app = FastAPI()
30
+ install_uploadstream_openapi(app)
31
+
32
+
33
+ @app.post("/upload")
34
+ async def upload_file(
35
+ body_content: Annotated[
36
+ UploadStream,
37
+ StreamBody(
38
+ media_types=["application/octet-stream"],
39
+ title="File upload",
40
+ ),
41
+ ],
42
+ ):
43
+ # Read the streamed content
44
+ data = await body_content.read()
45
+ return {
46
+ "size": body_content.size,
47
+ "content_type": body_content.content_type,
48
+ }
49
+ ```
50
+
51
+ ## Usage
52
+
53
+ ### StreamBody Dependency
54
+
55
+ The `StreamBody` dependency configures how your endpoint accepts streaming uploads:
56
+
57
+ ```python
58
+ StreamBody(
59
+ media_types=["application/octet-stream", "text/plain"], # Accepted content types
60
+ include_in_schema=True, # Include in OpenAPI schema
61
+ title="Upload content", # OpenAPI title
62
+ )
63
+ ```
64
+
65
+ ### UploadStream Object
66
+
67
+ Once received, you interact with the stream through `UploadStream`:
68
+
69
+ ```python
70
+ @app.post("/upload")
71
+ async def handle_upload(body: Annotated[UploadStream, StreamBody(media_types=["*/*"])]):
72
+ # Access file metadata
73
+ print(f"Size: {body.size}") # From Content-Length header
74
+ print(f"Type: {body.content_type}")
75
+ print(f"Name: {body.filename}") # From x-filename header
76
+
77
+ # Read the stream in chunks
78
+ chunk = await body.read(1024) # Read up to 1024 bytes
79
+
80
+ # Read remaining data at once
81
+ remaining = await body.read() # Read all remaining data
82
+
83
+ # Read everything (resets on first call)
84
+ all_data = await body.read(-1) # Read all from beginning
85
+ ```
86
+
87
+ ### OpenAPI Documentation
88
+
89
+ Call `install_uploadstream_openapi()` to add automatic schema documentation:
90
+
91
+ ```python
92
+ app = FastAPI()
93
+ install_uploadstream_openapi(app)
94
+ ```
95
+
96
+ This adds proper OpenAPI definitions for binary request bodies on your streaming endpoints.
97
+
98
+ ## When to Use
99
+
100
+ ### ✅ Use UploadStream for:
101
+ - Large file uploads (to avoid memory overhead)
102
+ - Proxying streaming content to a backend API or storage
103
+ - Binary data streams
104
+ - Direct binary body requests
105
+ - Maintaining Compatibility with non-form-based legacy endpoints
106
+
107
+ ### ❌ Use FastAPI's UploadFile for:
108
+ - Multipart form uploads
109
+ - Multiple files in one request
110
+ - Form fields + file combinations
111
+
112
+ ## Example: File Upload with Size Tracking
113
+
114
+ ```python
115
+ @app.post("/upload-large")
116
+ async def upload_large_file(body: Annotated[UploadStream, StreamBody()]):
117
+ bytes_received = 0
118
+ chunk_size = 65536 # 64KB chunks
119
+
120
+ while True:
121
+ chunk = await body.read(chunk_size)
122
+ if not chunk:
123
+ break
124
+ bytes_received += len(chunk)
125
+ # Process chunk...
126
+
127
+ return {"total_bytes": bytes_received}
128
+ ```
129
+
130
+ ## Requirements
131
+
132
+ - Python 3.10+
133
+ - FastAPI 0.116.1+
134
+ - anyio (for async streaming primitives)
135
+
136
+ ## License
137
+
138
+ MIT
@@ -0,0 +1,77 @@
1
+ [project]
2
+ name = "fastapi_uploadstream"
3
+ version = "0.1.0rc1"
4
+ description = "3rd party extension for streaming uploads in FastAPI"
5
+ license = 'MIT'
6
+ readme = "README.md"
7
+ authors = [{ name = "whichoneiwonder", email = "whichoneiwonder@github.com" }]
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "anyio>=3.6.2,<5",
11
+ "fastapi>=0.116.1",
12
+ "starlette>=1.0.1",
13
+ "typing-extensions>=4.14.1; python_version < '3.11'",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Web Environment",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Programming Language :: Python :: 3.15",
28
+ "Topic :: Internet :: WWW/HTTP",
29
+ "Framework :: FastAPI",
30
+ "Framework :: AnyIO",
31
+ ]
32
+ keywords = [
33
+ "fastapi",
34
+ "upload",
35
+ "streaming",
36
+ "http",
37
+ "asynchronous",
38
+ ]
39
+ [build-system]
40
+ requires = ["flit_core>=3.2,<4"]
41
+ build-backend = "flit_core.buildapi"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "httpx>=0.28.1",
46
+ "pytest>=9.0.3",
47
+ "python-multipart>=0.0.28",
48
+ "ruff>=0.12.7",
49
+ "trio>=0.33.0",
50
+ ]
51
+ standard = [
52
+ "fastapi[standard]>=0.116.1",
53
+ ]
54
+ docs = [
55
+ "mkdocs-material>=9.7.6",
56
+ "mkdocstrings-python>=2.0.3",
57
+ ]
58
+
59
+ [tool.ruff]
60
+ line-length = 120
61
+ lint.select = [
62
+ "E",
63
+ "F",
64
+ "W",
65
+ "C90",
66
+ "B",
67
+ "B9",
68
+ "I",
69
+ "Q",
70
+ "S",
71
+ "T",
72
+ "ERA",
73
+ "ANN",
74
+ "FAST",
75
+ "RUF",
76
+ ]
77
+ lint.per-file-ignores = { "tests/**.py" = ["S101"] }
@@ -0,0 +1,511 @@
1
+ """Streaming raw request-body helpers for FastAPI.
2
+
3
+ This package provides a dependency-first runtime wrapper plus an optional
4
+ OpenAPI hook that documents those dependencies as binary request bodies.
5
+ """
6
+
7
+ import warnings
8
+ from collections.abc import AsyncIterator, Callable, Iterable
9
+ from typing import Any, Literal
10
+
11
+ from anyio import EndOfStream, create_memory_object_stream, create_task_group
12
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
13
+ from fastapi import Depends, FastAPI, HTTPException, Request, status
14
+ from fastapi.params import Body as BodyFieldInfo
15
+ from fastapi.params import File as FileFieldInfo
16
+ from fastapi.params import Form as FormFieldInfo
17
+ from fastapi.routing import APIRoute
18
+ from starlette.requests import ClientDisconnect
19
+
20
+
21
+ def size_from_request(request: Request) -> int | None:
22
+ """Return the request Content-Length header as an integer when possible."""
23
+ if content_size := request.headers.get("content-length"):
24
+ try:
25
+ return int(content_size)
26
+ except ValueError:
27
+ return None
28
+ return None
29
+
30
+
31
+ def _normalize_media_types(media_types: str | Iterable[str]) -> tuple[str, ...]:
32
+ """Normalize media type strings to a deduplicated tuple.
33
+
34
+ Args:
35
+ media_types: A single media type string or iterable of media type strings.
36
+
37
+ Returns:
38
+ A tuple of normalized media types, or ("*/*",) if no valid types provided.
39
+ """
40
+ if isinstance(media_types, str):
41
+ normalized = [media_types.strip()]
42
+ else:
43
+ normalized = [value.strip() for value in media_types if value.strip()]
44
+
45
+ if not normalized:
46
+ return ("*/*",)
47
+
48
+ return tuple(dict.fromkeys(normalized))
49
+
50
+
51
+ def _media_type_matches(content_type: str, allowed_media_type: str) -> bool:
52
+ """Check if a content type matches an allowed media type pattern.
53
+
54
+ Supports wildcard matching for type patterns like "application/*".
55
+
56
+ Args:
57
+ content_type: The actual content type from the request.
58
+ allowed_media_type: The pattern to match against (may include "*").
59
+
60
+ Returns:
61
+ True if the content type matches the allowed pattern.
62
+ """
63
+ if allowed_media_type == "*/*":
64
+ return True
65
+
66
+ content_base = content_type.split(";", 1)[0].strip().lower()
67
+ allowed_base = allowed_media_type.strip().lower()
68
+
69
+ if allowed_base.endswith("/*"):
70
+ prefix = allowed_base[:-1]
71
+ return content_base.startswith(prefix)
72
+
73
+ return content_base == allowed_base
74
+
75
+
76
+ class UploadStream:
77
+ """A streaming view of a raw request body.
78
+
79
+ This object intentionally mimics only the subset of UploadFile behavior that
80
+ is meaningful for one-pass, non-multipart request streaming.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ request: Request,
86
+ receiver: MemoryObjectReceiveStream[bytes | ClientDisconnect],
87
+ cancel_receive: Callable[[], object],
88
+ ) -> None:
89
+ """Wrap the incoming request stream as a file-like binary reader."""
90
+ self.request = request
91
+ self.filename = request.headers.get("x-filename")
92
+ self.size = size_from_request(request)
93
+ self.content_type = request.headers.get("content-type", "*/*")
94
+
95
+ self._receiver = receiver
96
+ self._cancel_receive = cancel_receive
97
+ self._buffer = bytearray()
98
+ self._eof = False
99
+ self._closed = False
100
+
101
+ async def _pull_chunk(self) -> bytes:
102
+ """Pull the next chunk from the receive stream.
103
+
104
+ Returns:
105
+ The next chunk of bytes, or empty bytes if EOF is reached.
106
+ """
107
+ if self._eof:
108
+ return b""
109
+
110
+ try:
111
+ item = await self._receiver.receive()
112
+ if isinstance(item, ClientDisconnect):
113
+ raise item
114
+ return item
115
+ except EndOfStream:
116
+ self._eof = True
117
+ return b""
118
+
119
+ async def read(self, size: int = -1) -> bytes:
120
+ """Read bytes from the streamed request body."""
121
+ if self._closed:
122
+ return b""
123
+
124
+ if size == 0:
125
+ return b""
126
+ if size < -1:
127
+ raise ValueError("size must be >= -1")
128
+
129
+ if size == -1:
130
+ chunks = [bytes(self._buffer)]
131
+ self._buffer.clear()
132
+ while True:
133
+ chunk = await self._pull_chunk()
134
+ if not chunk:
135
+ break
136
+ chunks.append(chunk)
137
+ return b"".join(chunks)
138
+
139
+ while len(self._buffer) < size and not self._eof:
140
+ chunk = await self._pull_chunk()
141
+ if not chunk:
142
+ break
143
+ self._buffer.extend(chunk)
144
+
145
+ if not self._buffer:
146
+ return b""
147
+
148
+ out = bytes(self._buffer[:size])
149
+ del self._buffer[:size]
150
+ return out
151
+
152
+ async def seek(self, offset: int) -> None:
153
+ """Advance the stream by consuming and discarding bytes.
154
+
155
+ Args:
156
+ offset: Number of bytes to advance. Must be non-negative.
157
+
158
+ Raises:
159
+ ValueError: If offset is negative.
160
+ """
161
+ if offset < 0:
162
+ raise ValueError(f"Negative seek position: {offset}")
163
+ if offset == 0:
164
+ return
165
+
166
+ remaining = offset
167
+ while remaining > 0:
168
+ chunk = await self.read(remaining)
169
+ if not chunk:
170
+ break
171
+ remaining -= len(chunk)
172
+
173
+ async def iter_chunks(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]:
174
+ """Yield the request body in chunks of specified size.
175
+
176
+ Args:
177
+ chunk_size: Size of each chunk in bytes. Defaults to 64KB. Must be positive.
178
+
179
+ Yields:
180
+ Chunks of bytes until EOF.
181
+
182
+ Raises:
183
+ ValueError: If chunk_size is not positive.
184
+ """
185
+ if chunk_size <= 0:
186
+ raise ValueError("chunk_size must be > 0")
187
+
188
+ while True:
189
+ chunk = await self.read(chunk_size)
190
+ if not chunk:
191
+ break
192
+ yield chunk
193
+
194
+ async def close(self) -> None:
195
+ """Stop background request pumping and close the receive stream."""
196
+ if self._closed:
197
+ return
198
+
199
+ self._closed = True
200
+ self._cancel_receive()
201
+ await self._receiver.aclose()
202
+
203
+
204
+ class StreamBodyParam(BodyFieldInfo):
205
+ """Callable dependency wrapper used with StreamBody(...)."""
206
+
207
+ def __init__(
208
+ self,
209
+ *,
210
+ media_types: str | Iterable[str] = "*/*",
211
+ title: str | None = None,
212
+ description: str | None = None,
213
+ examples: list[Any] | None = None,
214
+ openapi_examples: dict[str, Any] | None = None,
215
+ deprecated: bool | str | None = None,
216
+ include_in_schema: bool = True,
217
+ json_schema_extra: dict[str, Any] | Callable[[dict[str, Any]], None] | None = None,
218
+ channel_buffer_size: int = 2048,
219
+ client_disconnect: Literal["eof", "raise"] = "raise",
220
+ ) -> None:
221
+ """Capture runtime and documentation settings for a raw body dependency."""
222
+ self.media_types = _normalize_media_types(media_types)
223
+ self.channel_buffer_size = channel_buffer_size
224
+ self.client_disconnect = client_disconnect
225
+ super().__init__(
226
+ title=title,
227
+ description=description,
228
+ examples=examples,
229
+ openapi_examples=openapi_examples,
230
+ deprecated=deprecated,
231
+ include_in_schema=include_in_schema,
232
+ json_schema_extra=json_schema_extra or {},
233
+ )
234
+
235
+ async def __call__(self, request: Request) -> AsyncIterator[UploadStream]:
236
+ """Yield a streaming file-like wrapper over the incoming request body."""
237
+ self._validate_content_type(request)
238
+
239
+ send_stream: MemoryObjectSendStream[bytes | ClientDisconnect]
240
+ recv_stream: MemoryObjectReceiveStream[bytes | ClientDisconnect]
241
+ send_stream, recv_stream = create_memory_object_stream[bytes | ClientDisconnect](self.channel_buffer_size)
242
+
243
+ async with create_task_group() as task_group:
244
+ upload = UploadStream(
245
+ request=request,
246
+ receiver=recv_stream,
247
+ cancel_receive=task_group.cancel_scope.cancel,
248
+ )
249
+ task_group.start_soon(self._recv_from_request, request, send_stream)
250
+
251
+ try:
252
+ yield upload
253
+ finally:
254
+ await upload.close()
255
+
256
+ async def _recv_from_request(
257
+ self,
258
+ request: Request,
259
+ sender: MemoryObjectSendStream[bytes | ClientDisconnect],
260
+ ) -> None:
261
+ """Pump request body chunks into the send stream.
262
+
263
+ Runs as a background task that continuously reads from the request
264
+ and forwards chunks to the send stream until the body is exhausted.
265
+
266
+ Args:
267
+ request: The incoming HTTP request.
268
+ sender: The send stream to forward chunks to.
269
+ """
270
+ async with sender:
271
+ try:
272
+ async for chunk in request.stream():
273
+ if chunk:
274
+ await sender.send(chunk)
275
+ except ClientDisconnect:
276
+ if self.client_disconnect == "raise":
277
+ await sender.send(ClientDisconnect())
278
+ return
279
+
280
+ def _validate_content_type(self, request: Request) -> None:
281
+ """Validate that the request content type matches allowed media types.
282
+
283
+ Args:
284
+ request: The incoming HTTP request.
285
+
286
+ Raises:
287
+ HTTPException: If content type is not in the allowed media types.
288
+ """
289
+ content_type = request.headers.get("content-type", "*/*")
290
+
291
+ if any(_media_type_matches(content_type, allowed) for allowed in self.media_types):
292
+ return
293
+
294
+ allowed = ", ".join(self.media_types)
295
+ raise HTTPException(
296
+ status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
297
+ detail=(f"Unsupported content-type '{content_type}'. Expected one of: {allowed}"),
298
+ )
299
+
300
+ def openapi_request_body(self) -> dict[str, Any]:
301
+ """Return an OpenAPI requestBody block for a binary upload."""
302
+ content: dict[str, Any] = {}
303
+
304
+ for media_type in self.media_types:
305
+ schema: dict[str, Any] = {"type": "string", "format": "binary"}
306
+
307
+ if self.title is not None:
308
+ schema["title"] = self.title
309
+ if self.deprecated:
310
+ schema["deprecated"] = True
311
+ if isinstance(self.json_schema_extra, Callable):
312
+ warnings.warn(
313
+ message=UserWarning(
314
+ "json_schema_extra callable overrides StreamBody annotations; "
315
+ "use a dict instead for compatibility"
316
+ ),
317
+ stacklevel=2,
318
+ )
319
+ self.json_schema_extra(schema)
320
+ elif isinstance(self.json_schema_extra, dict):
321
+ schema.update(dict(self.json_schema_extra))
322
+
323
+ media_type_obj: dict[str, Any] = {"schema": schema}
324
+
325
+ if self.openapi_examples is not None:
326
+ media_type_obj["examples"] = self.openapi_examples
327
+ elif self.examples is not None and self.examples:
328
+ media_type_obj["examples"] = {f"example-{i}": {"value": v} for i, v in enumerate(self.examples)}
329
+
330
+ if self.example is not None and "examples" not in media_type_obj:
331
+ media_type_obj["example"] = self.example
332
+
333
+ content[media_type] = media_type_obj
334
+
335
+ request_body: dict[str, Any] = {
336
+ "content": content,
337
+ }
338
+ if self.description is not None:
339
+ request_body["description"] = self.description
340
+
341
+ return request_body
342
+
343
+
344
+ def StreamBody(
345
+ *,
346
+ media_types: str | list[str] = "*/*",
347
+ title: str | None = None,
348
+ description: str | None = None,
349
+ examples: list[Any] | None = None,
350
+ openapi_examples: dict[str, Any] | None = None,
351
+ deprecated: bool | str | None = None,
352
+ include_in_schema: bool = True,
353
+ json_schema_extra: dict[str, Any] | Callable[[dict[str, Any]], None] | None = None,
354
+ channel_buffer_size: int = 2048,
355
+ client_disconnect: Literal["eof", "raise"] = "raise",
356
+ ) -> StreamBodyParam:
357
+ """Create a dependency object for streamed raw body uploads."""
358
+ return Depends(
359
+ StreamBodyParam(
360
+ media_types=media_types,
361
+ title=title,
362
+ description=description,
363
+ examples=examples,
364
+ openapi_examples=openapi_examples,
365
+ deprecated=deprecated,
366
+ include_in_schema=include_in_schema,
367
+ json_schema_extra=json_schema_extra,
368
+ channel_buffer_size=channel_buffer_size,
369
+ client_disconnect=client_disconnect,
370
+ )
371
+ )
372
+
373
+
374
+ def install_uploadstream_openapi(app: FastAPI) -> FastAPI:
375
+ """Teach FastAPI's OpenAPI generator about StreamBody dependencies."""
376
+ # TODO: Add an APIRouter helper that auto-installs this hook the first time a
377
+ # router with StreamBody dependencies is included in an app. A practical shape
378
+ # is `StreamBodyRouter(APIRouter)` plus `include_streambody_router(app, router)`
379
+ # that calls `install_uploadstream_openapi(app)` once (guarded by a private flag)
380
+ # before delegating to `app.include_router(router)`.
381
+ original_openapi = app.openapi
382
+
383
+ def custom_openapi() -> dict[str, Any]:
384
+ if app.openapi_schema is not None:
385
+ return app.openapi_schema
386
+
387
+ openapi_schema = original_openapi()
388
+ _inject_streambody_openapi(app, openapi_schema)
389
+ app.openapi_schema = openapi_schema
390
+ return app.openapi_schema
391
+
392
+ app.openapi = custom_openapi # type: ignore[method-assign]
393
+ return app
394
+
395
+
396
+ def _check_streambody_conflicts(route: APIRoute, stream_bodies: list[StreamBodyParam]) -> None:
397
+ """Raise ValueError if a StreamBody dependency coexists with Body/Form/UploadFile params.
398
+
399
+ Args:
400
+ route: The API route to validate.
401
+
402
+ Raises:
403
+ ValueError: If incompatible parameter types are mixed on the same endpoint.
404
+ """
405
+ if len(stream_bodies) > 1:
406
+ raise ValueError(
407
+ f"Multiple StreamBody dependencies found on route {route.path_format}. "
408
+ f"Only one StreamBodyParam is allowed per endpoint to avoid OpenAPI conflicts."
409
+ )
410
+
411
+ conflicting = route.dependant.body_params
412
+ if not conflicting:
413
+ return
414
+
415
+ def _param_kind(field_info: object) -> str:
416
+ if isinstance(field_info, FileFieldInfo):
417
+ return "File"
418
+ if isinstance(field_info, FormFieldInfo):
419
+ return "Form"
420
+ if isinstance(field_info, StreamBodyParam):
421
+ return "StreamBody"
422
+ return "Body"
423
+
424
+ descriptions = [f"{p.name} ({_param_kind(p.field_info)})" for p in conflicting]
425
+ methods = ", ".join(route.methods or ["?"])
426
+ raise ValueError(
427
+ f"StreamBody cannot be combined with Body, Form, or File/UploadFile parameters on the "
428
+ f"same endpoint ({methods} {route.path_format}). "
429
+ f"Conflicting params: {', '.join(descriptions)}. "
430
+ f"Use StreamBody exclusively for request body handling on this endpoint."
431
+ )
432
+
433
+
434
+ def _inject_streambody_openapi(app: FastAPI, openapi_schema: dict[str, Any]) -> None:
435
+ """Patch generated OpenAPI paths with binary requestBody entries.
436
+
437
+ Scans all routes for StreamBody dependencies and injects appropriate
438
+ binary media type definitions into their OpenAPI operation specs.
439
+
440
+ Args:
441
+ app: The FastAPI application.
442
+ openapi_schema: The OpenAPI schema dict to modify in-place.
443
+ """
444
+ paths = openapi_schema.setdefault("paths", {})
445
+
446
+ for route in app.routes:
447
+ if not isinstance(route, APIRoute):
448
+ continue
449
+
450
+ stream_bodies = _collect_streambody_dependencies(route)
451
+ if not stream_bodies:
452
+ continue
453
+ _check_streambody_conflicts(route, stream_bodies)
454
+
455
+ path_item = paths.get(route.path_format)
456
+ if not path_item:
457
+ continue
458
+
459
+ for method in route.methods or []:
460
+ operation = path_item.get(method.lower())
461
+ if not operation:
462
+ continue
463
+
464
+ visible_stream_bodies = [stream_body for stream_body in stream_bodies if stream_body.include_in_schema]
465
+ if not visible_stream_bodies:
466
+ continue
467
+
468
+ request_body = operation.setdefault("requestBody", {})
469
+ request_body.setdefault("required", True)
470
+ content = request_body.setdefault("content", {})
471
+
472
+ for stream_body in visible_stream_bodies:
473
+ rb = stream_body.openapi_request_body()
474
+ content.update(rb["content"])
475
+ if "description" in rb and "description" not in request_body:
476
+ request_body["description"] = rb["description"]
477
+
478
+
479
+ def _collect_streambody_dependencies(route: APIRoute) -> list[StreamBodyParam]:
480
+ """Find StreamBodyParam instances reachable from an APIRoute dependency tree.
481
+
482
+ Recursively traverses the dependency graph of an endpoint to collect
483
+ all StreamBodyParam instances used in its dependencies.
484
+
485
+ Args:
486
+ route: The API route to scan.
487
+
488
+ Returns:
489
+ A list of all StreamBodyParam instances found in the dependency tree.
490
+ """
491
+ matches: list[StreamBodyParam] = []
492
+
493
+ def visit(dependant: object) -> None:
494
+ call = getattr(dependant, "call", None)
495
+ if isinstance(call, StreamBodyParam):
496
+ matches.append(call)
497
+
498
+ for child in getattr(dependant, "dependencies", []):
499
+ visit(child)
500
+
501
+ visit(route.dependant)
502
+ return matches
503
+
504
+
505
+ __all__ = [
506
+ "StreamBody",
507
+ "StreamBodyParam",
508
+ "UploadStream",
509
+ "install_uploadstream_openapi",
510
+ "size_from_request",
511
+ ]