← eval_submission_nothink

fastapi_14303

resolved RESOLVED HIGH_ERROR_RATE PASS · 9 tool calls · 21 s · fastapi/fastapi

Task input

🐛 Fix parsing extra `Form` parameter list

## Description

Currently if we declare Form parameters as model with `extra="allow"` and then pass extra parameter with multiple values, only last value of such parameter will be passed to model validator:

```py
from fastapi import FastAPI, Form
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class Model(BaseModel):
    param: str

    model_config = {"extra": "allow"}

@app.post("/")
async def model_with_extra(data: Model = Form()):
    return data


def test_pass_extra_list():
    client = TestClient(app)
    resp = client.post(
        "/",
        data={
            "param": "123",
            "param2": ["456", "789"],  # Pass a list of values as extra parameter
        },
    )
    assert resp.status_code == 200
    assert resp.json() == {
        "param": "123",
        "param2": ["456", "789"],
    }
    # AssertionError: assert
    # {'param': '123', 'param2': '789'}
    # ==
    # {'param': '123', 'param2': ['456', '789']}
```

The part of code that adds extra parameters is:

https://github.com/fastapi/fastapi/blob/972a967d5d3fa24d450637c36c6b7ad1f1d8c1b6/fastapi/dependencies/utils.py#L901-L903

The problem is in `received_body.items()` - it returns only last value for each key.
To fix this we need to iterate over all keys in `received_body` and get the values using `received_body.getlist(key)`:

```py
    for key in received_body.keys():
        if key not in values:
            param_values = received_body.getlist(key)
            if len(param_values) == 1:
                values[key] = param_values[0]
            else:
                values[key] = param_values
```

## Alternative implementation

This solution parses single value as string

`extra=string` -> `{"extra": "string"}`

but multiple values as list of strings

`extra=string1&extra=string2` -> `{"extra": ["string1", "string2"]}`

We can consider always parse extra parameters as lists

```py
    for key in received_body.keys():
        if key not in values:
            values[key] = received_body.getlist(key)
```

This way for single value we would also have list with one element:

`extra=string` -> `{"extra": ["string"]}`

It's more consistent, but probably not what people expect for single value.

I think we should go with current implementation (single value -> single string, multiple values -> list of strings)

## Other parameters

Query\Header\Cookie parameters likely have the same issue, I will look at it a bit later and open separate PR to fix this

Tool calls (10)

