From 1bd473e99987ed15f13ad08dd58a1e4b9f3ccc7f Mon Sep 17 00:00:00 2001 From: Chris Pryer Date: Mon, 9 Mar 2026 23:13:07 -0400 Subject: [PATCH] Implement Response.history --- src/client.rs | 1 + src/models.rs | 29 ++++++++++++++++++++++++++++- tests/test_models.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 46e5fce..740eb28 100644 --- a/src/client.rs +++ b/src/client.rs @@ -2396,6 +2396,7 @@ async fn convert_async_response( url, request, encoding, + history: Vec::new(), default_encoding, extensions: None, stream: None, diff --git a/src/models.rs b/src/models.rs index 1b05e6f..9fd12d8 100644 --- a/src/models.rs +++ b/src/models.rs @@ -124,6 +124,19 @@ pub fn parse_default_encoding_arg( )) } +fn parse_history_arg(py: Python<'_>, history: Option>) -> PyResult>> { + let Some(history) = history else { + return Ok(Vec::new()); + }; + let bound = history.bind(py); + if bound.is_none() { + return Ok(Vec::new()); + } + bound + .extract::>>() + .map_err(|_| PyTypeError::new_err("history must be a list of Response objects or None")) +} + fn immediate_awaitable<'py>(py: Python<'py>, value: Py) -> PyResult> { pyo3_async_runtimes::tokio::future_into_py(py, async move { Ok(value) }) } @@ -758,6 +771,7 @@ pub struct PyResponse { pub url: String, pub request: Option>, pub encoding: Option, + pub history: Vec>, pub default_encoding: Option>, pub extensions: Option>, // Streaming: Some(response) while stream is unread, None after read. @@ -890,6 +904,7 @@ impl PyResponse { url, request, encoding, + history: Vec::new(), default_encoding, extensions: None, stream: None, @@ -921,6 +936,7 @@ impl PyResponse { url, request, encoding, + history: Vec::new(), default_encoding, extensions: None, stream: Some(Arc::new(Mutex::new(Some(ResponseStream::Blocking(resp))))), @@ -958,6 +974,7 @@ impl PyResponse { url, request, encoding, + history: Vec::new(), default_encoding, extensions: None, stream: None, @@ -990,6 +1007,7 @@ impl PyResponse { url, request, encoding, + history: Vec::new(), default_encoding, extensions: None, stream: Some(Arc::new(Mutex::new(Some(ResponseStream::Async(resp))))), @@ -1036,7 +1054,7 @@ impl PyResponse { default_encoding: Option>, http_version: Option, ) -> PyResult { - let _ = history; + let history = parse_history_arg(py, history)?; let default_encoding = parse_default_encoding_arg(py, default_encoding)?; let reason_phrase = reqwest::StatusCode::from_u16(status_code) .ok() @@ -1118,6 +1136,7 @@ impl PyResponse { url: String::new(), request, encoding, + history, default_encoding, extensions, stream: None, @@ -1174,6 +1193,14 @@ impl PyResponse { self.encoding.as_deref() } + #[getter] + pub fn history(&self, py: Python<'_>) -> Vec> { + self.history + .iter() + .map(|response| response.clone_ref(py)) + .collect() + } + #[getter] pub fn is_redirect(&self) -> bool { self.status_code >= 300 && self.status_code < 400 diff --git a/tests/test_models.py b/tests/test_models.py index fd6098c..ba6ad99 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -195,6 +195,36 @@ def test_text(self): r = httprs.Response(200, content=b"Hello, world!") assert r.text == "Hello, world!" + def test_history_defaults_to_empty_list(self): + r = httprs.Response(200, content=b"ok") + assert r.history == [] + + def test_history_accepts_response_list(self): + redirect = httprs.Response( + 301, + headers={"location": "https://example.com/final"}, + content=b"", + ) + final = httprs.Response(200, content=b"ok", history=[redirect]) + history = final.history + assert len(history) == 1 + assert history[0].status_code == 301 + assert history[0].headers["location"] == "https://example.com/final" + + def test_history_copied_from_input_list(self): + redirect1 = httprs.Response(301, content=b"") + redirect2 = httprs.Response(302, content=b"") + history = [redirect1] + final = httprs.Response(200, content=b"ok", history=history) + history.append(redirect2) + assert [r.status_code for r in final.history] == [301] + + def test_history_rejects_invalid_items(self): + with pytest.raises( + TypeError, match="history must be a list of Response objects" + ): + httprs.Response(200, content=b"ok", history=["not-a-response"]) + def test_text_uses_default_encoding_when_charset_missing(self): r = httprs.Response( 200,