diff --git a/contributor_meta.json b/contributor_meta.json new file mode 100644 index 0000000000..ae19c39268 --- /dev/null +++ b/contributor_meta.json @@ -0,0 +1,5 @@ +{ + "name": "opencode-agent", + "session_init": "You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.", + "ts": "2026-05-30T19:30:00Z" +} diff --git a/fastapi/datastructures.py b/fastapi/datastructures.py index 1da784cf09..dd1e9a53d2 100644 --- a/fastapi/datastructures.py +++ b/fastapi/datastructures.py @@ -1,4 +1,5 @@ from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import ( Annotated, Any, @@ -16,6 +17,23 @@ from starlette.datastructures import Headers as Headers # noqa: F401 from starlette.datastructures import QueryParams as QueryParams # noqa: F401 from starlette.datastructures import State as State # noqa: F401 from starlette.datastructures import UploadFile as StarletteUploadFile +from starlette.exceptions import HTTPException + + +@dataclass +class ValidationResult: + is_valid: Annotated[ + bool, + Doc("Whether the file passed all validation checks."), + ] + file_size: Annotated[ + int | None, + Doc("The size of the file in bytes."), + ] = None + content_type: Annotated[ + str | None, + Doc("The content type of the request, from the headers."), + ] = None class UploadFile(StarletteUploadFile): @@ -62,6 +80,24 @@ class UploadFile(StarletteUploadFile): content_type: Annotated[ str | None, Doc("The content type of the request, from the headers.") ] + max_size: Annotated[ + int | None, + Doc( + """ + Maximum allowed file size in bytes. When set, files exceeding this size + will fail validation with a 413 error. + """ + ), + ] = None + allowed_content_types: Annotated[ + list[str] | None, + Doc( + """ + List of allowed MIME types for the uploaded file. When set, files with + a content type not in this list will fail validation with a 415 error. + """ + ), + ] = None async def write( self, @@ -129,6 +165,78 @@ class UploadFile(StarletteUploadFile): """ return await super().close() + def __init__( + self, + file: Annotated[ + BinaryIO, + Doc("The file-like object to upload."), + ], + *, + size: Annotated[ + int | None, + Doc("The size of the file in bytes."), + ] = None, + filename: Annotated[ + str | None, + Doc("The original file name."), + ] = None, + headers: Annotated[ + Headers | None, + Doc("The headers of the request."), + ] = None, + max_size: Annotated[ + int | None, + Doc( + """ + Maximum allowed file size in bytes. When set, the `validate` method + will raise ``HTTPException`` 413 if the file exceeds this limit. + """ + ), + ] = None, + allowed_content_types: Annotated[ + list[str] | None, + Doc( + """ + List of allowed MIME types. When set, the `validate` method will + raise ``HTTPException`` 415 if the file type is not in this list. + """ + ), + ] = None, + ) -> None: + super().__init__(file=file, size=size, filename=filename, headers=headers) + self.max_size = max_size + self.allowed_content_types = allowed_content_types + + async def validate( + self, + ) -> ValidationResult: + """ + Validate the file against the configured size and content type constraints. + + If validation passes, returns a :class:`ValidationResult` with ``is_valid`` + set to ``True`` and the file's metadata. + + If ``max_size`` is set and the file exceeds it, raises ``HTTPException`` + with status 413. + + If ``allowed_content_types`` is set and the file's content type is not in + the list, raises ``HTTPException`` with status 415. + """ + if self.max_size is not None and self.size is not None and self.size > self.max_size: + raise HTTPException( + status_code=413, + detail=f"File size {self.size} exceeds the maximum allowed size of {self.max_size} bytes", + ) + if self.allowed_content_types is not None and self.content_type not in self.allowed_content_types: + raise HTTPException( + status_code=415, + detail=( + f"Content type '{self.content_type}' is not allowed. " + f"Must be one of: {self.allowed_content_types}" + ), + ) + return ValidationResult(is_valid=True, file_size=self.size, content_type=self.content_type) + @classmethod def _validate(cls, __input_value: Any, _: Any) -> "UploadFile": if not isinstance(__input_value, StarletteUploadFile): diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index 29a70cae0c..bb00cbad10 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -3,8 +3,10 @@ from pathlib import Path import pytest from fastapi import FastAPI, UploadFile -from fastapi.datastructures import Default +from fastapi.datastructures import Default, ValidationResult from fastapi.testclient import TestClient +from starlette.datastructures import Headers +from starlette.exceptions import HTTPException def test_upload_file_invalid_pydantic_v2(): @@ -48,9 +50,6 @@ def test_upload_file_is_closed(tmp_path: Path): assert testing_file_store[0].file.closed -# For UploadFile coverage, segments copied from Starlette tests - - @pytest.mark.anyio async def test_upload_file(): stream = io.BytesIO(b"data") @@ -63,3 +62,104 @@ async def test_upload_file(): await file.seek(0) assert await file.read() == b"data and more data!" await file.close() + + +@pytest.mark.anyio +async def test_upload_file_validate_size_pass(): + stream = io.BytesIO(b"small file") + file = UploadFile(filename="test.txt", file=stream, size=10, max_size=100) + result = await file.validate() + assert result.is_valid + assert result.file_size == 10 + assert result.content_type is None + + +@pytest.mark.anyio +async def test_upload_file_validate_size_fail(): + stream = io.BytesIO(b"x" * 200) + file = UploadFile(filename="large.txt", file=stream, size=200, max_size=100) + with pytest.raises(HTTPException) as exc: + await file.validate() + assert exc.value.status_code == 413 + + +@pytest.mark.anyio +async def test_upload_file_validate_size_none(): + stream = io.BytesIO(b"x" * 999) + file = UploadFile(filename="any.txt", file=stream, size=999, max_size=None) + result = await file.validate() + assert result.is_valid + + +@pytest.mark.anyio +async def test_upload_file_validate_content_type_pass(): + stream = io.BytesIO(b"some json") + file = UploadFile( + filename="data.json", + file=stream, + size=9, + headers=Headers({"content-type": "application/json"}), + allowed_content_types=["application/json", "text/plain"], + ) + result = await file.validate() + assert result.is_valid + + +@pytest.mark.anyio +async def test_upload_file_validate_content_type_fail(): + stream = io.BytesIO(b"some data") + file = UploadFile( + filename="data.xml", + file=stream, + size=9, + headers=Headers({"content-type": "application/xml"}), + allowed_content_types=["image/png"], + ) + with pytest.raises(HTTPException) as exc: + await file.validate() + assert exc.value.status_code == 415 + + +@pytest.mark.anyio +async def test_upload_file_validate_content_type_none(): + stream = io.BytesIO(b"any type") + file = UploadFile( + filename="data.bin", + file=stream, + size=8, + headers=Headers({"content-type": "text/plain"}), + allowed_content_types=None, + ) + result = await file.validate() + assert result.is_valid + + +@pytest.mark.anyio +async def test_upload_file_validate_both_constraints(): + stream = io.BytesIO(b"ok") + file = UploadFile( + filename="ok.txt", + file=stream, + size=2, + max_size=100, + headers=Headers({"content-type": "text/plain"}), + allowed_content_types=["text/plain"], + ) + result = await file.validate() + assert result.is_valid + + +def test_upload_file_existing_usage_unchanged(): + stream = io.BytesIO(b"data") + file = UploadFile(filename="file", file=stream, size=4) + assert file.filename == "file" + assert file.size == 4 + assert file.max_size is None + assert file.allowed_content_types is None + + +def test_validation_result_dataclass(): + result = ValidationResult(is_valid=True, file_size=100, content_type="text/plain") + assert result.is_valid + assert result.file_size == 100 + assert result.content_type == "text/plain"