resolved RESOLVED PASS · 13 tool calls · 120 s · fastapi/fastapi
🐛 Fix support for form values with empty strings interpreted as missing (`None` if that's the default), for compatibility with HTML forms There was a bug in the validation of form data for optional params with None default value. This is a regression that was introduced by https://github.com/fastapi/fastapi/pull/12134. ## Explaination: The `_get_multidict_value` uses `None` to represent both the absence a value in the dict, and a default value of `None` for that field. When the values are collected in `_extract_form_body`, they are only added to the values if they are not `None`. Later (#12134), some code was added that attempted to complement the model values with values not explicitely in the model, if values for those fields were not already processed earlier. The issue is that since we didn't add values to the initial value dict if they were none, we attempted to add them again in that second iteration. This generally wasn't an issue, except for the fast that a form field set to the empty string (`""`) is interpreted as the absence of that field. This resulted in `_get_multidict_value` picking the default value, that would happen to be `None`, so it wasn't added to the values dict in the first iteration. In the second iteration, we would find that value in the request params again, but set to `""`, and add that to the initial value dict. Later, validation would choke on that empty string. The fix is to keep track of already visited field, rather that relying on the content of the values dict to complement the missing field. Such thing was already done in `request_params_to_args` for example. closes #13533
| # | Tool | Arguments | Result |
|---|---|---|---|
| 1 | run_command | {
"command": "grep -rn \"_extract_form_body\" ."
} | {"status": "ok", "stdout": "./fastapi/dependencies/utils.py:869:async def _extract_form_body(\n./fastapi/dependencies/utils.py:933: body_to_process = await _extfull{"status": "ok", "stdout": "./fastapi/dependencies/utils.py:869:async def _extract_form_body(\n./fastapi/dependencies/utils.py:933: body_to_process = await _extract_form_body(fields_to_extract, received_body)\n", "stderr": "", "exit_code": 0} |
| 2 | run_command | {
"command": "sed -n '869,940p' fastapi/dependencies/utils.py"
} | {"status": "ok", "stdout": "async def _extract_form_body(\n body_fields: List[ModelField],\n received_body: FormData,\n) -> Dict[str, Any]:\n values = {}\n\n fofull{"status": "ok", "stdout": "async def _extract_form_body(\n body_fields: List[ModelField],\n received_body: FormData,\n) -> Dict[str, Any]:\n values = {}\n\n for field in body_fields:\n value = _get_multidict_value(field, received_body)\n field_info = field.field_info\n if (\n isinstance(field_info, (params.File, temp_pydantic_v1_params.File))\n and is_bytes_field(field)\n and isinstance(value, UploadFile)\n ):\n value = await value.read()\n elif (\n is_bytes_sequence_field(field)\n and isinstance(field_info, (params.File, temp_pydantic_v1_params.File))\n and value_is_sequence(value)\n ):\n # For types\n 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 for key, value in received_body.items():\n if key not in values:\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) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:\n values: Dict[str, Any] = {}\n errors: List[Dict[str, Any]] = []\n assert body_fields, \"request_body_to_args() should be called with fields\"\n single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields\n first_fiel |
| 3 | run_command | {
"command": "grep -n \"_get_multidict_value\" fastapi/dependencies/utils.py"
} | {"status": "ok", "stdout": "723:def _get_multidict_value(\n788: value = _get_multidict_value(field, received_params, alias=alias)\n810: value = _get_multidict_vfull{"status": "ok", "stdout": "723:def _get_multidict_value(\n788: value = _get_multidict_value(field, received_params, alias=alias)\n810: value = _get_multidict_value(field, received_params)\n876: value = _get_multidict_value(field, received_body)\n", "stderr": "", "exit_code": 0} |
| 4 | run_command | {
"command": "sed -n '723,780p' fastapi/dependencies/utils.py"
} | {"status": "ok", "stdout": "def _get_multidict_value(\n field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None\n) -> Any:\n alias = alias full{"status": "ok", "stdout": "def _get_multidict_value(\n field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None\n) -> Any:\n alias = alias or field.alias\n if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):\n value = values.getlist(alias)\n else:\n value = values.get(alias, None)\n if (\n value is None\n or (\n isinstance(field.field_info, (params.Form, temp_pydantic_v1_params.Form))\n and isinstance(value, str) # For type checks\n and value == \"\"\n )\n or (is_sequence_field(field) and len(value) == 0)\n ):\n if field.required:\n return\n else:\n return deepcopy(field.default)\n return value\n\n\ndef request_params_to_args(\n fields: Sequence[ModelField],\n received_params: Union[Mapping[str, Any], QueryParams, Headers],\n) -> Tuple[Dict[str, Any], List[Any]]:\n values: Dict[str, Any] = {}\n errors: List[Dict[str, Any]] = []\n\n if not fields:\n return values, errors\n\n first_field = fields[0]\n fields_to_extract = fields\n single_not_embedded_field = False\n default_convert_underscores = True\n if len(fields) == 1 and lenient_issubclass(first_field.type_, BaseModel):\n fields_to_extract = get_cached_model_fields(first_field.type_)\n single_not_embedded_field = True\n # If headers are in a Pydantic model, the way to disable convert_underscores\n # would be with Header(convert_underscores=False) at the Pydantic model level\n default_convert_underscores = getattr(\n first_field.field_info, \"convert_underscores\", True\n )\n\n params_to_process: Dict[str, Any] = {}\n\n processed_keys = set()\n\n for field in fields_to_extract:\n alias = None\n if isinstance(received_params, Headers):\n # Handle fields extracted from a Pydantic Model for a header, each field\n |
| 5 | run_command | {
"command": "python3 - <<'PY'\nfrom fastapi import FastAPI, Form\nfrom fastapi.testclient import TestClient\nfrom pydantic import BaseModel\nfrom typing import Optional\n\napp = FastAPI()\n\n@app.post(\"/test\")\nasync def test_endpoint(name: Optional[str] = Form(None)):\n return {\"name\": name}\n\nclient = TestClient(app)\n\n# Case 1: name is missing from form\n# Expected: name=None\nresp1 = client.post(\"/test\")\nprint(f\"Missing: {resp1.json()}\")\n\n# Case 2: name is empty string in form\n# Expected: name=None (because of the bug description: \"a form field set to the empty string ('') is interpreted as the absence of that field\")\n# Wait, the bug description says: \"Later, validation would choke on that empty string.\"\n# If name is Optional[str] = Form(None), and we send name=\"\", \n# _get_multidict_value returns None (default).\n# Then in _extract_form_body, it's not added to values.\n# Then in the second loop, it's added as \"\".\n# Then validation happens. For Optional[str], \"\" is a valid string, so it should be \"\".\n# But if it's Optional[int] = Form(None), then \"\" would cause a validation error.\n\nPY\n"
} | {"status": "ok", "stdout": "Missing: {'name': None}\n", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "Missing: {'name': None}\n", "stderr": "", "exit_code": 0} |
| 6 | run_command | {
"command": "python3 - <<'PY'\nfrom fastapi import FastAPI, Form\nfrom fastapi.testclient import TestClient\nfrom typing import Optional\n\napp = FastAPI()\n\n@app.post(\"/test\")\nasync def test_endpoint(age: Optional[int] = Form(None)):\n return {\"age\": age}\n\nclient = TestClient(app)\n\n# Case 1: age is missing from form\nresp1 = client.post(\"/test\")\nprint(f\"Missing: {resp1.json()}\")\n\n# Case 2: age is empty string in form\n# This should trigger the bug if the bug is present.\n# _get_multidict_value(field, received_body) -> returns None (default)\n# _extract_form_body:\n# 1. for field in body_fields:\n# value = None\n# if value is not None: values[field.alias] = value # Not added\n# 2. for key, value in received_body.items():\n# if key not in values:\n# values[key] = value # Added as \"\"\n# return {\"age\": \"\"}\n# Then validation: age=\"\" but expected int. Should fail.\ntry:\n resp2 = client.post(\"/test\", data={\"age\": \"\"})\n print(f\"Empty string: {resp2.json()}\")\nexcept Exception as e:\n print(f\"Error: {e}\")\nPY\n"
} | {"status": "ok", "stdout": "Missing: {'age': None}\nEmpty string: {'detail': [{'type': 'int_parsing', 'loc': ['body', 'age'], 'msg': 'Input should be a valid infull{"status": "ok", "stdout": "Missing: {'age': None}\nEmpty string: {'detail': [{'type': 'int_parsing', 'loc': ['body', 'age'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': ''}]}\n", "stderr": "", "exit_code": 0} |
| 7 | run_command | {
"command": "python3 - <<'PY'\nfrom fastapi import FastAPI, Form\nfrom fastapi.testclient import TestClient\nfrom typing import Optional\n\napp = FastAPI()\n\n@app.post(\"/test\")\nasync def test_endpoint(age: Optional[int] = Form(None)):\n return {\"age\": age}\n\nclient = TestClient(app)\n\n# Case 1: age is missing from form\nresp1 = client.post(\"/test\")\nprint(f\"Missing: {resp1.json()}\")\n\n# Case 2: age is empty string in form\nresp2 = client.post(\"/test\", data={\"age\": \"\"})\nprint(f\"Empty string: {resp2.json()}\")\nPY\n"
} | {"status": "ok", "stdout": "Missing: {'age': None}\nEmpty string: {'detail': [{'type': 'int_parsing', 'loc': ['body', 'age'], 'msg': 'Input should be a valid infull{"status": "ok", "stdout": "Missing: {'age': None}\nEmpty string: {'detail': [{'type': 'int_parsing', 'loc': ['body', 'age'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': ''}]}\n", "stderr": "", "exit_code": 0} |
| 8 | run_command | {
"command": "sed -n '869,935p' fastapi/dependencies/utils.py"
} | {"status": "ok", "stdout": "async def _extract_form_body(\n body_fields: List[ModelField],\n received_body: FormData,\n) -> Dict[str, Any]:\n values = {}\n\n fofull{"status": "ok", "stdout": "async def _extract_form_body(\n body_fields: List[ModelField],\n received_body: FormData,\n) -> Dict[str, Any]:\n values = {}\n\n for field in body_fields:\n value = _get_multidict_value(field, received_body)\n field_info = field.field_info\n if (\n isinstance(field_info, (params.File, temp_pydantic_v1_params.File))\n and is_bytes_field(field)\n and isinstance(value, UploadFile)\n ):\n value = await value.read()\n elif (\n is_bytes_sequence_field(field)\n and isinstance(field_info, (params.File, temp_pydantic_v1_params.File))\n and value_is_sequence(value)\n ):\n # For types\n 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 for key, value in received_body.items():\n if key not in values:\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) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:\n values: Dict[str, Any] = {}\n errors: List[Dict[str, Any]] = []\n assert body_fields, \"request_body_to_args() should be called with fields\"\n single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields\n first_fiel |
| 9 | run_command | {
"command": "cd /workspace && python3 - <<'PY'\nfrom pathlib import Path\np = Path(\"fastapi/dependencies/utils.py\"); s = p.read_text()\nold = \"\"\" for field in body_fields:\n value = _get_multidict_value(field, received_body)\n field_info = field.field_info\n if (\n isinstance(field_info, (params.File, temp_pydantic_v1_params.File))\n and is_bytes_field(field)\n and isinstance(value, UploadFile)\n ):\n value = await value.read()\n elif (\n is_bytes_sequence_field(field)\n and isinstance(field_info, (params.File, temp_pydantic_v1_params.File))\n and value_is_sequence(value)\n ):\n # For types\n 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 for key, value in received_body.items():\n if key not in values:\n values[key] = value\n\"\"\"\nnew = \"\"\" processed_keys = set()\n for field in body_fields:\n value = _get_multidict_value(field, received_body)\n field_info = field.field_info\n if (\n isinstance(field_info, (params.File, temp_pydantic_v1_params.File))\n and is_bytes_field(field)\n and isinstance(value, UploadFile)\n ):\n value = await value.read()\n elif (\n is_bytes_sequence_field(field)\n and isinstance(field_info, (params.File, temp_pydantic_v1_params.File))\n and value_is_sequence(value)\n ):\n # For types\n 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 processed_keys.add(field.alias)\n for key, value in received_body.items():\n if key not in processed_keys:\n values[key] = value\n\"\"\"\nassert s.count(old) == 1, (\"old text found this many times:\", s.count(old))\nt = s.replace(old, new); compile(t, str(p), \"exec\"); p.write_text(t)\nPY\n"
} | {"status": "ok", "stdout": "", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "", "stderr": "", "exit_code": 0} |
| 10 | run_command | {
"command": "python3 - <<'PY'\nfrom fastapi import FastAPI, Form\nfrom fastapi.testclient import TestClient\nfrom typing import Optional\n\napp = FastAPI()\n\n@app.post(\"/test\")\nasync def test_endpoint(age: Optional[int] = Form(None)):\n return {\"age\": age}\n\nclient = TestClient(app)\n\n# Case 1: age is missing from form\nresp1 = client.post(\"/test\")\nprint(f\"Missing: {resp1.json()}\")\n\n# Case 2: age is empty string in form\n# Now it should be None, because _get_multidict_value returns None for \"\", \n# and it's added to processed_keys, so it's NOT added in the second loop.\nresp2 = client.post(\"/test\", data={\"age\": \"\"})\nprint(f\"Empty string: {resp2.json()}\")\nPY\n"
} | {"status": "ok", "stdout": "Missing: {'age': None}\nEmpty string: {'age': None}\n", "stderr": "", "exit_code": 0}full{"status": "ok", "stdout": "Missing: {'age': None}\nEmpty string: {'age': None}\n", "stderr": "", "exit_code": 0} |
| 11 | run_command | {
"command": "python3 - <<'PY'\nfrom fastapi import FastAPI, Form\nfrom fastapi.testclient import TestClient\nfrom typing import Optional\n\napp = FastAPI()\n\n@app.post(\"/test\")\nasync def test_endpoint(age: int = Form(...)):\n return {\"age\": age}\n\nclient = TestClient(app)\n\n# Case 1: age is missing from form\n# Should fail (required)\nresp1 = client.post(\"/test\")\nprint(f\"Missing: {resp1.json()}\")\n\n# Case 2: age is empty string in form\n# Should fail (required, and \"\" is treated as missing)\nresp2 = client.post(\"/test\", data={\"age\": \"\"})\nprint(f\"Empty string: {resp2.json()}\")\nPY\n"
} | {"status": "ok", "stdout": "Missing: {'detail': [{'type': 'missing', 'loc': ['body', 'age'], 'msg': 'Field required', 'input': None}]}\nEmpty string: {'detail':full{"status": "ok", "stdout": "Missing: {'detail': [{'type': 'missing', 'loc': ['body', 'age'], 'msg': 'Field required', 'input': None}]}\nEmpty string: {'detail': [{'type': 'missing', 'loc': ['body', 'age'], 'msg': 'Field required', 'input': None}]}\n", "stderr": "", "exit_code": 0} |
| 12 | run_command | {
"command": "git diff"
} | {"status": "ok", "stdout": "diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py\nindex d43fa8a5..4e952adf 100644\n--- a/fastapi/dependencfull{"status": "ok", "stdout": "diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py\nindex d43fa8a5..4e952adf 100644\n--- a/fastapi/dependencies/utils.py\n+++ b/fastapi/dependencies/utils.py\n@@ -872,6 +872,7 @@ async def _extract_form_body(\n ) -> Dict[str, Any]:\n values = {}\n \n+ processed_keys = set()\n for field in body_fields:\n value = _get_multidict_value(field, received_body)\n field_info = field.field_info\n@@ -902,8 +903,9 @@ async def _extract_form_body(\n value = serialize_sequence_value(field=field, value=results)\n if value is not None:\n values[field.alias] = value\n+ processed_keys.add(field.alias)\n for key, value in received_body.items():\n- if key not in values:\n+ if key not in processed_keys:\n values[key] = value\n return values\n \n", "stderr": "", "exit_code": 0} |
| 13 | run_command | {
"command": "submit_patch"
} | {"status": "error", "error_type": "CommandError", "error_message": "/bin/bash: submit_patch: command not found\n", "details": {"stdout": "", "stderr": "/bin/basfull{"status": "error", "error_type": "CommandError", "error_message": "/bin/bash: submit_patch: command not found\n", "details": {"stdout": "", "stderr": "/bin/bash: submit_patch: command not found\n", "exit_code": 127}} |
| 14 | submit_patch | {} | {"status": "ok", "patch_size": 835, "files_changed": 1}full{"status": "ok", "patch_size": 835, "files_changed": 1} |
diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index d43fa8a5..4e952adf 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -872,6 +872,7 @@ async def _extract_form_body(
) -> Dict[str, Any]:
values = {}
+ processed_keys = set()
for field in body_fields:
value = _get_multidict_value(field, received_body)
field_info = field.field_info
@@ -902,8 +903,9 @@ async def _extract_form_body(
value = serialize_sequence_value(field=field, value=results)
if value is not None:
values[field.alias] = value
+ processed_keys.add(field.alias)
for key, value in received_body.items():
- if key not in values:
+ if key not in processed_keys:
values[key] = value
return values
.. [100%]
=============================== warnings summary ===============================
../../../../../../../Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.50.0-py3-none-any/starlette/testclient.py:45
/Users/jp/repos/kaggle-gemini-coding-agent-post-training/.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
2 passed, 1 warning in 0.42s