diff --git a/playwright/_impl/_fetch.py b/playwright/_impl/_fetch.py index 50bf4ad4a..972ec4457 100644 --- a/playwright/_impl/_fetch.py +++ b/playwright/_impl/_fetch.py @@ -14,6 +14,7 @@ import base64 import json +import mimetypes import pathlib import typing from pathlib import Path @@ -32,6 +33,7 @@ ) from playwright._impl._connection import ChannelOwner, from_channel from playwright._impl._errors import is_target_closed_error +from playwright._impl._form_data import FormData from playwright._impl._helper import ( Error, NameValue, @@ -51,9 +53,9 @@ from playwright._impl._playwright import Playwright -FormType = Dict[str, Union[bool, float, str]] +FormType = Union[Dict[str, Union[bool, float, str]], FormData] DataType = Union[Any, bytes, str] -MultipartType = Dict[str, Union[bytes, bool, float, str, FilePayload]] +MultipartType = Union[Dict[str, Union[bytes, bool, float, str, FilePayload]], FormData] ParamsType = Union[Dict[str, Union[bool, float, str]], str] @@ -212,7 +214,7 @@ async def patch( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -241,7 +243,7 @@ async def put( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -270,7 +272,7 @@ async def post( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -300,7 +302,7 @@ async def fetch( headers: Headers = None, data: DataType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -341,7 +343,7 @@ async def _inner_fetch( data: DataType = None, params: ParamsType = None, form: FormType = None, - multipart: Dict[str, Union[bytes, bool, float, str, FilePayload]] = None, + multipart: MultipartType = None, timeout: float = None, failOnStatusCode: bool = None, ignoreHTTPSErrors: bool = None, @@ -381,21 +383,36 @@ async def _inner_fetch( else: raise Error(f"Unsupported 'data' type: {type(data)}") elif form: - form_data = object_to_array(form) + if isinstance(form, FormData): + form_data = [] + for fd_name, fd_value in form._fields: + if isinstance(fd_value, (pathlib.Path, dict)): + raise Error( + f"Form field {fd_name!r} must be a string, number or boolean. Use 'multipart' for file uploads." + ) + form_data.append(NameValue(name=fd_name, value=str(fd_value))) + else: + form_data = object_to_array(form) elif multipart: multipart_data = [] - # Convert file-like values to ServerFilePayload structs. - for name, value in multipart.items(): - if is_file_payload(value): - payload = cast(FilePayload, value) - assert isinstance( - payload["buffer"], bytes - ), f"Unexpected buffer type of 'data.{name}'" + if isinstance(multipart, FormData): + for fd_name, fd_value in multipart._fields: multipart_data.append( - FormField(name=name, file=file_payload_to_json(payload)) + await _form_data_field_to_form_field(fd_name, fd_value) ) - elif isinstance(value, str): - multipart_data.append(FormField(name=name, value=value)) + else: + # Convert file-like values to ServerFilePayload structs. + for name, value in multipart.items(): + if is_file_payload(value): + payload = cast(FilePayload, value) + assert isinstance( + payload["buffer"], bytes + ), f"Unexpected buffer type of 'data.{name}'" + multipart_data.append( + FormField(name=name, file=file_payload_to_json(payload)) + ) + elif isinstance(value, str): + multipart_data.append(FormField(name=name, value=value)) if ( post_data_buffer is None and json_data is None @@ -450,6 +467,28 @@ def file_payload_to_json(payload: FilePayload) -> ServerFilePayload: ) +async def _form_data_field_to_form_field(name: str, value: Any) -> FormField: + if isinstance(value, pathlib.Path): + mime_type, _ = mimetypes.guess_type(str(value)) + return FormField( + name=name, + file=ServerFilePayload( + name=value.name, + mimeType=mime_type or "application/octet-stream", + buffer=base64.b64encode(await async_readfile(str(value))).decode(), + ), + ) + if is_file_payload(value): + payload = cast(FilePayload, value) + assert isinstance( + payload["buffer"], bytes + ), f"Unexpected buffer type of form field {name!r}" + return FormField(name=name, file=file_payload_to_json(payload)) + if isinstance(value, (str, int, float, bool)): + return FormField(name=name, value=str(value)) + raise Error(f"Unsupported form field {name!r} value type: {type(value).__name__}") + + class APIResponse: def __init__(self, context: APIRequestContext, initializer: Dict) -> None: self._loop = context._loop diff --git a/playwright/_impl/_form_data.py b/playwright/_impl/_form_data.py new file mode 100644 index 000000000..384806abd --- /dev/null +++ b/playwright/_impl/_form_data.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pathlib +from typing import List, Tuple, Union + +from playwright._impl._api_structures import FilePayload + +FormDataValue = Union[bool, float, str, pathlib.Path, FilePayload] + + +class FormData: + def __init__(self) -> None: + self._fields: List[Tuple[str, FormDataValue]] = [] + + def set(self, name: str, value: FormDataValue) -> "FormData": + self._fields = [(n, v) for (n, v) in self._fields if n != name] + self._fields.append((name, value)) + return self + + def append(self, name: str, value: FormDataValue) -> "FormData": + self._fields.append((name, value)) + return self diff --git a/playwright/async_api/__init__.py b/playwright/async_api/__init__.py index 6508994c3..6808ed6fb 100644 --- a/playwright/async_api/__init__.py +++ b/playwright/async_api/__init__.py @@ -22,6 +22,7 @@ import playwright._impl._api_structures import playwright._impl._errors +import playwright._impl._form_data import playwright.async_api._generated from playwright._impl._assertions import ( APIResponseAssertions as APIResponseAssertionsImpl, @@ -69,6 +70,7 @@ Cookie = playwright._impl._api_structures.Cookie FilePayload = playwright._impl._api_structures.FilePayload +FormData = playwright._impl._form_data.FormData FloatRect = playwright._impl._api_structures.FloatRect Geolocation = playwright._impl._api_structures.Geolocation HttpCredentials = playwright._impl._api_structures.HttpCredentials @@ -171,6 +173,7 @@ def __call__( "FileChooser", "FilePayload", "FloatRect", + "FormData", "Frame", "FrameLocator", "Geolocation", diff --git a/playwright/async_api/_generated.py b/playwright/async_api/_generated.py index 5ca533ef2..304f3559d 100644 --- a/playwright/async_api/_generated.py +++ b/playwright/async_api/_generated.py @@ -70,6 +70,7 @@ from playwright._impl._fetch import APIRequestContext as APIRequestContextImpl from playwright._impl._fetch import APIResponse as APIResponseImpl from playwright._impl._file_chooser import FileChooser as FileChooserImpl +from playwright._impl._form_data import FormData from playwright._impl._frame import Frame as FrameImpl from playwright._impl._input import Keyboard as KeyboardImpl from playwright._impl._input import Mouse as MouseImpl @@ -18913,9 +18914,14 @@ async def delete( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -18942,14 +18948,16 @@ async def delete( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -18994,9 +19002,14 @@ async def head( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19023,14 +19036,16 @@ async def head( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19075,9 +19090,14 @@ async def get( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19116,14 +19136,16 @@ async def get( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19168,9 +19190,14 @@ async def patch( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19197,14 +19224,16 @@ async def patch( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19249,9 +19278,14 @@ async def put( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19278,14 +19312,16 @@ async def put( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19330,9 +19366,14 @@ async def post( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19390,14 +19431,16 @@ async def post( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19443,9 +19486,14 @@ async def fetch( method: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19489,14 +19537,16 @@ async def fetch( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] diff --git a/playwright/sync_api/__init__.py b/playwright/sync_api/__init__.py index 6015bc1be..97b3baf9d 100644 --- a/playwright/sync_api/__init__.py +++ b/playwright/sync_api/__init__.py @@ -22,6 +22,7 @@ import playwright._impl._api_structures import playwright._impl._errors +import playwright._impl._form_data import playwright.sync_api._generated from playwright._impl._assertions import ( APIResponseAssertions as APIResponseAssertionsImpl, @@ -69,6 +70,7 @@ Cookie = playwright._impl._api_structures.Cookie FilePayload = playwright._impl._api_structures.FilePayload +FormData = playwright._impl._form_data.FormData FloatRect = playwright._impl._api_structures.FloatRect Geolocation = playwright._impl._api_structures.Geolocation HttpCredentials = playwright._impl._api_structures.HttpCredentials @@ -170,6 +172,7 @@ def __call__( "FileChooser", "FilePayload", "FloatRect", + "FormData", "Frame", "FrameLocator", "Geolocation", diff --git a/playwright/sync_api/_generated.py b/playwright/sync_api/_generated.py index f7a8a2c24..bce97403a 100644 --- a/playwright/sync_api/_generated.py +++ b/playwright/sync_api/_generated.py @@ -64,6 +64,7 @@ from playwright._impl._fetch import APIRequestContext as APIRequestContextImpl from playwright._impl._fetch import APIResponse as APIResponseImpl from playwright._impl._file_chooser import FileChooser as FileChooserImpl +from playwright._impl._form_data import FormData from playwright._impl._frame import Frame as FrameImpl from playwright._impl._input import Keyboard as KeyboardImpl from playwright._impl._input import Mouse as MouseImpl @@ -18956,9 +18957,14 @@ def delete( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -18985,14 +18991,16 @@ def delete( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19039,9 +19047,14 @@ def head( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19068,14 +19081,16 @@ def head( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19122,9 +19137,14 @@ def get( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19163,14 +19183,16 @@ def get( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19217,9 +19239,14 @@ def patch( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19246,14 +19273,16 @@ def patch( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19300,9 +19329,14 @@ def put( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19329,14 +19363,16 @@ def put( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19383,9 +19419,14 @@ def post( ] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19443,14 +19484,16 @@ def post( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] @@ -19498,9 +19541,14 @@ def fetch( method: typing.Optional[str] = None, headers: typing.Optional[typing.Dict[str, str]] = None, data: typing.Optional[typing.Union[typing.Any, bytes, str]] = None, - form: typing.Optional[typing.Dict[str, typing.Union[str, float, bool]]] = None, + form: typing.Optional[ + typing.Union[typing.Dict[str, typing.Union[str, float, bool]], "FormData"] + ] = None, multipart: typing.Optional[ - typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]] + typing.Union[ + typing.Dict[str, typing.Union[bytes, bool, float, str, FilePayload]], + "FormData", + ] ] = None, timeout: typing.Optional[float] = None, fail_on_status_code: typing.Optional[bool] = None, @@ -19548,14 +19596,16 @@ def fetch( Allows to set post data of the request. If the data parameter is an object, it will be serialized to json string and `content-type` header will be set to `application/json` if not explicitly set. Otherwise the `content-type` header will be set to `application/octet-stream` if not explicitly set. - form : Union[Dict[str, Union[bool, float, str]], None] + form : Union[Dict[str, Union[bool, float, str]], FormData, None] Provides an object that will be serialized as html form using `application/x-www-form-urlencoded` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to - `application/x-www-form-urlencoded` unless explicitly provided. - multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], None] + `application/x-www-form-urlencoded` unless explicitly provided. Use `FormData` to send multiple values for the same + field. + multipart : Union[Dict[str, Union[bool, bytes, float, str, {name: str, mimeType: str, buffer: bytes}]], FormData, None] Provides an object that will be serialized as html form using `multipart/form-data` encoding and sent as this request body. If this parameter is specified `content-type` header will be set to `multipart/form-data` unless explicitly provided. File values can be passed as file-like object containing file name, mime-type and its content. + Use `FormData` to send multiple files in the same field. timeout : Union[float, None] Request timeout in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. fail_on_status_code : Union[bool, None] diff --git a/scripts/documentation_provider.py b/scripts/documentation_provider.py index 8016a601d..37f6ed77c 100644 --- a/scripts/documentation_provider.py +++ b/scripts/documentation_provider.py @@ -535,7 +535,7 @@ def print_remainder(self) -> None: for [member_name, member] in clazz["members"].items(): if member.get("deprecated"): continue - if class_name in ["Error"]: + if class_name in ["Error", "FormData"]: continue entry = f"{class_name}.{member_name}" if entry not in self.printed_entries: diff --git a/scripts/generate_api.py b/scripts/generate_api.py index 9d217b7c5..b8c45d2d4 100644 --- a/scripts/generate_api.py +++ b/scripts/generate_api.py @@ -253,6 +253,7 @@ def return_value(value: Any) -> List[str]: from playwright._impl._tracing import Tracing as TracingImpl from playwright._impl._locator import Locator as LocatorImpl, FrameLocator as FrameLocatorImpl from playwright._impl._errors import Error +from playwright._impl._form_data import FormData from playwright._impl._fetch import APIRequest as APIRequestImpl, APIResponse as APIResponseImpl, APIRequestContext as APIRequestContextImpl from playwright._impl._assertions import PageAssertions as PageAssertionsImpl, LocatorAssertions as LocatorAssertionsImpl, APIResponseAssertions as APIResponseAssertionsImpl """ diff --git a/tests/async/test_fetch_browser_context.py b/tests/async/test_fetch_browser_context.py index cc4e2b555..f61f12eb0 100644 --- a/tests/async/test_fetch_browser_context.py +++ b/tests/async/test_fetch_browser_context.py @@ -15,12 +15,20 @@ import asyncio import base64 import json +from pathlib import Path from typing import Any, Callable, cast from urllib.parse import parse_qs import pytest -from playwright.async_api import Browser, BrowserContext, Error, FilePayload, Page +from playwright.async_api import ( + Browser, + BrowserContext, + Error, + FilePayload, + FormData, + Page, +) from tests.server import Server, TestServerRequest from tests.utils import must @@ -332,6 +340,96 @@ async def test_should_support_multipart_form_data( assert request.args[b"file"][0] == file["buffer"] +async def test_should_support_form_data_with_repeated_keys( + context: BrowserContext, server: Server +) -> None: + form = FormData() + form.append("name", "John") + form.append("name", "Doe") + form.set("email", "john@example.com") + [request, _] = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.post(server.EMPTY_PAGE, form=form), + ) + assert request.getHeader("Content-Type") == "application/x-www-form-urlencoded" + params = parse_qs(must(request.post_body)) + assert params[b"name"] == [b"John", b"Doe"] + assert params[b"email"] == [b"john@example.com"] + + +async def test_should_support_form_data_set_overwrites_previous_values( + context: BrowserContext, server: Server +) -> None: + form = FormData() + form.append("name", "first") + form.append("name", "second") + form.set("name", "final") + form.set("age", 30) + [request, _] = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.post(server.EMPTY_PAGE, form=form), + ) + params = parse_qs(must(request.post_body)) + assert params[b"name"] == [b"final"] + assert params[b"age"] == [b"30"] + + +async def test_should_reject_file_value_in_form( + context: BrowserContext, server: Server, tmp_path: Path +) -> None: + file = tmp_path / "f.txt" + file.write_bytes(b"hello") + form = FormData() + form.set("attachment", file) + with pytest.raises(Error, match="Use 'multipart' for file uploads"): + await context.request.post(server.EMPTY_PAGE, form=form) + + +async def test_should_support_multipart_form_data_with_multiple_files_in_one_field( + context: BrowserContext, server: Server +) -> None: + file1: FilePayload = { + "name": "f1.txt", + "mimeType": "text/plain", + "buffer": b"file 1 content", + } + file2: FilePayload = { + "name": "f2.txt", + "mimeType": "text/plain", + "buffer": b"file 2 content", + } + form = FormData() + form.append("files", file1) + form.append("files", file2) + form.set("user", "alice") + [request, _] = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.post(server.EMPTY_PAGE, multipart=form), + ) + assert cast(str, request.getHeader("Content-Type")).startswith( + "multipart/form-data; " + ) + assert request.args[b"user"] == [b"alice"] + assert request.args[b"files"] == [file1["buffer"], file2["buffer"]] + + +async def test_should_support_multipart_form_data_with_path_value( + context: BrowserContext, server: Server, tmp_path: Path +) -> None: + file = tmp_path / "data.csv" + file.write_bytes(b"a,b,c\n1,2,3\n") + form = FormData() + form.set("attachment", file) + [request, _] = await asyncio.gather( + server.wait_for_request("/empty.html"), + context.request.post(server.EMPTY_PAGE, multipart=form), + ) + assert cast(str, request.getHeader("Content-Type")).startswith( + "multipart/form-data; " + ) + assert request.args[b"attachment"] == [b"a,b,c\n1,2,3\n"] + + async def test_should_add_default_headers( context: BrowserContext, page: Page, server: Server ) -> None: diff --git a/tests/sync/test_fetch_browser_context.py b/tests/sync/test_fetch_browser_context.py index e4d880631..affca0e0d 100644 --- a/tests/sync/test_fetch_browser_context.py +++ b/tests/sync/test_fetch_browser_context.py @@ -13,12 +13,19 @@ # limitations under the License. import json -from typing import Any, Dict, List +from pathlib import Path +from typing import Any, Dict, List, cast from urllib.parse import parse_qs import pytest -from playwright.sync_api import BrowserContext, Error, FilePayload, Page +from playwright.sync_api import ( + BrowserContext, + Error, + FilePayload, + FormData, + Page, +) from tests.server import Server from tests.utils import must @@ -255,6 +262,72 @@ def test_should_support_multipart_form_data( assert server_req.value.args[b"file"][0] == file["buffer"] +def test_should_support_form_data_with_repeated_keys( + context: BrowserContext, server: Server +) -> None: + form = FormData() + form.append("name", "John") + form.append("name", "Doe") + form.set("email", "john@example.com") + with server.expect_request("/empty.html") as server_req: + context.request.post(server.EMPTY_PAGE, form=form) + params = parse_qs(must(server_req.value.post_body)) + assert params[b"name"] == [b"John", b"Doe"] + assert params[b"email"] == [b"john@example.com"] + + +def test_should_reject_file_value_in_form( + context: BrowserContext, server: Server, tmp_path: Path +) -> None: + file = tmp_path / "f.txt" + file.write_bytes(b"hello") + form = FormData() + form.set("attachment", file) + with pytest.raises(Error, match="Use 'multipart' for file uploads"): + context.request.post(server.EMPTY_PAGE, form=form) + + +def test_should_support_multipart_form_data_with_multiple_files_in_one_field( + context: BrowserContext, server: Server +) -> None: + file1: FilePayload = { + "name": "f1.txt", + "mimeType": "text/plain", + "buffer": b"file 1 content", + } + file2: FilePayload = { + "name": "f2.txt", + "mimeType": "text/plain", + "buffer": b"file 2 content", + } + form = FormData() + form.append("files", file1) + form.append("files", file2) + form.set("user", "alice") + with server.expect_request("/empty.html") as server_req: + context.request.post(server.EMPTY_PAGE, multipart=form) + assert cast(str, server_req.value.getHeader("Content-Type")).startswith( + "multipart/form-data; " + ) + assert server_req.value.args[b"user"] == [b"alice"] + assert server_req.value.args[b"files"] == [file1["buffer"], file2["buffer"]] + + +def test_should_support_multipart_form_data_with_path_value( + context: BrowserContext, server: Server, tmp_path: Path +) -> None: + file = tmp_path / "data.csv" + file.write_bytes(b"a,b,c\n1,2,3\n") + form = FormData() + form.set("attachment", file) + with server.expect_request("/empty.html") as server_req: + context.request.post(server.EMPTY_PAGE, multipart=form) + assert cast(str, server_req.value.getHeader("Content-Type")).startswith( + "multipart/form-data; " + ) + assert server_req.value.args[b"attachment"] == [b"a,b,c\n1,2,3\n"] + + def test_should_add_default_headers( context: BrowserContext, page: Page, server: Server ) -> None: