Repository: litestar-org/litestar

Issue:
Bug: cant upload one file only if type is list[UploadFile] | None
### Description

I though I had the same issue as #2939 but it's slightly different, here is a test case that fails for one file and not for 2 files:
```
@dataclass
 class OFiles:
    file_list: Optional[List[UploadFile]]


@pytest.mark.parametrize("file_count", (1, 2))
def test_upload_multiple_ofiles_in_model(file_count: int) -> None:
    @post("/")
    async def handler(data: OFiles = Body(media_type=RequestEncodingType.MULTI_PART)) -> None:
        assert len(data.file_list) == file_count

        for file in data.file_list:
            assert await file.read() == b"1"

    with create_test_client([handler]) as client:
        files_to_upload = [("file_list", b"1") for _ in range(file_count)]
        response = client.post("/", files=files_to_upload)

        assert response.status_code == HTTP_201_CREATED

```

### URL to code causing the issue

_No response_

### MCVE

```python
# Your MCVE code here
```


### Steps to reproduce

```bash
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
```


### Screenshots

```bash
"![SCREENSHOT_DESCRIPTION](SCREENSHOT_LINK.png)"
```


### Logs

_No response_

### Litestar Version

2.8.2final0

### Platform

- [X] Linux
- [ ] Mac
- [ ] Windows
- [ ] Other (Please specify in the description above)

<!-- POLAR PLEDGE BADGE START -->
---
> [!NOTE]  
> While we are open for sponsoring on [GitHub Sponsors](https://github.com/sponsors/litestar-org/) and 
> [OpenCollective](https://opencollective.com/litestar), we also utilize [Polar.sh](https://polar.sh/) to engage in pledge-based sponsorship.
>
> Check out all issues funded or available for funding [on our Polar.sh dashboard](https://polar.sh/litestar-org)
> * If you would like to see an issue prioritized, make a pledge towards it!
> * We receive the pledge once the issue is completed & verified
> * This, along with engagement in the community, helps us know which features are a priority to our users.

<a href="https://polar.sh/litestar-org/litestar/issues/3407">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/litestar-org/litestar/issues/3407/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/litestar-org/litestar/issues/3407/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->



Discussion:
May need to be a separate issue, but `| None` also removes the `Upload File` button in OpenAPI schema docs :(

```py
from litestar import Controller, Litestar, post
from litestar.datastructures import UploadFile
from litestar.enums import RequestEncodingType
from litestar.params import Body


class Files(Controller):
    @post(path="/files/upload")
    async def upload_files(
        self,
        data: UploadFile | None = Body(media_type=RequestEncodingType.MULTI_PART),
    ) -> str:
        return data


app = Litestar(route_handlers=[Files])
```

Without `| None`
<img width="805" alt="image" src="https://github.com/litestar-org/litestar/assets/45884264/b6cb0694-aea9-4a82-929c-cdc79fb447fe">

With `| None`

<img width="1454" alt="image" src="https://github.com/litestar-org/litestar/assets/45884264/503e2f27-1319-4820-83bd-40438c66136d">


Resolving patch:
diff --git a/litestar/_kwargs/extractors.py b/litestar/_kwargs/extractors.py
--- a/litestar/_kwargs/extractors.py
+++ b/litestar/_kwargs/extractors.py
@@ -16,7 +16,8 @@
 from litestar.exceptions import ValidationException
 from litestar.params import BodyKwarg
 from litestar.types import Empty
-from litestar.utils.predicates import is_non_string_sequence
+from litestar.utils import make_non_optional_union
+from litestar.utils.predicates import is_non_string_sequence, is_optional_union
 from litestar.utils.scope.state import ScopeState
 
 if TYPE_CHECKING:
@@ -348,7 +349,10 @@ async def _extract_multipart(
 
     if field_definition.is_non_string_sequence:
         values = list(form_values.values())
-        if field_definition.has_inner_subclass_of(UploadFile) and isinstance(values[0], list):
+        if isinstance(values[0], list) and (
+            field_definition.has_inner_subclass_of(UploadFile)
+            or (field_definition.is_optional and field_definition.inner_types[0].is_non_string_sequence)
+        ):
             return values[0]
 
         return values
@@ -364,7 +368,14 @@ async def _extract_multipart(
 
     for name, tp in field_definition.get_type_hints().items():
         value = form_values.get(name)
-        if value is not None and is_non_string_sequence(tp) and not isinstance(value, list):
+        if (
+            value is not None
+            and not isinstance(value, list)
+            and (
+                is_non_string_sequence(tp)
+                or (is_optional_union(tp) and is_non_string_sequence(make_non_optional_union(tp)))
+            )
+        ):
             form_values[name] = [value]
 
     return form_values


Test patch:
diff --git a/tests/unit/test_kwargs/test_multipart_data.py b/tests/unit/test_kwargs/test_multipart_data.py
--- a/tests/unit/test_kwargs/test_multipart_data.py
+++ b/tests/unit/test_kwargs/test_multipart_data.py
@@ -394,10 +394,15 @@ async def hello_world(data: UploadFile = Body(media_type=RequestEncodingType.MUL
         assert response.status_code == HTTP_201_CREATED
 
 
+@pytest.mark.parametrize("optional", [True, False])
 @pytest.mark.parametrize("file_count", (1, 2))
-def test_upload_multiple_files(file_count: int) -> None:
-    @post("/")
-    async def handler(data: List[UploadFile] = Body(media_type=RequestEncodingType.MULTI_PART)) -> None:
+def test_upload_multiple_files(file_count: int, optional: bool) -> None:
+    annotation = List[UploadFile]
+    if optional:
+        annotation = Optional[annotation]  # type: ignore[misc, assignment]
+
+    @post("/", signature_namespace={"annotation": annotation})
+    async def handler(data: annotation = Body(media_type=RequestEncodingType.MULTI_PART)) -> None:  # pyright: ignore[reportGeneralTypeIssues]
         assert len(data) == file_count
 
         for file in data:
@@ -415,13 +420,20 @@ class Files:
     file_list: List[UploadFile]
 
 
+# https://github.com/litestar-org/litestar/issues/3407
+@dataclass
+class OptionalFiles:
+    file_list: Optional[List[UploadFile]]
+
+
+@pytest.mark.parametrize("file_model", (Files, OptionalFiles))
 @pytest.mark.parametrize("file_count", (1, 2))
-def test_upload_multiple_files_in_model(file_count: int) -> None:
-    @post("/")
-    async def handler(data: Files = Body(media_type=RequestEncodingType.MULTI_PART)) -> None:
-        assert len(data.file_list) == file_count
+def test_upload_multiple_files_in_model(file_count: int, file_model: type[Files | OptionalFiles]) -> None:
+    @post("/", signature_namespace={"file_model": file_model})
+    async def handler(data: file_model = Body(media_type=RequestEncodingType.MULTI_PART)) -> None:  # type: ignore[valid-type]
+        assert len(data.file_list) == file_count  # type: ignore[attr-defined]
 
-        for file in data.file_list:
+        for file in data.file_list:  # type: ignore[attr-defined]
             assert await file.read() == b"1"
 
     with create_test_client([handler]) as client: