diff --git a/impit-python/python/impit/impit.pyi b/impit-python/python/impit/impit.pyi index f1667a53..2dd2abd4 100644 --- a/impit-python/python/impit/impit.pyi +++ b/impit-python/python/impit/impit.pyi @@ -518,7 +518,7 @@ class Client: def get( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -538,7 +538,7 @@ class Client: def post( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -559,7 +559,7 @@ class Client: def put( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -579,7 +579,7 @@ class Client: def patch( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -599,7 +599,7 @@ class Client: def delete( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -619,7 +619,7 @@ class Client: def head( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -639,7 +639,7 @@ class Client: def options( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -659,7 +659,7 @@ class Client: def trace( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -680,7 +680,7 @@ class Client: self, method: str, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -704,7 +704,7 @@ class Client: self, method: str, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -859,7 +859,7 @@ class AsyncClient: async def get( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -879,7 +879,7 @@ class AsyncClient: async def post( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -900,7 +900,7 @@ class AsyncClient: async def put( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -920,7 +920,7 @@ class AsyncClient: async def patch( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -940,7 +940,7 @@ class AsyncClient: async def delete( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -960,7 +960,7 @@ class AsyncClient: async def head( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -980,7 +980,7 @@ class AsyncClient: async def options( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1000,7 +1000,7 @@ class AsyncClient: async def trace( self, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1021,7 +1021,7 @@ class AsyncClient: self, method: str, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1045,7 +1045,7 @@ class AsyncClient: self, method: str, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | AsyncIterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1078,7 +1078,7 @@ class AsyncClient: def stream( method: str, url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1111,7 +1111,7 @@ def stream( def get( url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1144,7 +1144,7 @@ def get( def post( url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1177,7 +1177,7 @@ def post( def put( url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1210,7 +1210,7 @@ def put( def patch( url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1243,7 +1243,7 @@ def patch( def delete( url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1276,7 +1276,7 @@ def delete( def head( url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1309,7 +1309,7 @@ def head( def options( url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, @@ -1339,7 +1339,7 @@ def options( def trace( url: str, - content: bytes | bytearray | list[int] | None = None, + content: bytes | bytearray | list[int] | Iterator[bytes] | None = None, data: dict[str, str] | None = None, headers: dict[str, str] | None = None, timeout: float | str | None = USE_CLIENT_DEFAULT, diff --git a/impit-python/src/async_client.rs b/impit-python/src/async_client.rs index 8963371a..40cde55f 100644 --- a/impit-python/src/async_client.rs +++ b/impit-python/src/async_client.rs @@ -11,7 +11,10 @@ use pyo3::{exceptions::PyTypeError, ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{form_to_bytes, parse_timeout, RequestBody, USE_CLIENT_DEFAULT_SENTINEL}, + request::{ + async_iterator_to_bytes, form_to_bytes, iterator_to_bytes, parse_timeout, RequestBody, + USE_CLIENT_DEFAULT_SENTINEL, + }, response::ImpitPyResponse, }; @@ -135,7 +138,7 @@ impl AsyncClient { &self, py: Python<'python>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -159,7 +162,7 @@ impl AsyncClient { &self, py: Python<'python>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -183,7 +186,7 @@ impl AsyncClient { &self, py: Python<'python>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -207,7 +210,7 @@ impl AsyncClient { &self, py: Python<'python>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -231,7 +234,7 @@ impl AsyncClient { &self, py: Python<'python>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -255,7 +258,7 @@ impl AsyncClient { &self, py: Python<'python>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -279,7 +282,7 @@ impl AsyncClient { &self, py: Python<'python>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -303,7 +306,7 @@ impl AsyncClient { &self, py: Python<'python>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -328,7 +331,7 @@ impl AsyncClient { py: Python<'python>, method: &str, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -374,35 +377,34 @@ impl AsyncClient { py: Python<'python>, method: &str, url: String, - content: Option>, - mut data: Option, + content: Option>, + data: Option, headers: Option>, timeout: Option>, force_http3: Option, stream: Option, ) -> Result, PyErr> { let mut headers = headers.clone(); - - if let Some(content) = content { - data = Some(RequestBody::Bytes(content)); - } - - let body: Vec = match data { - Some(data) => match data { - RequestBody::Bytes(bytes) => Ok(bytes), - RequestBody::Form(form) => { - headers.get_or_insert_with(HashMap::new).insert( - "Content-Type".to_string(), - "application/x-www-form-urlencoded".to_string(), - ); - Ok(form_to_bytes(form)) - } - RequestBody::CatchAll(e) => Err(PyErr::new::(format!( + let data = content.or(data); + + let (async_body, body): (Option>, Vec) = match data { + Some(RequestBody::AsyncIterator(iter)) => (Some(iter.0), Vec::new()), + Some(RequestBody::Bytes(bytes)) => (None, bytes), + Some(RequestBody::Iterator(iter)) => (None, iterator_to_bytes(iter)?), + Some(RequestBody::Form(form)) => { + headers.get_or_insert_with(HashMap::new).insert( + "Content-Type".to_string(), + "application/x-www-form-urlencoded".to_string(), + ); + (None, form_to_bytes(form)) + } + Some(RequestBody::CatchAll(e)) => { + return Err(PyErr::new::(format!( "Unsupported data type in request body: {e:#?}" - ))), - }, - None => Ok(Vec::new()), - }?; + ))); + } + None => (None, Vec::new()), + }; let timeout = parse_timeout(timeout)?; @@ -422,6 +424,11 @@ impl AsyncClient { let impit = Arc::clone(&self.impit); pyo3_async_runtimes::tokio::future_into_py::<_, ImpitPyResponse>(py, async move { + let body = match async_body { + Some(iter) => async_iterator_to_bytes(iter).await?, + None => body, + }; + let response = match method_str.to_lowercase().as_str() { "get" => impit.get(url, Some(body), Some(options)).await, "post" => impit.post(url, Some(body), Some(options)).await, diff --git a/impit-python/src/client.rs b/impit-python/src/client.rs index a817cbfe..d5409cb5 100644 --- a/impit-python/src/client.rs +++ b/impit-python/src/client.rs @@ -11,7 +11,9 @@ use pyo3::{ffi::c_str, prelude::*}; use crate::{ cookies::PythonCookieJar, errors::ImpitPyError, - request::{form_to_bytes, parse_timeout, RequestBody, USE_CLIENT_DEFAULT_SENTINEL}, + request::{ + form_to_bytes, iterator_to_bytes, parse_timeout, RequestBody, USE_CLIENT_DEFAULT_SENTINEL, + }, response::{self, ImpitPyResponse}, }; @@ -132,7 +134,7 @@ impl Client { &self, py: Python<'_>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -156,7 +158,7 @@ impl Client { &self, py: Python<'_>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -180,7 +182,7 @@ impl Client { &self, py: Python<'_>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -204,7 +206,7 @@ impl Client { &self, py: Python<'_>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -228,7 +230,7 @@ impl Client { &self, py: Python<'_>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -252,7 +254,7 @@ impl Client { &self, py: Python<'_>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -276,7 +278,7 @@ impl Client { &self, py: Python<'_>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -300,7 +302,7 @@ impl Client { &self, py: Python<'_>, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -325,7 +327,7 @@ impl Client { py: Python<'python>, method: &str, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -371,22 +373,25 @@ impl Client { py: Python<'_>, method: &str, url: String, - content: Option>, - mut data: Option, + content: Option>, + data: Option, headers: Option>, timeout: Option>, force_http3: Option, stream: Option, ) -> Result { let mut headers = headers.clone(); - - if let Some(content) = content { - data = Some(RequestBody::Bytes(content)); - } + let data = content.or(data); let body: Vec = match data { Some(data) => match data { + RequestBody::AsyncIterator(_) => Err(ImpitPyError(ImpitError::BindingPassthroughError( + "async iterators are not supported by the synchronous Client; use AsyncClient instead" + .to_string(), + ))), RequestBody::Bytes(bytes) => Ok(bytes), + RequestBody::Iterator(iter) => iterator_to_bytes(iter) + .map_err(|e| ImpitPyError(ImpitError::BindingPassthroughError(e.to_string()))), RequestBody::Form(form) => { headers.get_or_insert_with(HashMap::new).insert( "Content-Type".to_string(), diff --git a/impit-python/src/lib.rs b/impit-python/src/lib.rs index 9e16af38..4beb4dee 100644 --- a/impit-python/src/lib.rs +++ b/impit-python/src/lib.rs @@ -93,7 +93,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { fn $name( _py: Python, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, @@ -122,7 +122,7 @@ fn impit(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { _py: Python<'python>, method: &str, url: String, - content: Option>, + content: Option>, data: Option, headers: Option>, timeout: Option>, diff --git a/impit-python/src/request.rs b/impit-python/src/request.rs index 07a26da6..f50fe9e7 100644 --- a/impit-python/src/request.rs +++ b/impit-python/src/request.rs @@ -33,10 +33,34 @@ pub(crate) fn parse_timeout( } } -use pyo3::FromPyObject; +use pyo3::exceptions::{PyStopAsyncIteration, PyTypeError}; +use pyo3::types::{PyAnyMethods, PyIterator}; +use pyo3::{Borrowed, BoundObject, FromPyObject, Py, PyErr, PyResult, Python}; + +/// Wraps a Python object that implements `__anext__`. Extraction only checks for the attribute +/// (no side effects), unlike `Vec`/`PyIterator` extraction which can consume items. +pub(crate) struct PyAsyncIterator(pub Py); + +impl<'a, 'py> FromPyObject<'a, 'py> for PyAsyncIterator { + type Error = PyErr; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> Result { + if obj.hasattr("__anext__")? { + Ok(PyAsyncIterator(BoundObject::unbind(obj))) + } else { + Err(PyTypeError::new_err("object is not an async iterator")) + } + } +} #[derive(FromPyObject)] pub(crate) enum RequestBody<'py> { + #[pyo3(transparent, annotation = "AsyncIterator[bytes]")] + AsyncIterator(PyAsyncIterator), + // Placed before `Bytes` because `Vec` extraction would otherwise partially consume the + // iterator while trying (and failing) to coerce its items into bytes. + #[pyo3(transparent, annotation = "Iterator[bytes]")] + Iterator(Bound<'py, PyIterator>), #[pyo3(transparent, annotation = "bytes")] Bytes(Vec), #[pyo3(transparent, annotation = "dict[str, str]")] @@ -45,6 +69,37 @@ pub(crate) enum RequestBody<'py> { CatchAll(Bound<'py, PyAny>), // This extraction never fails } +pub fn iterator_to_bytes(iter: Bound<'_, PyIterator>) -> PyResult> { + let mut body = Vec::new(); + for chunk in iter { + body.extend(chunk?.extract::>()?); + } + Ok(body) +} + +/// Drains a Python async iterator by repeatedly awaiting `__anext__`, until it raises +/// `StopAsyncIteration`. +pub async fn async_iterator_to_bytes(iter: Py) -> PyResult> { + let mut body = Vec::new(); + loop { + let next = Python::attach(|py| { + pyo3_async_runtimes::tokio::into_future( + iter.call_method0(py, "__anext__")?.into_bound(py), + ) + })?; + + match next.await { + Ok(chunk) => { + body.extend(Python::attach(|py| chunk.extract::>(py))?); + } + Err(err) if Python::attach(|py| err.is_instance_of::(py)) => { + return Ok(body); + } + Err(err) => return Err(err), + } + } +} + pub fn form_to_bytes(data: HashMap) -> Vec { let mut body = Vec::new(); for (key, value) in data { diff --git a/impit-python/test/async_client_test.py b/impit-python/test/async_client_test.py index b062e305..457eaf48 100644 --- a/impit-python/test/async_client_test.py +++ b/impit-python/test/async_client_test.py @@ -2,6 +2,7 @@ import json import socket import threading +from collections.abc import AsyncIterator from http.cookiejar import Cookie, CookieJar from typing import Literal @@ -582,6 +583,26 @@ async def test_passing_binary_body(self, browser: Browser) -> None: assert response.status_code == 200 assert json.loads(response.text)['data'] == 'Impit-Test:foořžš' + @pytest.mark.asyncio + async def test_passing_generator_body(self, browser: Browser) -> None: + impit = AsyncClient(browser=browser) + + response = await impit.post(get_httpbin_url('/post'), content=(chunk for chunk in [b'foo', b'bar'])) + assert response.status_code == 200 + assert json.loads(response.text)['data'] == 'foobar' + + @pytest.mark.asyncio + async def test_passing_async_generator_body(self, browser: Browser) -> None: + impit = AsyncClient(browser=browser) + + async def agen() -> AsyncIterator[bytes]: + yield b'foo' + yield b'bar' + + response = await impit.post(get_httpbin_url('/post'), content=agen()) + assert response.status_code == 200 + assert json.loads(response.text)['data'] == 'foobar' + @pytest.mark.parametrize( ('method'), ['POST', 'PUT', 'PATCH'], diff --git a/impit-python/test/basic_client_test.py b/impit-python/test/basic_client_test.py index f78bffe6..bcda2c04 100644 --- a/impit-python/test/basic_client_test.py +++ b/impit-python/test/basic_client_test.py @@ -2,6 +2,7 @@ import socket import threading import time +from collections.abc import AsyncIterator, Iterator from http.cookiejar import Cookie, CookieJar from typing import Literal @@ -13,6 +14,7 @@ Client, ConnectTimeout, Cookies, + HTTPError, ReadTimeout, RemoteProtocolError, StreamClosed, @@ -507,6 +509,26 @@ def test_passing_binary_body(self, browser: Browser) -> None: assert response.status_code == 200 assert response.json()['data'] == 'Impit-Test:foořžš' + def test_passing_generator_body(self, browser: Browser) -> None: + impit = Client(browser=browser) + + def gen() -> Iterator[bytes]: + yield b'Impit-' + yield b'Test' + + response = impit.post(get_httpbin_url('/post'), content=gen()) + assert response.status_code == 200 + assert response.json()['data'] == 'Impit-Test' + + def test_passing_async_generator_body_raises(self, browser: Browser) -> None: + impit = Client(browser=browser) + + async def agen() -> AsyncIterator[bytes]: + yield b'foo' + + with pytest.raises(HTTPError): + impit.post(get_httpbin_url('/post'), content=agen()) # type: ignore[arg-type] + @pytest.mark.parametrize( ('method'), ['POST', 'PUT', 'PATCH'], @@ -520,6 +542,20 @@ def test_methods_accept_request_body(self, browser: Browser, method: str) -> Non assert response.status_code == 200 assert response.json()['data'] == 'foo' + def test_methods_accept_generator_body(self, browser: Browser) -> None: + impit = Client(browser=browser) + + response = impit.post(get_httpbin_url('/post'), content=(chunk for chunk in [b'foo', b'bar'])) + assert response.status_code == 200 + assert response.json()['data'] == 'foobar' + + def test_passing_iterator_body(self, browser: Browser) -> None: + impit = Client(browser=browser) + + response = impit.post(get_httpbin_url('/post'), content=iter([b'foo', b'bar'])) + assert response.status_code == 200 + assert response.json()['data'] == 'foobar' + def test_content(self, browser: Browser) -> None: impit = Client(browser=browser) diff --git a/impit-python/test/no_client_test.py b/impit-python/test/no_client_test.py index 6090b976..3234e331 100644 --- a/impit-python/test/no_client_test.py +++ b/impit-python/test/no_client_test.py @@ -365,6 +365,11 @@ def test_passing_binary_body(self) -> None: assert response.status_code == 200 assert response.json()['data'] == 'Impit-Test:foořžš' + def test_passing_generator_body(self) -> None: + response = impit.post(get_httpbin_url('/post'), content=(chunk for chunk in [b'foo', b'bar'])) + assert response.status_code == 200 + assert response.json()['data'] == 'foobar' + @pytest.mark.parametrize( ('method'), ['POST', 'PUT', 'PATCH'],