#ToolArgumentsResult
1run_command
{
 "command": "grep -rn \"received_body.items()\" fastapi/dependencies/utils.py"
}
{"status": "ok", "stdout": "906: for key, value in received_body.items():\n", "exit_code": 0}
full
{"status": "ok", "stdout": "906:    for key, value in received_body.items():\n", "exit_code": 0}
2read_file
{
 "end_line": 915,
 "filepath": "fastapi/dependencies/utils.py",
 "start_line": 890
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": " assert isinstance(value, sequence_types) # type: ignore[arg-type]\n results: List[Uni
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "content": "            assert isinstance(value, sequence_types)  # type: ignore[arg-type]\n            results: List[Union[bytes, str]] = []\n\n            async def process_fn(\n                fn: Callable[[], Coroutine[Any, Any, Any]],\n            ) -> None:\n                result = await fn()\n                results.append(result)  # noqa: B023\n\n            async with anyio.create_task_group() as tg:\n                for sub_value in value:\n                    tg.start_soon(process_fn, sub_value.read)\n            value = serialize_sequence_value(field=field, value=results)\n        if value is not None:\n            values[field.alias] = value\n    field_aliases = {field.alias for field in body_fields}\n    for key, value in received_body.items():\n        if key not in field_aliases:\n            values[key] = value\n    return values\n\n\nasync def request_body_to_args(\n    body_fields: List[ModelField],\n    received_body: Optional[Union[Dict[str, Any], FormData]],\n    embed_body_fields: bool,\n", "start_line": 890, "end_line": 915, "total_lines": 1025, "is_truncated": false}
3write_file
{
 "content": "from fastapi import FastAPI, Form\nfrom fastapi.testclient import TestClient\nfrom pydantic import BaseModel\n\napp = FastAPI()\n\n\nclass Model(BaseModel):\n    param: str\n\n    model_config = {\"extra\": \"allow\"}\n\n@app.post(\"/\")\nasync def model_with_extra(data: Model = Form()):\n    return data\n\n\ndef test_pass_extra_list():\n    client = TestClient(app)\n    resp = client.post(\n        \"/\",\n        data={\n            \"param\": \"123\",\n            \"param2\": [\"456\", \"789\"],  # Pass a list of values as extra parameter\n        },\n    )\n    assert resp.status_code == 200\n    assert resp.json() == {\n        \"param\": \"123\",\n        \"param2\": [\"456\", \"789\"],\n    }\n\nif __name__ == \"__main__\":\n    test_pass_extra_list()\n    print(\"Test passed!\")\n",
 "filepath": "/tmp/repro.py"
}
{"status": "ok", "filepath": "/tmp/repro.py", "size": 732}
full
{"status": "ok", "filepath": "/tmp/repro.py", "size": 732}
4run_command
{
 "command": "python3 /tmp/repro.py"
}
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "python3: can't open f
full
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "python3: can't open file '/tmp/swe_work/eval_submission_nothink/fastapi_14303/a/tmp/repro.py': [Errno 2] No such file or directory\n", "exit_code": 2}}
5run_command
{
 "command": "python3 /workspace/tmp/repro.py"
}
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "python3: can't open f
full
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "python3: can't open file '/tmp/swe_work/eval_submission_nothink/fastapi_14303/a/workspace/tmp/repro.py': [Errno 2] No such file or directory\n", "exit_code": 2}}
6run_command
{
 "command": "mkdir -p /tmp && python3 /tmp/repro.py"
}
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "python3: can't open f
full
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "python3: can't open file '/tmp/swe_work/eval_submission_nothink/fastapi_14303/a/tmp/repro.py': [Errno 2] No such file or directory\n", "exit_code": 2}}
7run_command
{
 "command": "ls -R /workspace"
}
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "ls: cannot access '/t
full
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 2", "details": {"stdout": "", "stderr": "ls: cannot access '/tmp/swe_work/eval_submission_nothink/fastapi_14303/a/workspace': No such file or directory\n", "exit_code": 2}}
8run_command
{
 "command": "python3 -c \"from fastapi import FastAPI, Form; from fastapi.testclient import TestClient; from pydantic import BaseModel; app = FastAPI(); class Model(BaseModel): param: str; model_config = {'extra': 'allow'}; @app.post('/')\\nasync def model_with_extra(data: Model = Form()): return data; client = TestClient(app); resp = client.post('/', data={'param': '123', 'param2': ['456', '789']}); print(resp.json())\""
}
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 1", "details": {"stdout": "", "stderr": " File \"<string>\", l
full
{"status": "error", "error_type": "CommandError", "error_message": "Command failed with exit code 1", "details": {"stdout": "", "stderr": "  File \"<string>\", line 1\n    from fastapi import FastAPI, Form; from fastapi.testclient import TestClient; from pydantic import BaseModel; app = FastAPI(); class Model(BaseModel): param: str; model_config = {'extra': 'allow'}; @app.post('/')\\nasync def model_with_extra(data: Model = Form()): return data; client = TestClient(app); resp = client.post('/', data={'param': '123', 'param2': ['456', '789']}); print(resp.json())\n                                                                                                                                   ^^^^^\nSyntaxError: invalid syntax\n", "exit_code": 1}}
9edit_file
{
 "filepath": "fastapi/dependencies/utils.py",
 "new_string": "    for key in received_body.keys():\n        if key not in field_aliases:\n            param_values = received_body.getlist(key)\n            if len(param_values) == 1:\n                values[key] = param_values[0]\n            else:\n                values[key] = param_values",
 "old_string": "    for key, value in received_body.items():\n        if key not in field_aliases:\n            values[key] = value"
}
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "occurrences": 1, "strategy": "exact", "diff": "--- a/fastapi/dependencies/utils.py\n+++ b/fastapi
full
{"status": "ok", "filepath": "fastapi/dependencies/utils.py", "occurrences": 1, "strategy": "exact", "diff": "--- a/fastapi/dependencies/utils.py\n+++ b/fastapi/dependencies/utils.py\n@@ -903,9 +903,13 @@\n         if value is not None:\n             values[field.alias] = value\n     field_aliases = {field.alias for field in body_fields}\n-    for key, value in received_body.items():\n+    for key in received_body.keys():\n         if key not in field_aliases:\n-            values[key] = value\n+            param_values = received_body.getlist(key)\n+            if len(param_values) == 1:\n+                values[key] = param_values[0]\n+            else:\n+                values[key] = param_values\n     return values\n \n \n", "is_truncated": false}
10submit_patch
[]
{"status": "ok", "patch_size": 1649, "files_changed": 2}
full
{"status": "ok", "patch_size": 1649, "files_changed": 2}

Patch

diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index 0f25a3c3..2b2e6c5a 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -903,9 +903,13 @@ async def _extract_form_body(
         if value is not None:
             values[field.alias] = value
     field_aliases = {field.alias for field in body_fields}
-    for key, value in received_body.items():
+    for key in received_body.keys():
         if key not in field_aliases:
-            values[key] = value
+            param_values = received_body.getlist(key)
+            if len(param_values) == 1:
+                values[key] = param_values[0]
+            else:
+                values[key] = param_values
     return values
 
 
diff --git a/tmp/repro.py b/tmp/repro.py
new file mode 100644
index 00000000..f000a37e
--- /dev/null
+++ b/tmp/repro.py
@@ -0,0 +1,35 @@
+from fastapi import FastAPI, Form
+from fastapi.testclient import TestClient
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class Model(BaseModel):
+    param: str
+
+    model_config = {"extra": "allow"}
+
+@app.post("/")
+async def model_with_extra(data: Model = Form()):
+    return data
+
+
+def test_pass_extra_list():
+    client = TestClient(app)
+    resp = client.post(
+        "/",
+        data={
+            "param": "123",
+            "param2": ["456", "789"],  # Pass a list of values as extra parameter
+        },
+    )
+    assert resp.status_code == 200
+    assert resp.json() == {
+        "param": "123",
+        "param2": ["456", "789"],
+    }
+
+if __name__ == "__main__":
+    test_pass_extra_list()
+    print("Test passed!")

Test output

show
......                                                                   [100%]
=============================== warnings summary ===============================
../../../../../../kaggle/tmp/envs/overlays/starlette-0.50.0-py3-none-any/starlette/testclient.py:45
  /kaggle/tmp/envs/overlays/starlette-0.50.0-py3-none-any/starlette/testclient.py:45: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
    _PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
6 passed, 1 warning in 0.74s