From 4acc1cd7b8b86aa7a81eddfeb92161648c2b5308 Mon Sep 17 00:00:00 2001 From: gngpp Date: Tue, 29 Sep 2026 19:05:54 +0800 Subject: [PATCH 1/6] docs(sponsors): add Byteful and remove expired Hyper Solutions --- .github/assets/byteful-logo.svg | 18 +++++++++++++ .github/assets/ezcaptcha.svg | 4 +++ .github/assets/hypersolutions.jpg | Bin 39483 -> 0 bytes .github/assets/nodemaven.svg | 5 +++- .github/assets/scrapedo.svg | 5 +++- README.md | 30 +++++++++++----------- docs/source/sponsors.md | 41 +++++++++++++----------------- 7 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 .github/assets/byteful-logo.svg create mode 100644 .github/assets/ezcaptcha.svg delete mode 100644 .github/assets/hypersolutions.jpg diff --git a/.github/assets/byteful-logo.svg b/.github/assets/byteful-logo.svg new file mode 100644 index 00000000..2f09be60 --- /dev/null +++ b/.github/assets/byteful-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/.github/assets/ezcaptcha.svg b/.github/assets/ezcaptcha.svg new file mode 100644 index 00000000..396a6338 --- /dev/null +++ b/.github/assets/ezcaptcha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/.github/assets/hypersolutions.jpg b/.github/assets/hypersolutions.jpg deleted file mode 100644 index f1d90152dedbd296bd0606449acdb9c10017da21..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39483 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 + + + + diff --git a/.github/assets/scrapedo.svg b/.github/assets/scrapedo.svg index 641b5925..2eee1142 100644 --- a/.github/assets/scrapedo.svg +++ b/.github/assets/scrapedo.svg @@ -1,4 +1,6 @@ + + @@ -50,4 +52,5 @@ - \ No newline at end of file + + diff --git a/README.md b/README.md index 1f79c915..4f0bb348 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,20 @@ Unless you explicitly state otherwise, any contribution intentionally submitted ## Sponsors +Byteful + +**35M+ residential IPs with 99.9% success rates** + +UK-based **[Byteful](https://byteful.com/?utm_source=github_python&utm_medium=github-sponsor&utm_campaign=wreq_github_sponsorship)** provides ethically sourced residential, mobile, static residential (ISP), and datacenter proxies. Its API-first scraping, data collection, and AI automation tools handle tens of billions of monthly requests. + +With free geo-targeting and ~0.5s response times, Byteful helps you collect data at scale. Use code **`WREQ10`** for **10% off residential proxies**. + +**[Dashboard](https://dashboard.byteful.com/)** | **[Docs](https://documentation.byteful.com/)** | **[Discord](https://discord.com/invite/ping-proxies-584021352940568578)** + +--- + - NodeMaven + NodeMaven **[NodeMaven](https://go.nodemaven.com/wreqpythonGHaugust)** is the most efficient proxy provider for web scraping and automation, with the highest-quality IPs on the market. @@ -142,7 +154,7 @@ Unique free tools include the **Proxy Bandwidth Checker**, **Meta Tag Checker**, --- - + Scrape.do **[Scrape.do](https://scrape.do/?utm_source=github&utm_medium=rnet)** is the ultimate toolkit for collecting public data at scale. Unmatched speed, unbeatable prices, unblocked access. @@ -156,7 +168,7 @@ One line of code. Instant data access --- - + EzCaptcha Captcha solving can be slow and unreliable, but **[EzCaptcha](https://www.ez-captcha.com/?r=github-rnet)** delivers fast, reliable solving through a simple API — supporting a wide range of captcha types with no complex integration required. @@ -166,15 +178,3 @@ Captcha solving can be slow and unreliable, but **[EzCaptcha](https://www.ez-cap Designed for developers, it offers high accuracy, low price, low latency, and easy integration, helping you automate verification while keeping traffic secure and user flows smooth. **[Dashboard](https://www.ez-captcha.com/?r=github-rnet)** | **[Docs](https://ezcaptcha.atlassian.net/wiki/spaces/IS/pages/7045121/EzCaptcha+API+Docs+English)** | **[Telegram](https://t.me/+NrVmPhlb9ZFkZGY5)** - ---- - - - -TLS fingerprinting alone isn't enough for modern bot protection. **[Hyper Solutions](https://hypersolutions.co?utm_source=github&utm_medium=readme&utm_campaign=rnet)** provides the missing piece - API endpoints that generate valid antibot tokens for: - -**Akamai** • **DataDome** • **Kasada** • **Incapsula** - -No browser automation. Just simple API calls that return the exact cookies and headers these systems require. - -**[Dashboard](https://hypersolutions.co?utm_source=github&utm_medium=readme&utm_campaign=rnet)** | **[Docs](https://docs.justhyped.dev)** | **[Discord](https://discord.gg/akamai)** diff --git a/docs/source/sponsors.md b/docs/source/sponsors.md index 9d026434..8d2a7715 100644 --- a/docs/source/sponsors.md +++ b/docs/source/sponsors.md @@ -5,11 +5,24 @@ We're grateful for the support of our sponsors who help make this project possib --- +## [Byteful](https://byteful.com/?utm_source=github_python&utm_medium=github-sponsor&utm_campaign=wreq_github_sponsorship) + +[![Byteful](https://raw.githubusercontent.com/0x676e67/wreq-python/main/.github/assets/byteful-logo.svg){ width="149" height="47" }](https://byteful.com/?utm_source=github_python&utm_medium=github-sponsor&utm_campaign=wreq_github_sponsorship){ target="_blank" } + +**35M+ residential IPs with 99.9% success rates** + +UK-based **[Byteful](https://byteful.com/?utm_source=github_python&utm_medium=github-sponsor&utm_campaign=wreq_github_sponsorship)** provides ethically sourced residential, mobile, static residential (ISP), and datacenter proxies. Its API-first scraping, data collection, and AI automation tools handle tens of billions of monthly requests. + +With free geo-targeting and ~0.5s response times, Byteful helps you collect data at scale. Use code **`WREQ10`** for **10% off residential proxies**. + +**[Dashboard](https://dashboard.byteful.com/)** | **[Docs](https://documentation.byteful.com/)** | **[Discord](https://discord.com/invite/ping-proxies-584021352940568578)** + +--- + + ## [NodeMaven](https://go.nodemaven.com/wreqpythonGHaugust) - - NodeMaven - +[![NodeMaven](https://raw.githubusercontent.com/0x676e67/wreq-python/main/.github/assets/nodemaven.svg){ width="165" height="47" }](https://go.nodemaven.com/wreqpythonGHaugust){ target="_blank" } NodeMaven is the most efficient proxy provider for web scraping and automation, with the highest-quality IPs on the market. @@ -28,9 +41,7 @@ Unique free tools include the **Proxy Bandwidth Checker**, **Meta Tag Checker**, ## [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=wreq) - - - +[![Scrape.do](https://raw.githubusercontent.com/0x676e67/wreq-python/main/.github/assets/scrapedo.svg){ width="149" height="47" }](https://scrape.do/?utm_source=github&utm_medium=wreq){ target="_blank" } Scrape.do is the ultimate toolkit for collecting public data at scale. Unmatched speed, unbeatable prices, unblocked access. @@ -45,9 +56,7 @@ One line of code. Instant data access ## [EzCaptcha](https://www.ez-captcha.com/?r=github-wreq) - - - +[![EzCaptcha](https://raw.githubusercontent.com/0x676e67/wreq-python/main/.github/assets/ezcaptcha.svg){ width="47" height="47" }](https://www.ez-captcha.com){ target="_blank" } Captcha solving can be slow and unreliable, but EzCaptcha delivers fast, reliable solving through a simple API — supporting a wide range of captcha types with no complex integration required. @@ -60,20 +69,6 @@ Designed for developers, it offers high accuracy, low price, low latency, and ea --- -## [Hyper Solutions](https://hypersolutions.co?utm_source=github&utm_medium=readme&utm_campaign=wreq) - - - -TLS fingerprinting alone isn't enough for modern bot protection. Hyper Solutions provides the missing piece - API endpoints that generate valid antibot tokens for: - -**Akamai** • **DataDome** • **Kasada** • **Incapsula** - -No browser automation. Just simple API calls that return the exact cookies and headers these systems require. - -**[Dashboard](https://hypersolutions.co?utm_source=github&utm_medium=readme&utm_campaign=wreq)** | **[Docs](https://docs.justhyped.dev)** | **[Discord](https://discord.gg/akamai)** - ---- - ## Become a Sponsor Interested in sponsoring this project? [Contact us](mailto:gngppz@gmail.com) or check out our [sponsorship options](https://github.com/0x676e67/0x676e67/blob/main/SPONSOR.md). From 33a7000a67ad03527c378ee4142cfb4921124aca Mon Sep 17 00:00:00 2001 From: 0x676e67 Date: Tue, 29 Sep 2026 22:41:15 +0800 Subject: [PATCH 2/6] fix(client): avoid PyO3 attach panics during interpreter shutdown (#621) --- Cargo.toml | 1 - src/client/body/stream.rs | 13 ++++++-- src/client/nogil.rs | 38 ++++++++++++++++++---- src/error.rs | 50 ++++++++++++++++++++++++++++- src/redirect.rs | 5 +-- tests/shutdown_test.py | 66 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 tests/shutdown_test.py diff --git a/Cargo.toml b/Cargo.toml index 1711c00e..539d6cd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,5 +92,4 @@ debug = false incremental = false lto = "fat" opt-level = 3 -panic = "abort" strip = true diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index e24862cb..179aea10 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -15,7 +15,12 @@ use pyo3::{ }; use tokio::{sync::Mutex, task::JoinHandle}; -use crate::{buffer::PyBuffer, client::nogil::NoGIL, error::Error, header::HeaderMap}; +use crate::{ + buffer::PyBuffer, + client::nogil::NoGIL, + error::{self, Error}, + header::HeaderMap, +}; type Pending = Option>>>; @@ -224,11 +229,12 @@ impl Stream for PyStream { PyStreamSource::Sync(ref ob) => { let ob = ob.clone(); runtime.spawn_blocking(move || { - Python::attach(|py| { + error::attach(|py| { ob.call_method0(py, intern!(py, "__next__")) .ok() .map(|ob| ob.extract(py)) }) + .unwrap_or_else(|err| Some(Err(err.into()))) }) } PyStreamSource::Async(ref stream) => { @@ -236,7 +242,8 @@ impl Stream for PyStream { runtime.spawn(async move { let ob = stream.lock().await.next().await; tokio::task::spawn_blocking(move || { - Python::attach(|py| ob.map(|ob| ob.extract(py))) + error::attach(|py| ob.map(|ob| ob.extract(py))) + .unwrap_or_else(|err| Some(Err(err.into()))) }) .await .ok()? diff --git a/src/client/nogil.rs b/src/client/nogil.rs index 6a9c59a6..db537352 100644 --- a/src/client/nogil.rs +++ b/src/client/nogil.rs @@ -1,7 +1,8 @@ use std::{ future::Future, pin::Pin, - task::{Context, Poll}, + sync::{Arc, Once}, + task::{Context, Poll, Wake, Waker}, }; use pin_project_lite::pin_project; @@ -12,6 +13,8 @@ use pyo3::{ }; use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; +use crate::error; + pin_project! { /// A future that allows Python threads to run while it is being polled or executed. /// It also handles cancellation and spawns the task in tokio runtime. @@ -77,13 +80,36 @@ where }))); } - let waker = cx.waker(); + let waker = Waker::from(Arc::new(GuardedWaker(cx.waker().clone()))); Python::attach(|py| { - py.detach(|| match this.handle.poll(&mut Context::from_waker(waker)) { - Poll::Ready(Ok(result)) => Poll::Ready(result), - Poll::Ready(Err(e)) => Poll::Ready(Err(PyRuntimeError::new_err(e.to_string()))), - Poll::Pending => Poll::Pending, + py.detach(|| { + let mut cx = Context::from_waker(&waker); + match this.handle.poll(&mut cx) { + Poll::Ready(Ok(result)) => Poll::Ready(result), + Poll::Ready(Err(e)) => Poll::Ready(Err(PyRuntimeError::new_err(e.to_string()))), + Poll::Pending => Poll::Pending, + } }) }) } } + +/// Wakes the Python coroutine from Tokio threads. +/// +/// PyO3's coroutine waker calls `Python::attach`, which panics once the interpreter has +/// shut down. Waking inside [`error::attach`] lets it reuse that attachment instead; when +/// Python is gone the wake is dropped and reported once, as no coroutine is left to resume. +struct GuardedWaker(Waker); + +impl Wake for GuardedWaker { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + if let Err(err) = error::attach(|_| self.0.wake_by_ref()) { + static REPORTED: Once = Once::new(); + REPORTED.call_once(|| eprintln!("wreq: failed to wake a Python coroutine: {err}")); + } + } +} diff --git a/src/error.rs b/src/error.rs index 7a78a8f3..9899b00f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,11 @@ +use std::{ + any::Any, + fmt, + panic::{self, AssertUnwindSafe}, +}; + use pyo3::{ - PyErr, create_exception, + PyErr, Python, create_exception, exceptions::{PyException, PyRuntimeError, PyStopAsyncIteration, PyStopIteration}, }; use wreq::header; @@ -57,6 +63,9 @@ macro_rules! wrap_error { }; } +const INTERPRETER_UNAVAILABLE_MSG: &str = + "The Python interpreter is not available (not initialized or shutting down)"; + /// Unified error enum #[derive(Debug)] pub enum Error { @@ -74,6 +83,8 @@ pub enum Error { Json(serde_json::Error), Form(serde_urlencoded::ser::Error), Library(wreq::Error), + InterpreterUnavailable, + Panic(Box), } impl From for PyErr { @@ -100,6 +111,8 @@ impl From for PyErr { Error::Dns(err) => BuilderError::new_err(format!("DNS resolver error: {err:?}")), Error::Json(err) => PyRuntimeError::new_err(format!("JSON error: {err:?}")), Error::Form(err) => PyRuntimeError::new_err(format!("Form error: {err:?}")), + Error::InterpreterUnavailable => PyRuntimeError::new_err(INTERPRETER_UNAVAILABLE_MSG), + Error::Panic(msg) => RustPanic::new_err(msg.into_string()), Error::Library(err) => wrap_error!(err, is_body => BodyError, is_tls => TlsError, @@ -118,6 +131,41 @@ impl From for PyErr { } } +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::InterpreterUnavailable => f.write_str(INTERPRETER_UNAVAILABLE_MSG), + Error::Panic(msg) => f.write_str(msg), + err => fmt::Debug::fmt(err, f), + } + } +} + +impl Error { + fn panic(payload: Box) -> Self { + let msg = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("unknown panic"); + Error::Panic(format!("Rust panic: {msg}").into_boxed_str()) + } +} + +/// Attaches to Python from a non-Python thread. +/// +/// `Python::attach` panics once the interpreter has shut down (see discussions/305), so +/// this uses `Python::try_attach` and returns [`Error::InterpreterUnavailable`] instead. +/// Any other panic in `f` is caught as [`Error::Panic`] to keep Tokio threads alive. +pub fn attach(f: F) -> Result +where + F: for<'py> FnOnce(Python<'py>) -> R, +{ + panic::catch_unwind(AssertUnwindSafe(|| Python::try_attach(f))) + .map_err(Error::panic)? + .ok_or(Error::InterpreterUnavailable) +} + impl From for Error { fn from(err: header::InvalidHeaderName) -> Self { Error::InvalidHeaderName(err) diff --git a/src/redirect.rs b/src/redirect.rs index 353f0555..2ee1bebe 100644 --- a/src/redirect.rs +++ b/src/redirect.rs @@ -2,7 +2,7 @@ use std::{fmt::Display, sync::Arc}; use pyo3::prelude::*; -use crate::{header::HeaderMap, http::StatusCode}; +use crate::{error, header::HeaderMap, http::StatusCode}; /// Represents the redirect policy for HTTP requests. #[derive(Clone)] @@ -103,13 +103,14 @@ impl Policy { attempt.pending(|attempt| async move { let args = Attempt::from(&attempt); let kind = tokio::task::spawn_blocking(move || { - Python::attach(|py| { + error::attach(|py| { callback .call1(py, (args,)) .and_then(|result| result.extract::(py).map_err(PyErr::from)) .map(|action| action.kind) .unwrap_or_else(|err| ActionKind::Error(err.to_string())) }) + .unwrap_or_else(|err| ActionKind::Error(err.to_string())) }) .await; diff --git a/tests/shutdown_test.py b/tests/shutdown_test.py new file mode 100644 index 00000000..8aba518c --- /dev/null +++ b/tests/shutdown_test.py @@ -0,0 +1,66 @@ +import platform +import subprocess +import sys + +import pytest + +# https://github.com/0x676e67/wreq-python/discussions/305: a Tokio worker that attaches +# to Python during interpreter shutdown used to hit PyO3's not-initialized panic. wreq +# now detects the unavailable interpreter and reports a wreq error without panicking. + +# A request is left pending on a listener that never accepts, then an uncaught error +# shuts the interpreter down. Module teardown runs after Py_IsInitialized() drops to 0; +# `HoldTeardown.__del__` closes the listener there, failing the request so the coroutine +# waker attaches from a Tokio worker, and stalls teardown until that happens. +SCRIPT = """ +import asyncio +import socket +import time + +import wreq + +listener = socket.socket() +listener.bind(("127.0.0.1", 0)) +listener.listen(8) +url = f"http://127.0.0.1:{listener.getsockname()[1]}/" + + +class HoldTeardown: + def __init__(self, listener, task): + self.listener = listener + self.task = task + + def __del__(self): + self.listener.close() + time.sleep(1) + + +loop = asyncio.new_event_loop() +task = loop.create_task(wreq.Client().get(url)) +loop.run_until_complete(asyncio.sleep(0.05)) +hold = HoldTeardown(listener, task) +del listener, task +raise RuntimeError("uncaught error while a request is in flight") +""" + +# PyPy doesn't guarantee `__del__` runs during interpreter exit, so the trigger may not fire. +pytestmark = pytest.mark.skipif( + platform.python_implementation() != "CPython", + reason="relies on CPython running __del__ during module teardown", +) + + +def test_shutdown_wake_reports_error_without_panic(): + proc = subprocess.run( + [sys.executable, "-c", SCRIPT], + capture_output=True, + text=True, + timeout=60, + ) + # Exits through the uncaught RuntimeError, not an abort. + assert proc.returncode == 1, proc.stderr + assert "panicked" not in proc.stderr + assert ( + "wreq: failed to wake a Python coroutine: " + "The Python interpreter is not available" in proc.stderr + ) From 38e4af8fbd2bab4d8bbb378e19009e7354f53779 Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 17:08:52 +0800 Subject: [PATCH 3/6] feat(buffer): return read-only memoryviews --- docs/source/getting-started/quickstart.md | 23 ++- docs/source/guide/basic.md | 17 +- docs/source/guide/blocking.md | 7 +- docs/source/guide/websocket.md | 2 + examples/blocking/stream.py | 5 +- examples/header_map.py | 6 +- examples/request.py | 10 +- examples/stream.py | 5 +- python/wreq/blocking.py | 8 +- python/wreq/header.py | 33 ++-- python/wreq/tls.py | 4 +- python/wreq/wreq.py | 35 +++-- src/buffer.rs | 46 ++++-- src/client/body/stream.rs | 4 +- src/client/resp/http.rs | 8 +- src/client/resp/ws/msg.rs | 8 +- src/header.rs | 12 +- src/tls.rs | 2 +- tests/buffer_test.py | 183 ++++++++++++++++++++++ tests/response_test.py | 4 +- tests/tls_test.py | 5 +- 21 files changed, 347 insertions(+), 80 deletions(-) create mode 100644 tests/buffer_test.py diff --git a/docs/source/getting-started/quickstart.md b/docs/source/getting-started/quickstart.md index 79213db3..0faac302 100644 --- a/docs/source/getting-started/quickstart.md +++ b/docs/source/getting-started/quickstart.md @@ -81,12 +81,33 @@ data = await response.json() print(data) ``` +### Binary data + +`response.bytes()` returns a read-only `memoryview`, not a `bytes` object. The view shares Rust-owned data without a copy into Python bytes. Reading a complete response can still allocate memory to combine body chunks. + +```python +view = await response.bytes() +await response.close() +print(view.readonly) # True; closing the response does not invalidate the view + +data = bytes(view) # Copies into a Python bytes object +view.release() +``` + +The blocking API returns the same type, without `await`. Stream data frames, WebSocket binary fields, header names and values, and peer certificates also return read-only memoryviews. Each view retains its backing data even after the source object is closed or deleted. + +This changes the binary return type. `memoryview` supports the buffer protocol, but has no `.decode()` method or byte-string concatenation. Use `bytes(view)` or `view.tobytes()` when an API requires `bytes`; both copy the data. `view.release()` releases that view, not other views or slices sharing the data. Input types are unchanged. + +When passing a view back to wreq's binary inputs (`body`, `Part`, `Message` constructors, or `CertStore`), convert it with `bytes(view)`. These inputs do not treat a memoryview as binary data. + ### Response headers Response headers are available as a [HeaderMap](../api/header/?h=HeaerMap#wreq.header.HeaderMap) object: ```python -print(response.headers.get("content-type")) +content_type = response.headers.get("content-type") +if content_type is not None: + print(bytes(content_type).decode("ascii")) # application/json ``` diff --git a/docs/source/guide/basic.md b/docs/source/guide/basic.md index f6f9df46..8c6c55e4 100644 --- a/docs/source/guide/basic.md +++ b/docs/source/guide/basic.md @@ -140,13 +140,17 @@ headers.append("Accept", "application/json") headers.append("Accept", "text/html") # Retrieve a single value -print(headers.get("Content-Type")) +content_type = headers.get("Content-Type") +if content_type is not None: + print(bytes(content_type).decode("ascii")) # application/json # Retrieve all values for a multi-value header -print(list(headers.get_all("Accept"))) +print([bytes(value).decode("ascii") for value in headers.get_all("Accept")]) # ['application/json', 'text/html'] ``` + +Header names and values are read-only `memoryview` objects. Convert them to `bytes` before using byte-string methods such as `.decode()`. Pass the `HeaderMap` to any request method via the `headers` argument: @@ -161,16 +165,19 @@ response = await wreq.get("https://httpbin.org/headers", headers=headers) For large responses, you can read the body incrementally instead of loading it all into memory at once. Use `resp.stream()` as an async iterator: ```python -from wreq import Client +from wreq import Client, HeaderMap async def main(): client = Client() response = await client.get("https://httpbin.org/stream/10") async for chunk in response.stream(): - print(chunk.decode("utf-8")) + if isinstance(chunk, memoryview): + print(bytes(chunk).decode("utf-8")) + elif isinstance(chunk, HeaderMap): + print("Trailers:", chunk) ``` -Each `chunk` is a `bytes` object. Decode it to a string only if you know the response body is text. +Data chunks are read-only `memoryview` objects; trailer frames are `HeaderMap` objects. Decode data only if you know the response body is text. Each view stays valid after the stream is closed. See [Binary data](../getting-started/quickstart.md#binary-data) for copying and releasing views. --- diff --git a/docs/source/guide/blocking.md b/docs/source/guide/blocking.md index c8e4901f..f94a75cd 100644 --- a/docs/source/guide/blocking.md +++ b/docs/source/guide/blocking.md @@ -175,9 +175,14 @@ def main(): with resp: with resp.stream() as streamer: for chunk in streamer: - print(chunk) + if isinstance(chunk, memoryview): + print(bytes(chunk)) + else: + print("Trailers:", chunk) if __name__ == "__main__": main() ``` + +Data chunks are read-only `memoryview` objects that stay valid after the stream is closed. `resp.bytes()` returns the same type; use `bytes(view)` or `view.tobytes()` to make a copy when needed. diff --git a/docs/source/guide/websocket.md b/docs/source/guide/websocket.md index db617203..35ac74f9 100644 --- a/docs/source/guide/websocket.md +++ b/docs/source/guide/websocket.md @@ -4,6 +4,8 @@ - HTTP/1.1 WebSocket - HTTP/2 WebSocket +`Message.data`, `.binary`, `.ping`, and `.pong` return read-only `memoryview` objects when present. The views remain valid after the message is deleted or the connection is closed. Use `bytes(view)` when a consumer requires a `bytes` object; `Message.text` still returns a string. + ### HTTP/1.1 WebSocket Connection ```python diff --git a/examples/blocking/stream.py b/examples/blocking/stream.py index 17dfccb4..fae708fe 100644 --- a/examples/blocking/stream.py +++ b/examples/blocking/stream.py @@ -8,7 +8,10 @@ def main(): with wreq.blocking.get("https://httpbin.io/stream/20") as resp: with resp.stream() as streamer: for chunk in streamer: - print(chunk) + if isinstance(chunk, memoryview): + print(bytes(chunk)) + else: + print("Trailers:", chunk) time.sleep(0.1) diff --git a/examples/header_map.py b/examples/header_map.py index 2bc43d1c..860f1654 100644 --- a/examples/header_map.py +++ b/examples/header_map.py @@ -9,9 +9,11 @@ # Add Accept header (second value) headers.insert("Accept", "text/html") # Get all values for 'Accept' header - print("All Accept:", list(headers.get_all("Accept"))) + print("All Accept:", [bytes(value) for value in headers.get_all("Accept")]) # Get the value for 'Content-Type' header - print("Content-Type:", headers.get("Content-Type")) + content_type = headers.get("Content-Type") + if content_type is not None: + print("Content-Type:", bytes(content_type)) # Print total number of values in the map print("len (all values):", headers.len()) # Print number of unique keys in the map diff --git a/examples/request.py b/examples/request.py index dc39f134..f7933dcd 100644 --- a/examples/request.py +++ b/examples/request.py @@ -12,13 +12,15 @@ async def main(): print("Cookies: ", resp.cookies) print("Content-Length: ", resp.content_length) print("Remote Address: ", resp.remote_addr) - print("Headers set-cookie: ", resp.headers["set-cookie"]) + set_cookie = resp.headers["set-cookie"] + if set_cookie is not None: + print("Headers set-cookie: ", bytes(set_cookie)) - for key in resp.headers: - print(key) + for key in resp.headers.keys(): + print(bytes(key)) for key, value in resp.headers: - print(f"{key}: {value}") + print(f"{bytes(key)}: {bytes(value)}") for cookie in resp.cookies: print(cookie) diff --git a/examples/stream.py b/examples/stream.py index 42ace271..454fc619 100644 --- a/examples/stream.py +++ b/examples/stream.py @@ -8,7 +8,10 @@ async def main(): async with resp: async with resp.stream() as streamer: async for chunk in streamer: - print(chunk) + if isinstance(chunk, memoryview): + print(bytes(chunk)) + else: + print("Trailers:", chunk) await asyncio.sleep(0.1) diff --git a/python/wreq/blocking.py b/python/wreq/blocking.py index 4a30eb75..ddc7e794 100644 --- a/python/wreq/blocking.py +++ b/python/wreq/blocking.py @@ -89,7 +89,7 @@ def raise_for_status(self) -> None: def stream(self) -> Streamer: r""" - Get the response into a `Streamer` of `bytes` from the body. + Stream the body as read-only memoryviews, with HeaderMap frames for trailers. """ ... @@ -104,9 +104,11 @@ def json(self) -> Any: Get the JSON content of the response. """ - def bytes(self) -> bytes: + def bytes(self) -> memoryview: r""" - Get the bytes content of the response. + Read the body as a read-only memoryview without copying it into Python bytes. + The view remains valid after the response is closed or deleted. + Use bytes(view) or view.tobytes() for a copy; view.release() releases this view. """ ... diff --git a/python/wreq/header.py b/python/wreq/header.py index 22291ae1..cb1060bd 100644 --- a/python/wreq/header.py +++ b/python/wreq/header.py @@ -27,9 +27,12 @@ class HeaderMap: The implementation follows HTTP/1.1 specifications for header handling and provides both dictionary-like access and specialized methods for HTTP header manipulation. + + Header names and values are returned as read-only memoryviews. + Each view retains its backing data even if the map is changed or deleted. """ - def __getitem__(self, key: str) -> bytes | None: + def __getitem__(self, key: str) -> memoryview | None: """Get the first value for a header name (case-insensitive).""" ... @@ -49,7 +52,7 @@ def __len__(self) -> int: """Return the total number of header values (not unique names).""" ... - def __iter__(self) -> Iterator[Tuple[bytes, bytes]]: + def __iter__(self) -> Iterator[Tuple[memoryview, memoryview]]: """Iterate all header(name, value) pairs, including duplicates for multiple values.""" ... @@ -140,7 +143,7 @@ def remove(self, key: str) -> None: """ ... - def get(self, key: str, default: bytes | None = None) -> bytes | None: + def get(self, key: str, default: bytes | None = None) -> memoryview | None: r""" Get the first value for a header name with optional default. @@ -153,15 +156,15 @@ def get(self, key: str, default: bytes | None = None) -> bytes | None: default: Value to return if header doesn't exist Returns: - The first header value as bytes, or the default value + A read-only view of the first header value, or of the default value """ ... - def get_all(self, key: str) -> Iterator[bytes]: + def get_all(self, key: str) -> list[memoryview]: r""" Get all values for a header name. - Returns an iterator over all values associated with the header name. + Returns a list of read-only views of all values associated with the header name. This is useful for headers that can have multiple values, such as Set-Cookie, Accept-Encoding, or custom headers. @@ -169,25 +172,25 @@ def get_all(self, key: str) -> Iterator[bytes]: key: The header name (case-insensitive) Returns: - An iterator over all header values + A list of read-only header value views """ ... - def values(self) -> Iterator[bytes]: + def values(self) -> list[memoryview]: """ - Iterate over all header values. + Get all header values. Returns: - An iterator over all header values as bytes. + A list of read-only header value views. """ ... - def keys(self) -> Iterator[bytes]: + def keys(self) -> list[memoryview]: """ - Iterate over unique header names. + Get all unique header names. Returns: - An iterator over unique header names as bytes. + A list of read-only header name views. """ ... @@ -247,6 +250,8 @@ class OrigHeaderMap: The map stores a mapping between the case-insensitive (standard) header name and the original case-sensitive header name as it appeared in the HTTP message. + Iteration returns pairs of read-only memoryviews that retain their backing data. + Example: If an HTTP message included the following headers: @@ -277,7 +282,7 @@ def __init__( """ ... - def __iter__(self) -> Iterator[Tuple[bytes, bytes]]: + def __iter__(self) -> Iterator[Tuple[memoryview, memoryview]]: """ Returns an iterator over the (standard_name, original_name) pairs. diff --git a/python/wreq/tls.py b/python/wreq/tls.py index fa7b72c1..cab2857d 100644 --- a/python/wreq/tls.py +++ b/python/wreq/tls.py @@ -446,8 +446,8 @@ class TlsInfo: Information about the established TLS connection. """ - def peer_certificate(self) -> bytes | None: + def peer_certificate(self) -> memoryview | None: """ - Get the DER encoded leaf certificate of the peer. + Get a read-only memoryview of the peer's DER-encoded leaf certificate. """ ... diff --git a/python/wreq/wreq.py b/python/wreq/wreq.py index 744ee9b2..9a8148c8 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -175,11 +175,13 @@ def __init__( class Message: r""" A WebSocket message. + + Binary fields return read-only memoryviews that retain their backing data. """ - data: bytes | None + data: memoryview | None r""" - Returns the data of the message as bytes. + Returns the data of the message as a read-only memoryview. """ text: str | None @@ -187,17 +189,17 @@ class Message: Returns the text content of the message if it is a text message. """ - binary: bytes | None + binary: memoryview | None r""" Returns the binary data of the message if it is a binary message. """ - ping: bytes | None + ping: memoryview | None r""" Returns the ping data of the message if it is a ping message. """ - pong: bytes | None + pong: memoryview | None r""" Returns the pong data of the message if it is a pong message. """ @@ -274,13 +276,14 @@ def __str__(self) -> str: ... class Streamer: r""" A stream response. - An asynchronous iterator yielding data chunks (bytes) or HTTP trailers (HeaderMap) from the response stream. + An asynchronous iterator yielding read-only memoryviews or HTTP trailers (HeaderMap) from the response stream. Used to stream response content and receive HTTP trailers if present. Implemented in the `stream` method of the `Response` class. Can be used in an asynchronous for loop in Python. - When streaming a response, each iteration yields either a bytes object (for body data) or a HeaderMap (for HTTP trailers, if the server sends them). + When streaming a response, each iteration yields either a memoryview (for body data) or a HeaderMap (for HTTP trailers, if the server sends them). This allows you to access HTTP/1.1 or HTTP/2 trailers in addition to the main body. + Data views retain their backing data after the stream is closed. # Examples @@ -293,8 +296,8 @@ async def main(): resp = await wreq.get("https://example.com/stream-with-trailers") async with resp.stream() as streamer: async for chunk in streamer: - if isinstance(chunk, bytes): - print("Chunk: ", chunk) + if isinstance(chunk, memoryview): + print("Chunk: ", bytes(chunk)) elif isinstance(chunk, HeaderMap): print("Trailers: ", chunk) await asyncio.sleep(0.1) @@ -305,11 +308,11 @@ async def main(): """ def __iter__(self) -> "Streamer": ... - def __next__(self) -> bytes | HeaderMap: ... + def __next__(self) -> memoryview | HeaderMap: ... def __enter__(self) -> Any: ... def __exit__(self, _exc_type: Any, _exc_value: Any, _traceback: Any) -> None: ... - async def __aiter__(self) -> "Streamer": ... - async def __anext__(self) -> bytes | HeaderMap: ... + def __aiter__(self) -> "Streamer": ... + async def __anext__(self) -> memoryview | HeaderMap: ... async def __aenter__(self) -> Any: ... async def __aexit__( self, _exc_type: Any, _exc_value: Any, _traceback: Any @@ -401,7 +404,7 @@ def raise_for_status(self) -> None: def stream(self) -> Streamer: r""" - Get the response into a `Streamer` of `bytes` from the body. + Stream the body as read-only memoryviews, with HeaderMap frames for trailers. """ ... @@ -416,9 +419,11 @@ async def json(self) -> Any: Get the JSON content of the response. """ - async def bytes(self) -> bytes: + async def bytes(self) -> memoryview: r""" - Get the bytes content of the response. + Read the body as a read-only memoryview without copying it into Python bytes. + The view remains valid after the response is closed or deleted. + Use bytes(view) or view.tobytes() for a copy; view.release() releases this view. """ ... diff --git a/src/buffer.rs b/src/buffer.rs index b6c66221..44d74eb3 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -18,29 +18,26 @@ use std::os::raw::c_int; use bytes::Bytes; -use pyo3::{ffi, prelude::*}; +use pyo3::{exceptions::PyOverflowError, ffi, prelude::*, types::PyMemoryView}; use wreq::header::{HeaderCaseName, HeaderName, HeaderValue}; -/// [`PyBuffer`] enables zero-copy conversion of Rust [`Bytes`] to Python bytes. +/// Exposes owned Rust bytes as a read-only Python memoryview without copying. pub struct PyBuffer(BufferView); #[pyclass(frozen, skip_from_py_object)] struct BufferView(Bytes); -// ===== PyBuffer ===== +// ===== impl PyBuffer ===== impl<'a> IntoPyObject<'a> for PyBuffer { - type Target = PyAny; + type Target = PyMemoryView; type Output = Bound<'a, Self::Target>; type Error = PyErr; #[inline(always)] fn into_pyobject(self, py: Python<'a>) -> Result { let buffer = self.0.into_pyobject(py)?; - #[allow(unsafe_code)] - unsafe { - Bound::from_owned_ptr_or_err(py, ffi::PyBytes_FromObject(buffer.as_ptr())) - } + PyMemoryView::from(buffer.as_any()) } } @@ -85,10 +82,12 @@ impl From for PyBuffer { } } -// ===== BufferView ===== +// ===== impl BufferView ===== #[pymethods] impl BufferView { + /// # Safety + /// `view` must be a writable `Py_buffer` supplied by the Python buffer protocol. #[allow(unsafe_code)] unsafe fn __getbuffer__( slf: PyRef, @@ -96,13 +95,16 @@ impl BufferView { flags: c_int, ) -> PyResult<()> { let bytes = &slf.0; + let len = ffi::Py_ssize_t::try_from(bytes.len()) + .map_err(|_| PyOverflowError::new_err("buffer length exceeds Python's maximum size"))?; + // SAFETY: PyO3 supplies a valid output buffer. FillInfo retains the exporter, + // which owns immutable Bytes for the lifetime of this read-only buffer. let ret = unsafe { - // Fill the Py_buffer struct with information about the buffer ffi::PyBuffer_FillInfo( view, slf.as_ptr() as *mut _, bytes.as_ptr() as *mut _, - bytes.len() as _, + len, 1, flags, ) @@ -113,3 +115,25 @@ impl BufferView { Ok(()) } } + +#[cfg(test)] +mod tests { + use pyo3::buffer::PyBuffer as PythonBuffer; + + use super::*; + + #[test] + fn memoryview_shares_owned_bytes() { + Python::initialize(); + Python::attach(|py| { + let bytes = Bytes::from(vec![0, 1, 255]); + let ptr = bytes.as_ptr(); + let view = PyBuffer::from(bytes).into_pyobject(py).unwrap(); + let buffer = PythonBuffer::::get(view.as_any()).unwrap(); + + assert_eq!(buffer.buf_ptr().cast_const().cast::(), ptr); + assert!(buffer.readonly()); + assert_eq!(buffer.to_vec(py).unwrap(), [0, 1, 255]); + }); + } +} diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 179aea10..78ab7da8 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -37,7 +37,7 @@ pub enum PyBytesLike { String(PyBackedStr), } -/// A bytes-like object that can be into Python. +/// A response frame exposed as a read-only memoryview or a header map. #[derive(IntoPyObject)] pub enum Frame { Bytes(PyBuffer), @@ -50,7 +50,7 @@ pub struct PyStream { pending: Pending, } -/// A bytes stream response. +/// A response stream yielding read-only memoryviews and any trailing headers. #[derive(Clone)] #[pyclass(subclass, frozen, skip_from_py_object)] pub struct Streamer(Arc>>); diff --git a/src/client/resp/http.rs b/src/client/resp/http.rs index 6e0cb882..1831b822 100644 --- a/src/client/resp/http.rs +++ b/src/client/resp/http.rs @@ -209,7 +209,7 @@ impl Response { .map_err(Into::into) } - /// Get the response into a `Stream` of `Bytes` from the body. + /// Stream read-only memoryviews and any trailing headers from the body. pub fn stream(&self) -> PyResult { self.stream_response() .map(Streamer::new) @@ -239,7 +239,7 @@ impl Response { NoGIL::new(fut, cancel).await } - /// Get the bytes content of the response. + /// Read the body as a read-only memoryview, retaining its data after the response closes. pub async fn bytes(&self, #[pyo3(cancel_handle)] cancel: CancelHandle) -> PyResult { let fut = self .cache_response() @@ -366,7 +366,7 @@ impl BlockingResponse { self.0.raise_for_status() } - /// Get the response into a `Stream` of `Bytes` from the body. + /// Stream read-only memoryviews and any trailing headers from the body. #[inline] pub fn stream(&self) -> PyResult { self.0.stream() @@ -397,7 +397,7 @@ impl BlockingResponse { }) } - /// Get the bytes content of the response. + /// Read the body as a read-only memoryview, retaining its data after the response closes. pub fn bytes(&self, py: Python) -> PyResult { py.detach(|| { let fut = self diff --git a/src/client/resp/ws/msg.rs b/src/client/resp/ws/msg.rs index ceb5bfd2..652834a4 100644 --- a/src/client/resp/ws/msg.rs +++ b/src/client/resp/ws/msg.rs @@ -39,7 +39,7 @@ pub struct Message(pub message::Message); #[pymethods] impl Message { - /// Returns the data of the message as bytes. + /// Returns the message data as a read-only memoryview. #[getter] pub fn data(&self) -> Option { let bytes = match &self.0 { @@ -62,7 +62,7 @@ impl Message { } } - /// Returns the binary data of the message if it is a binary message. + /// Returns a read-only memoryview if this is a binary message. #[getter] pub fn binary(&self) -> Option { if let message::Message::Binary(data) = &self.0 { @@ -72,7 +72,7 @@ impl Message { } } - /// Returns the ping data of the message if it is a ping message. + /// Returns a read-only memoryview if this is a ping message. #[getter] pub fn ping(&self) -> Option { if let message::Message::Ping(data) = &self.0 { @@ -82,7 +82,7 @@ impl Message { } } - /// Returns the pong data of the message if it is a pong message. + /// Returns a read-only memoryview if this is a pong message. #[getter] pub fn pong(&self) -> Option { if let message::Message::Pong(data) = &self.0 { diff --git a/src/header.rs b/src/header.rs index bf8794f1..701bfe9f 100644 --- a/src/header.rs +++ b/src/header.rs @@ -8,12 +8,12 @@ use wreq::header::{self, HeaderName, HeaderValue}; use crate::{buffer::PyBuffer, error::Error}; -/// A HTTP header map. +/// An HTTP header map whose names and values are exposed as read-only memoryviews. #[derive(Clone)] #[pyclass(subclass, str, skip_from_py_object)] pub struct HeaderMap(pub header::HeaderMap); -/// A HTTP original header map. +/// An HTTP original header map whose iterator exposes read-only memoryviews. #[derive(Clone)] #[pyclass(subclass, str, skip_from_py_object)] pub struct OrigHeaderMap(pub header::OrigHeaderMap); @@ -58,7 +58,7 @@ impl HeaderMap { HeaderMap(headers) } - /// Returns a reference to the value associated with the key. + /// Returns a read-only memoryview of the value associated with the key. /// /// If there are multiple values associated with the key, then the first one /// is returned. Use `get_all` to get all values associated with a given @@ -84,7 +84,7 @@ impl HeaderMap { .map(PyBuffer::from) } - /// Returns a view of all values associated with a key. + /// Returns a list of read-only memoryviews for the values associated with a key. #[pyo3(signature = (key))] fn get_all<'py>(&self, py: Python<'py>, key: PyBackedStr) -> Vec { py.detach(|| { @@ -137,7 +137,7 @@ impl HeaderMap { py.detach(|| self.0.contains_key::<&str>(key.as_ref())) } - /// An iterator visiting all keys. + /// Returns a list of read-only memoryviews for all keys. #[inline] fn keys<'py>(&self, py: Python<'py>) -> Vec { py.detach(|| { @@ -149,7 +149,7 @@ impl HeaderMap { }) } - /// An iterator visiting all values. + /// Returns a list of read-only memoryviews for all values. #[inline] fn values<'py>(&self, py: Python<'py>) -> Vec { py.detach(|| { diff --git a/src/tls.rs b/src/tls.rs index e81a323b..59e3d2a3 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -426,7 +426,7 @@ pub struct TlsInfo(pub wreq::tls::TlsInfo); #[pymethods] impl TlsInfo { - /// Get the DER encoded leaf certificate of the peer. + /// Get a read-only memoryview of the peer's DER-encoded leaf certificate. #[inline] pub fn peer_certificate(&self) -> Option { self.0 diff --git a/tests/buffer_test.py b/tests/buffer_test.py new file mode 100644 index 00000000..dda4c0bb --- /dev/null +++ b/tests/buffer_test.py @@ -0,0 +1,183 @@ +import asyncio +import datetime +import gc +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +import wreq +from wreq import Message, Version, blocking +from wreq.header import HeaderMap, OrigHeaderMap + + +def assert_readonly_view(view, expected): + assert type(view) is memoryview + assert view.readonly + assert view.format == "B" + assert view.ndim == 1 + assert view.itemsize == 1 + assert view.shape == (len(expected),) + assert view == expected + + +def test_header_and_message_views(): + headers = HeaderMap({"X-Buffer": "value"}) + headers.append("X-Buffer", "other") + original = OrigHeaderMap(["X-Buffer"]) + messages = [ + Message.from_text("text"), + Message.from_binary(b"binary"), + Message.from_ping(b"ping"), + Message.from_pong(b"pong"), + Message.from_binary(b""), + ] + assert messages[0].text == "text" + assert type(messages[0].text) is str + round_trip = Message.from_binary(bytes(messages[1].binary)) + assert round_trip.binary == b"binary" + assert headers.get("missing") is None + assert headers.get("missing", b"default") == b"default" + + views = [ + headers.get("X-Buffer"), + headers["X-Buffer"], + headers.get("missing", b"default"), + *headers.get_all("X-Buffer"), + *headers.keys(), + *headers.values(), + *(view for pair in headers for view in pair), + *(view for pair in original for view in pair), + *(message.data for message in messages), + messages[1].binary, + messages[2].ping, + messages[3].pong, + round_trip.binary, + ] + expected = [bytes(view) for view in views] + for view, data in zip(views, expected): + assert_readonly_view(view, data) + assert hash(view) == hash(data) + assert {view: "value"}[data] == "value" + + with pytest.raises(TypeError): + views[0][0] = 0 + + headers.clear() + del headers, original, messages, round_trip + gc.collect() + for view, data in zip(views, expected): + assert view == data + + parent = views[0] + child = parent[1:] + parent.release() + assert child == b"alue" + child.release() + + +@pytest.fixture +def buffer_http_server(): + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def setup(self): + super().setup() + self.connection.settimeout(3) + + def do_GET(self): + self.send_response(200) + self.send_header("Connection", "close") + if self.path == "/stream": + self.send_header("Transfer-Encoding", "chunked") + self.send_header("Trailer", "X-Buffer") + self.end_headers() + self.wfile.write( + b"5\r\nhello\r\n6\r\n world\r\n0\r\nX-Buffer: complete\r\n\r\n" + ) + else: + self.send_header("Content-Length", "11") + self.end_headers() + self.wfile.write(b"hello world") + + def log_message(self, *_): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread( + target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True + ) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + assert not thread.is_alive() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("blocking_api", [False, True]) +async def test_response_and_stream_views(buffer_http_server, blocking_api): + client_type = blocking.Client if blocking_api else wreq.Client + client = client_type(no_proxy=True, timeout=datetime.timedelta(seconds=3)) + + async def call(method, *args, **kwargs): + if blocking_api: + return await asyncio.wait_for( + asyncio.to_thread(method, *args, **kwargs), timeout=5 + ) + return await asyncio.wait_for(method(*args, **kwargs), timeout=5) + + def collect_blocking(response): + with response.stream() as stream: + return list(stream) + + async def collect_async(response): + async with response.stream() as stream: + return [frame async for frame in stream] + + try: + response = await call( + client.get, f"{buffer_http_server}/body", version=Version.HTTP_11 + ) + view = await call(response.bytes) + repeated = await call(response.bytes) + assert_readonly_view(view, b"hello world") + assert_readonly_view(repeated, b"hello world") + repeated.release() + child = view[6:] + await call(response.close) + del response, repeated + gc.collect() + assert view == b"hello world" + view.release() + assert child == b"world" + + response = await call( + client.get, f"{buffer_http_server}/stream", version=Version.HTTP_11 + ) + if blocking_api: + frames = await asyncio.wait_for( + asyncio.to_thread(collect_blocking, response), timeout=5 + ) + else: + frames = await asyncio.wait_for(collect_async(response), timeout=5) + await call(response.close) + del response + gc.collect() + data = [frame for frame in frames if isinstance(frame, memoryview)] + trailers = [frame for frame in frames if isinstance(frame, HeaderMap)] + assert data + assert b"".join(data) == b"hello world" + assert len(trailers) == 1 + assert_readonly_view(trailers[0]["X-Buffer"], b"complete") + for frame in data: + expected = bytes(frame) + assert_readonly_view(frame, expected) + child = frame[:] + frame.release() + assert child == expected + finally: + client.close() diff --git a/tests/response_test.py b/tests/response_test.py index ad7c017a..df9d0ba9 100644 --- a/tests/response_test.py +++ b/tests/response_test.py @@ -117,4 +117,6 @@ async def test_peer_certificate(): resp = await client.get("https://www.google.com/anything") async with resp: assert resp.tls_info is not None - assert resp.tls_info.peer_certificate() is not None + certificate = resp.tls_info.peer_certificate() + assert type(certificate) is memoryview + assert certificate.readonly diff --git a/tests/tls_test.py b/tests/tls_test.py index 44e9225f..2517deee 100644 --- a/tests/tls_test.py +++ b/tests/tls_test.py @@ -27,10 +27,11 @@ async def test_badssl_invalid_cert(): peer_der_cert = tls_info.peer_certificate() assert peer_der_cert is not None - assert isinstance(peer_der_cert, bytes) + assert type(peer_der_cert) is memoryview + assert peer_der_cert.readonly assert len(peer_der_cert) > 0 - cert_store = CertStore(der_certs=[peer_der_cert]) + cert_store = CertStore(der_certs=[bytes(peer_der_cert)]) assert cert_store is not None client = wreq.Client(tls_verify=cert_store) From 736297bb06e9995487877c7cea3bcf13ac65e5cb Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 17:24:07 +0800 Subject: [PATCH 4/6] docs(buffer): demonstrate native memoryview usage --- docs/source/getting-started/quickstart.md | 21 ++++++++++++++++----- docs/source/guide/basic.md | 12 +++++++----- docs/source/guide/blocking.md | 6 ++++-- examples/blocking/stream.py | 3 ++- examples/header_map.py | 4 ++-- examples/request.py | 6 +++--- examples/stream.py | 3 ++- python/wreq/wreq.py | 3 ++- 8 files changed, 38 insertions(+), 20 deletions(-) diff --git a/docs/source/getting-started/quickstart.md b/docs/source/getting-started/quickstart.md index 0faac302..4db34967 100644 --- a/docs/source/getting-started/quickstart.md +++ b/docs/source/getting-started/quickstart.md @@ -86,17 +86,28 @@ print(data) `response.bytes()` returns a read-only `memoryview`, not a `bytes` object. The view shares Rust-owned data without a copy into Python bytes. Reading a complete response can still allocate memory to combine body chunks. ```python +import hashlib + view = await response.bytes() await response.close() print(view.readonly) # True; closing the response does not invalidate the view - -data = bytes(view) # Copies into a Python bytes object -view.release() +print(hashlib.sha256(view).hexdigest()) # Reads the buffer directly ``` The blocking API returns the same type, without `await`. Stream data frames, WebSocket binary fields, header names and values, and peer certificates also return read-only memoryviews. Each view retains its backing data even after the source object is closed or deleted. -This changes the binary return type. `memoryview` supports the buffer protocol, but has no `.decode()` method or byte-string concatenation. Use `bytes(view)` or `view.tobytes()` when an API requires `bytes`; both copy the data. `view.release()` releases that view, not other views or slices sharing the data. Input types are unchanged. +Use views directly with APIs that accept the buffer protocol, such as `file.write(view)` or `hashlib.sha256(view)`. For text, `str(view, "utf-8")` decodes into a string without an intermediate `bytes` object. + +#### Copying data + +Only convert when you need an independent `bytes` object or an API requires one: + +```python +data = bytes(view) # Copies the data; view.tobytes() also copies +view.release() +``` + +This changes the binary return type. `memoryview` has no `.decode()` method or byte-string concatenation. When you finish using a view, you can call `view.release()`; this does not release other views or slices sharing the data. Input types are unchanged. When passing a view back to wreq's binary inputs (`body`, `Part`, `Message` constructors, or `CertStore`), convert it with `bytes(view)`. These inputs do not treat a memoryview as binary data. @@ -107,7 +118,7 @@ Response headers are available as a [HeaderMap](../api/header/?h=HeaerMap#wreq.h ```python content_type = response.headers.get("content-type") if content_type is not None: - print(bytes(content_type).decode("ascii")) + print(str(content_type, "ascii")) # application/json ``` diff --git a/docs/source/guide/basic.md b/docs/source/guide/basic.md index 8c6c55e4..793de9b5 100644 --- a/docs/source/guide/basic.md +++ b/docs/source/guide/basic.md @@ -142,15 +142,15 @@ headers.append("Accept", "text/html") # Retrieve a single value content_type = headers.get("Content-Type") if content_type is not None: - print(bytes(content_type).decode("ascii")) + print(str(content_type, "ascii")) # application/json # Retrieve all values for a multi-value header -print([bytes(value).decode("ascii") for value in headers.get_all("Accept")]) +print([str(value, "ascii") for value in headers.get_all("Accept")]) # ['application/json', 'text/html'] ``` -Header names and values are read-only `memoryview` objects. Convert them to `bytes` before using byte-string methods such as `.decode()`. +Header names and values are read-only `memoryview` objects. Decode text with `str(view, encoding)` without first copying it into `bytes`. Pass the `HeaderMap` to any request method via the `headers` argument: @@ -165,6 +165,8 @@ response = await wreq.get("https://httpbin.org/headers", headers=headers) For large responses, you can read the body incrementally instead of loading it all into memory at once. Use `resp.stream()` as an async iterator: ```python +import sys + from wreq import Client, HeaderMap async def main(): @@ -173,11 +175,11 @@ async def main(): async for chunk in response.stream(): if isinstance(chunk, memoryview): - print(bytes(chunk).decode("utf-8")) + sys.stdout.buffer.write(chunk) elif isinstance(chunk, HeaderMap): print("Trailers:", chunk) ``` -Data chunks are read-only `memoryview` objects; trailer frames are `HeaderMap` objects. Decode data only if you know the response body is text. Each view stays valid after the stream is closed. See [Binary data](../getting-started/quickstart.md#binary-data) for copying and releasing views. +Data chunks are read-only `memoryview` objects; trailer frames are `HeaderMap` objects. The example writes data directly through the buffer protocol. Each view stays valid after the stream is closed. See [Binary data](../getting-started/quickstart.md#binary-data) for copying and releasing views. --- diff --git a/docs/source/guide/blocking.md b/docs/source/guide/blocking.md index f94a75cd..a65cc3d9 100644 --- a/docs/source/guide/blocking.md +++ b/docs/source/guide/blocking.md @@ -166,6 +166,8 @@ if __name__ == "__main__": ### Streaming Response ```python +import sys + from wreq.blocking import Client @@ -176,7 +178,7 @@ def main(): with resp.stream() as streamer: for chunk in streamer: if isinstance(chunk, memoryview): - print(bytes(chunk)) + sys.stdout.buffer.write(chunk) else: print("Trailers:", chunk) @@ -185,4 +187,4 @@ if __name__ == "__main__": main() ``` -Data chunks are read-only `memoryview` objects that stay valid after the stream is closed. `resp.bytes()` returns the same type; use `bytes(view)` or `view.tobytes()` to make a copy when needed. +Data chunks are read-only `memoryview` objects that stay valid after the stream is closed. `resp.bytes()` returns the same type. Pass views directly to APIs that accept the buffer protocol; use `bytes(view)` or `view.tobytes()` only when you need a copy. diff --git a/examples/blocking/stream.py b/examples/blocking/stream.py index fae708fe..a3bda692 100644 --- a/examples/blocking/stream.py +++ b/examples/blocking/stream.py @@ -1,3 +1,4 @@ +import sys import time import wreq @@ -9,7 +10,7 @@ def main(): with resp.stream() as streamer: for chunk in streamer: if isinstance(chunk, memoryview): - print(bytes(chunk)) + sys.stdout.buffer.write(chunk) else: print("Trailers:", chunk) time.sleep(0.1) diff --git a/examples/header_map.py b/examples/header_map.py index 860f1654..1bf038af 100644 --- a/examples/header_map.py +++ b/examples/header_map.py @@ -9,11 +9,11 @@ # Add Accept header (second value) headers.insert("Accept", "text/html") # Get all values for 'Accept' header - print("All Accept:", [bytes(value) for value in headers.get_all("Accept")]) + print("All Accept:", [str(value, "ascii") for value in headers.get_all("Accept")]) # Get the value for 'Content-Type' header content_type = headers.get("Content-Type") if content_type is not None: - print("Content-Type:", bytes(content_type)) + print("Content-Type:", str(content_type, "ascii")) # Print total number of values in the map print("len (all values):", headers.len()) # Print number of unique keys in the map diff --git a/examples/request.py b/examples/request.py index f7933dcd..857e6a45 100644 --- a/examples/request.py +++ b/examples/request.py @@ -14,13 +14,13 @@ async def main(): print("Remote Address: ", resp.remote_addr) set_cookie = resp.headers["set-cookie"] if set_cookie is not None: - print("Headers set-cookie: ", bytes(set_cookie)) + print("Headers set-cookie: ", str(set_cookie, "latin-1")) for key in resp.headers.keys(): - print(bytes(key)) + print(str(key, "ascii")) for key, value in resp.headers: - print(f"{bytes(key)}: {bytes(value)}") + print(f"{str(key, 'ascii')}: {str(value, 'latin-1')}") for cookie in resp.cookies: print(cookie) diff --git a/examples/stream.py b/examples/stream.py index 454fc619..8f7f10ce 100644 --- a/examples/stream.py +++ b/examples/stream.py @@ -1,4 +1,5 @@ import asyncio +import sys import wreq from wreq import Response @@ -9,7 +10,7 @@ async def main(): async with resp.stream() as streamer: async for chunk in streamer: if isinstance(chunk, memoryview): - print(bytes(chunk)) + sys.stdout.buffer.write(chunk) else: print("Trailers:", chunk) await asyncio.sleep(0.1) diff --git a/python/wreq/wreq.py b/python/wreq/wreq.py index 9a8148c8..31155aa2 100644 --- a/python/wreq/wreq.py +++ b/python/wreq/wreq.py @@ -289,6 +289,7 @@ class Streamer: ```python import asyncio + import sys import wreq from wreq import Method, Emulation, HeaderMap @@ -297,7 +298,7 @@ async def main(): async with resp.stream() as streamer: async for chunk in streamer: if isinstance(chunk, memoryview): - print("Chunk: ", bytes(chunk)) + sys.stdout.buffer.write(chunk) elif isinstance(chunk, HeaderMap): print("Trailers: ", chunk) await asyncio.sleep(0.1) From 71dbcec563f911b82e68147f2cdc80971422772c Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 17:58:22 +0800 Subject: [PATCH 5/6] fix(buffer): break Python subclass ownership cycles --- docs/source/getting-started/quickstart.md | 2 + src/buffer.rs | 20 ++++++- src/client/body.rs | 16 +++--- src/client/body/multipart.rs | 38 ++++++------- src/client/body/stream.rs | 16 +++--- src/client/resp/ws.rs | 12 ++--- src/client/resp/ws/cmd.rs | 10 ++-- src/client/resp/ws/msg.rs | 35 ++++++------ src/cookie.rs | 6 +-- src/extractor.rs | 52 +++++++++++++++++- src/header.rs | 44 +++++++-------- src/proxy.rs | 7 ++- tests/buffer_test.py | 65 ++++++++++++++++++++++- 13 files changed, 222 insertions(+), 101 deletions(-) diff --git a/docs/source/getting-started/quickstart.md b/docs/source/getting-started/quickstart.md index 4db34967..e7679a17 100644 --- a/docs/source/getting-started/quickstart.md +++ b/docs/source/getting-started/quickstart.md @@ -109,6 +109,8 @@ view.release() This changes the binary return type. `memoryview` has no `.decode()` method or byte-string concatenation. When you finish using a view, you can call `view.release()`; this does not release other views or slices sharing the data. Input types are unchanged. +Built-in `bytes` and `str` inputs can share their storage. Their subclasses are copied from the actual contents to avoid hidden reference cycles; deleting a view releases its ownership normally, without requiring an explicit `release()`. + When passing a view back to wreq's binary inputs (`body`, `Part`, `Message` constructors, or `CertStore`), convert it with `bytes(view)`. These inputs do not treat a memoryview as binary data. ### Response headers diff --git a/src/buffer.rs b/src/buffer.rs index 44d74eb3..b44d6f25 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -118,9 +118,13 @@ impl BufferView { #[cfg(test)] mod tests { - use pyo3::buffer::PyBuffer as PythonBuffer; + use pyo3::{ + buffer::PyBuffer as PythonBuffer, + types::{PyBytes, PyString}, + }; use super::*; + use crate::extractor::{BytesInput, StrInput}; #[test] fn memoryview_shares_owned_bytes() { @@ -134,6 +138,20 @@ mod tests { assert_eq!(buffer.buf_ptr().cast_const().cast::(), ptr); assert!(buffer.readonly()); assert_eq!(buffer.to_vec(py).unwrap(), [0, 1, 255]); + + let binary = PyBytes::new(py, b"builtin bytes"); + let input = binary.extract::().unwrap(); + assert_eq!(input.0.as_ptr(), binary.as_bytes().as_ptr()); + let view = PyBuffer::from(input.0).into_pyobject(py).unwrap(); + let buffer = PythonBuffer::::get(view.as_any()).unwrap(); + assert_eq!( + buffer.buf_ptr().cast_const().cast::(), + binary.as_bytes().as_ptr() + ); + + let text = PyString::new(py, "builtin text"); + let input = text.extract::().unwrap(); + assert_eq!(input.0.as_ptr(), text.to_str().unwrap().as_ptr()); }); } } diff --git a/src/client/body.rs b/src/client/body.rs index d7fe67ac..6b4ecf6c 100644 --- a/src/client/body.rs +++ b/src/client/body.rs @@ -5,24 +5,20 @@ mod json; pub mod multipart; mod stream; -use bytes::Bytes; -use pyo3::{ - FromPyObject, PyResult, - prelude::*, - pybacked::{PyBackedBytes, PyBackedStr}, -}; +use pyo3::{FromPyObject, PyResult, prelude::*}; pub use self::{ form::Form, json::Json, stream::{PyStream, Streamer}, }; +use crate::extractor::{BytesInput, StrInput}; /// Represents the body of an HTTP request. #[derive(FromPyObject)] pub enum Body { - Text(PyBackedStr), - Bytes(PyBackedBytes), + Text(StrInput), + Bytes(BytesInput), Form(Form), Json(Json), Stream(PyStream), @@ -41,8 +37,8 @@ impl TryFrom for wreq::Body { .map_err(crate::Error::Json) .map(wreq::Body::from) .map_err(Into::into), - Body::Text(s) => Ok(wreq::Body::from(Bytes::from_owner(s))), - Body::Bytes(bytes) => Ok(wreq::Body::from(Bytes::from_owner(bytes))), + Body::Text(s) => Ok(wreq::Body::from(s.0)), + Body::Bytes(bytes) => Ok(wreq::Body::from(bytes.0)), Body::Stream(stream) => Ok(wreq::Body::wrap_stream(stream)), } } diff --git a/src/client/body/multipart.rs b/src/client/body/multipart.rs index 51f561e5..34f05280 100644 --- a/src/client/body/multipart.rs +++ b/src/client/body/multipart.rs @@ -1,14 +1,14 @@ use std::path::PathBuf; -use bytes::Bytes; -use pyo3::{ - prelude::*, - pybacked::{PyBackedBytes, PyBackedStr}, - types::PyTuple, -}; +use pyo3::{prelude::*, types::PyTuple}; use wreq::{Body, multipart}; -use crate::{client::body::PyStream, error::Error, header::HeaderMap}; +use crate::{ + client::body::PyStream, + error::Error, + extractor::{BytesInput, StrInput}, + header::HeaderMap, +}; /// A multipart form for a request. #[pyclass(subclass)] @@ -20,8 +20,8 @@ pub struct Multipart { /// The data for a part value of a multipart form. #[derive(FromPyObject)] pub enum Value { - Text(PyBackedStr), - Bytes(PyBackedBytes), + Text(StrInput), + Bytes(BytesInput), File(PathBuf), Stream(PyStream), } @@ -44,12 +44,12 @@ impl Multipart { /// Creates a new multipart. #[new] #[pyo3(signature = (*parts))] - pub fn new(py: Python, parts: &Bound) -> PyResult { + pub fn new(parts: &Bound) -> PyResult { let mut new_parts = Vec::with_capacity(parts.len()); for part in parts { let part = part.cast::()?; let mut part = part.borrow_mut(); - new_parts.push(part.try_clone(py)?); + new_parts.push(part.try_clone()?); } Ok(Self { @@ -88,14 +88,14 @@ impl FromPyObject<'_, '_> for Multipart { // ===== impl Value ===== impl Value { - fn try_clone(&self, py: Python) -> Option { + fn try_clone(&self) -> Option { match self { Value::Text(text) => { - let text = text.clone_ref(py); + let text = text.clone(); Some(Value::Text(text)) } Value::Bytes(bytes) => { - let bytes = bytes.clone_ref(py); + let bytes = bytes.clone(); Some(Value::Bytes(bytes)) } Value::File(path) => { @@ -125,14 +125,14 @@ impl Part { let value = self .value .as_ref() - .and_then(|value| value.try_clone(py)) + .and_then(Value::try_clone) .or_else(|| self.value.take()) .ok_or_else(|| Error::Memory)?; py.detach(move || { let mut inner = match value { - Value::Text(text) => multipart::Part::stream(Bytes::from_owner(text)), - Value::Bytes(bytes) => multipart::Part::stream(Bytes::from_owner(bytes)), + Value::Text(text) => multipart::Part::stream(text.0), + Value::Bytes(bytes) => multipart::Part::stream(bytes.0), Value::File(path) => pyo3_async_runtimes::tokio::get_runtime() .block_on(multipart::Part::file(path)) .map_err(Error::from)?, @@ -161,11 +161,11 @@ impl Part { }) } - fn try_clone(&mut self, py: Python) -> PyResult { + fn try_clone(&mut self) -> PyResult { if let Some(part) = self .value .as_ref() - .and_then(|value| value.try_clone(py)) + .and_then(Value::try_clone) .map(|value| self.with_value(value)) { return Ok(part); diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 78ab7da8..d7b8d9e6 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -7,18 +7,14 @@ use std::{ use bytes::Bytes; use futures_util::{FutureExt, Stream, StreamExt, stream::BoxStream}; use http_body_util::BodyExt; -use pyo3::{ - coroutine::CancelHandle, - intern, - prelude::*, - pybacked::{PyBackedBytes, PyBackedStr}, -}; +use pyo3::{coroutine::CancelHandle, intern, prelude::*}; use tokio::{sync::Mutex, task::JoinHandle}; use crate::{ buffer::PyBuffer, client::nogil::NoGIL, error::{self, Error}, + extractor::{BytesInput, StrInput}, header::HeaderMap, }; @@ -33,8 +29,8 @@ enum PyStreamSource { /// A bytes-like object that can be extracted from Python. #[derive(FromPyObject)] pub enum PyBytesLike { - Bytes(PyBackedBytes), - String(PyBackedStr), + Bytes(BytesInput), + String(StrInput), } /// A response frame exposed as a read-only memoryview or a header map. @@ -183,8 +179,8 @@ impl From for Bytes { #[inline] fn from(value: PyBytesLike) -> Self { match value { - PyBytesLike::Bytes(b) => Bytes::from_owner(b), - PyBytesLike::String(s) => Bytes::from_owner(s), + PyBytesLike::Bytes(b) => b.0, + PyBytesLike::String(s) => s.0, } } } diff --git a/src/client/resp/ws.rs b/src/client/resp/ws.rs index cf4a4394..03e5a8c3 100644 --- a/src/client/resp/ws.rs +++ b/src/client/resp/ws.rs @@ -4,7 +4,7 @@ pub mod msg; use std::{fmt::Display, time::Duration}; use msg::Message; -use pyo3::{coroutine::CancelHandle, prelude::*, pybacked::PyBackedStr}; +use pyo3::{coroutine::CancelHandle, prelude::*}; use tokio::sync::mpsc; use wreq::{ header::HeaderValue, @@ -15,6 +15,7 @@ use crate::{ client::{SocketAddr, nogil::NoGIL}, cookie::Cookie, error::Error, + extractor::StrInput, header::HeaderMap, http::{StatusCode, Version}, }; @@ -136,7 +137,7 @@ impl WebSocket { &self, #[pyo3(cancel_handle)] cancel: CancelHandle, code: Option, - reason: Option, + reason: Option, ) -> PyResult<()> { let tx = self.cmd.clone(); NoGIL::new(cmd::close(tx, code, reason), cancel).await @@ -243,12 +244,7 @@ impl BlockingWebSocket { /// Close the WebSocket connection. #[pyo3(signature = (code=None, reason=None))] - pub fn close( - &self, - py: Python, - code: Option, - reason: Option, - ) -> PyResult<()> { + pub fn close(&self, py: Python, code: Option, reason: Option) -> PyResult<()> { py.detach(|| { pyo3_async_runtimes::tokio::get_runtime().block_on(cmd::close( self.0.cmd.clone(), diff --git a/src/client/resp/ws/cmd.rs b/src/client/resp/ws/cmd.rs index ea00330e..6f381cf7 100644 --- a/src/client/resp/ws/cmd.rs +++ b/src/client/resp/ws/cmd.rs @@ -7,9 +7,8 @@ use std::time::Duration; -use bytes::Bytes; use futures_util::{SinkExt, StreamExt, TryStreamExt}; -use pyo3::{prelude::*, pybacked::PyBackedStr}; +use pyo3::prelude::*; use tokio::sync::{ mpsc::{UnboundedReceiver, UnboundedSender}, oneshot::{self, Sender}, @@ -19,6 +18,7 @@ use super::{ Error, Message, Utf8Bytes, ws::{self, WebSocket}, }; +use crate::extractor::StrInput; /// Commands for WebSocket operations. pub enum Command { @@ -40,7 +40,7 @@ pub enum Command { /// Close the WebSocket connection. /// /// Contains an optional close code, optional reason, and a oneshot sender for the result. - Close(Option, Option, Sender>), + Close(Option, Option, Sender>), } /// The main background task that processes incoming [`Command`]s and interacts with the WebSocket. @@ -97,7 +97,7 @@ pub async fn task(ws: WebSocket, mut cmd: UnboundedReceiver) { .map(ws::message::CloseCode::from) .unwrap_or(ws::message::CloseCode::NORMAL); let reason = reason - .map(Bytes::from_owner) + .map(|reason| reason.0) .map(Utf8Bytes::try_from) .transpose(); @@ -157,7 +157,7 @@ pub async fn send_all(cmd: UnboundedSender, messages: Vec) -> pub async fn close( cmd: UnboundedSender, code: Option, - reason: Option, + reason: Option, ) -> PyResult<()> { send_command(cmd, |tx| Command::Close(code, reason, tx)).await? } diff --git a/src/client/resp/ws/msg.rs b/src/client/resp/ws/msg.rs index 652834a4..100faa69 100644 --- a/src/client/resp/ws/msg.rs +++ b/src/client/resp/ws/msg.rs @@ -9,26 +9,27 @@ use std::fmt::Debug; -use bytes::Bytes; -use pyo3::{ - prelude::*, - pybacked::{PyBackedBytes, PyBackedStr}, -}; +use pyo3::prelude::*; use wreq::ws::message::{self, CloseCode, CloseFrame, Utf8Bytes}; -use crate::{buffer::PyBuffer, client::body::Json, error::Error}; +use crate::{ + buffer::PyBuffer, + client::body::Json, + error::Error, + extractor::{BytesInput, StrInput}, +}; /// An enum representing either a bytes message or a JSON message. #[derive(FromPyObject)] pub enum BytesLike { - Bytes(PyBackedBytes), + Bytes(BytesInput), Json(Json), } /// An enum representing either a text message or a JSON message. #[derive(FromPyObject)] pub enum TextLike { - Text(PyBackedStr), + Text(StrInput), Json(Json), } @@ -118,9 +119,7 @@ impl Message { py.detach(|| match like { TextLike::Text(text) => { // If the string is not valid UTF-8, this will panic. - let msg = message::Message::text( - Utf8Bytes::try_from(Bytes::from_owner(text)).expect("valid UTF-8"), - ); + let msg = message::Message::text(Utf8Bytes::try_from(text.0).expect("valid UTF-8")); Ok(Self(msg)) } TextLike::Json(json) => message::Message::text_from_json(&json) @@ -135,7 +134,7 @@ impl Message { #[pyo3(signature = (like))] pub fn from_binary(py: Python, like: BytesLike) -> PyResult { py.detach(|| match like { - BytesLike::Bytes(bytes) => Ok(Self(message::Message::binary(Bytes::from_owner(bytes)))), + BytesLike::Bytes(bytes) => Ok(Self(message::Message::binary(bytes.0))), BytesLike::Json(json) => message::Message::binary_from_json(&json) .map(Message) .map_err(Error::Library) @@ -146,23 +145,23 @@ impl Message { /// Creates a new ping message. #[staticmethod] #[pyo3(signature = (data))] - pub fn from_ping(data: PyBackedBytes) -> Self { - Self(message::Message::ping(Bytes::from_owner(data))) + pub fn from_ping(data: BytesInput) -> Self { + Self(message::Message::ping(data.0)) } /// Creates a new pong message. #[staticmethod] #[pyo3(signature = (data))] - pub fn from_pong(data: PyBackedBytes) -> Self { - Self(message::Message::pong(Bytes::from_owner(data))) + pub fn from_pong(data: BytesInput) -> Self { + Self(message::Message::pong(data.0)) } /// Creates a new close message. #[staticmethod] #[pyo3(signature = (code, reason=None))] - pub fn from_close(code: u16, reason: Option) -> Self { + pub fn from_close(code: u16, reason: Option) -> Self { let reason = reason - .map(Bytes::from_owner) + .map(|reason| reason.0) .and_then(|b| Utf8Bytes::try_from(b).ok()) .unwrap_or_else(|| Utf8Bytes::from_static("Goodbye")); let msg = message::Message::close(CloseFrame { diff --git a/src/cookie.rs b/src/cookie.rs index 328c673a..7112bba3 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -5,7 +5,7 @@ use cookie::{Cookie as RawCookie, Expiration, ParseError, time::Duration}; use pyo3::{prelude::*, pybacked::PyBackedStr, types::PyDict}; use wreq::header::{self, HeaderMap, HeaderValue}; -use crate::error::Error; +use crate::{error::Error, extractor::StrInput}; define_enum!( /// The Cookie SameSite attribute. @@ -189,8 +189,8 @@ impl FromPyObject<'_, '_> for Cookies { type Error = PyErr; fn extract(ob: Borrowed) -> PyResult { - if let Ok(cookie) = ob.extract::() { - return HeaderValue::from_maybe_shared(Bytes::from_owner(cookie)) + if let Ok(cookie) = ob.extract::() { + return HeaderValue::from_maybe_shared(cookie.0) .map(|cookie| Cookies(vec![cookie])) .map_err(Error::from) .map_err(Into::into); diff --git a/src/extractor.rs b/src/extractor.rs index 229e920c..6cf11077 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,10 +1,60 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use pyo3::{FromPyObject, prelude::*}; +use bytes::Bytes; +use pyo3::{ + FromPyObject, + prelude::*, + pybacked::{PyBackedBytes, PyBackedStr}, + types::{PyBytes, PyString}, +}; /// A generic extractor for various types. pub struct Extractor(pub T); +/// Byte input with no hidden references to Python subclasses. +#[derive(Clone)] +pub struct BytesInput(pub Bytes); + +/// UTF-8 input with no hidden references to Python subclasses. +#[derive(Clone)] +pub struct StrInput(pub Bytes); + +// ===== impl BytesInput ===== + +impl FromPyObject<'_, '_> for BytesInput { + type Error = PyErr; + + fn extract(ob: Borrowed) -> PyResult { + let value = ob.extract::()?; + // Subclasses can reference an exported view; Bytes owners are invisible to GC. + let bytes = if ob.is_instance_of::() && !ob.is_exact_instance_of::() { + Bytes::copy_from_slice(value.as_ref()) + } else { + Bytes::from_owner(value) + }; + Ok(Self(bytes)) + } +} + +// ===== impl StrInput ===== + +impl FromPyObject<'_, '_> for StrInput { + type Error = PyErr; + + fn extract(ob: Borrowed) -> PyResult { + let value = ob.extract::()?; + let bytes = if ob.is_exact_instance_of::() { + Bytes::from_owner(value) + } else { + // Copy the actual UTF-8 payload, not a subclass's __str__ result. + Bytes::copy_from_slice(value.as_bytes()) + }; + Ok(Self(bytes)) + } +} + +// ===== impl Extractor ===== + impl FromPyObject<'_, '_> for Extractor<(Option, Option)> { type Error = PyErr; diff --git a/src/header.rs b/src/header.rs index 701bfe9f..057aed64 100644 --- a/src/header.rs +++ b/src/header.rs @@ -1,12 +1,15 @@ -use bytes::Bytes; use pyo3::{ prelude::*, - pybacked::{PyBackedBytes, PyBackedStr}, + pybacked::PyBackedStr, types::{PyDict, PyIterator, PyList}, }; use wreq::header::{self, HeaderName, HeaderValue}; -use crate::{buffer::PyBuffer, error::Error}; +use crate::{ + buffer::PyBuffer, + error::Error, + extractor::{BytesInput, StrInput}, +}; /// An HTTP header map whose names and values are exposed as read-only memoryviews. #[derive(Clone)] @@ -43,8 +46,8 @@ impl HeaderMap { }; let value = match value - .extract::() - .map(Bytes::from_owner) + .extract::() + .map(|value| value.0) .map(HeaderValue::from_maybe_shared) { Ok(Ok(v)) => v, @@ -68,12 +71,12 @@ impl HeaderMap { &self, py: Python<'py>, key: PyBackedStr, - default: Option, + default: Option, ) -> Option { py.detach(|| { self.0.get::<&str>(key.as_ref()).cloned().or_else(|| { match default - .map(Bytes::from_owner) + .map(|value| value.0) .map(HeaderValue::from_maybe_shared) { Some(Ok(v)) => Some(v), @@ -99,11 +102,11 @@ impl HeaderMap { /// Insert a key-value pair into the header map. #[pyo3(signature = (key, value))] - fn insert(&mut self, py: Python, key: PyBackedStr, value: PyBackedStr) { + fn insert(&mut self, py: Python, key: PyBackedStr, value: StrInput) { py.detach(|| { if let (Ok(name), Ok(value)) = ( HeaderName::from_bytes(key.as_bytes()), - HeaderValue::from_maybe_shared(Bytes::from_owner(value)), + HeaderValue::from_maybe_shared(value.0), ) { self.0.insert(name, value); } @@ -112,11 +115,11 @@ impl HeaderMap { /// Append a key-value pair to the header map. #[pyo3(signature = (key, value))] - fn append(&mut self, py: Python, key: PyBackedStr, value: PyBackedStr) { + fn append(&mut self, py: Python, key: PyBackedStr, value: StrInput) { py.detach(|| { if let (Ok(name), Ok(value)) = ( HeaderName::from_bytes(key.as_bytes()), - HeaderValue::from_maybe_shared(Bytes::from_owner(value)), + HeaderValue::from_maybe_shared(value.0), ) { self.0.append(name, value); } @@ -201,7 +204,7 @@ impl HeaderMap { } #[inline] - fn __setitem__(&mut self, py: Python, key: PyBackedStr, value: PyBackedStr) { + fn __setitem__(&mut self, py: Python, key: PyBackedStr, value: StrInput) { self.insert(py, key, value); } @@ -253,9 +256,8 @@ impl FromPyObject<'_, '_> for HeaderMap { }; let value = { - let value = value.extract::()?; - HeaderValue::from_maybe_shared(Bytes::from_owner(value)) - .map_err(Error::from)? + let value = value.extract::()?; + HeaderValue::from_maybe_shared(value.0).map_err(Error::from)? }; headers.insert(name, value); @@ -282,8 +284,8 @@ impl OrigHeaderMap { // and we want to prevent Python's garbage collector from managing it. if let Some(init) = init { for name in init.iter() { - let name = match name.extract::() { - Ok(n) => Bytes::from_owner(n), + let name = match name.extract::() { + Ok(name) => name.0, _ => continue, }; @@ -304,8 +306,8 @@ impl OrigHeaderMap { /// updated, though; this matters for types that can be `==` without being /// identical. #[inline] - pub fn insert(&mut self, value: PyBackedStr) -> bool { - self.0.insert(Bytes::from_owner(value)) + pub fn insert(&mut self, value: StrInput) -> bool { + self.0.insert(value.0) } /// Extends the map with all entries from another [`OrigHeaderMap`], preserving order. @@ -354,8 +356,8 @@ impl FromPyObject<'_, '_> for OrigHeaderMap { header::OrigHeaderMap::with_capacity(list.len()), |mut headers, name| { let name = { - let name = name.extract::()?; - Bytes::from_owner(name) + let name = name.extract::()?; + name.0 }; headers.insert(name); Ok(headers) diff --git a/src/proxy.rs b/src/proxy.rs index 1277277b..d93a994a 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1,8 +1,7 @@ -use bytes::Bytes; use pyo3::{prelude::*, pybacked::PyBackedStr}; use wreq::header::HeaderValue; -use crate::{error::Error, header::HeaderMap}; +use crate::{error::Error, extractor::StrInput, header::HeaderMap}; /// A builder for `Proxy`. #[derive(Default)] @@ -14,7 +13,7 @@ struct Builder { password: Option, // Optional custom HTTP authentication header. - custom_http_auth: Option, + custom_http_auth: Option, /// Optional custom HTTP headers for the proxy. custom_http_headers: Option, @@ -114,7 +113,7 @@ fn create_proxy<'py>( // Convert the custom HTTP auth string to a header value. if let Some(Ok(custom_http_auth)) = builder .custom_http_auth - .map(Bytes::from_owner) + .map(|value| value.0) .map(HeaderValue::from_maybe_shared) { proxy = proxy.custom_http_auth(custom_http_auth); diff --git a/tests/buffer_test.py b/tests/buffer_test.py index dda4c0bb..1f87cdfe 100644 --- a/tests/buffer_test.py +++ b/tests/buffer_test.py @@ -2,12 +2,13 @@ import datetime import gc import threading +import weakref from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import pytest import wreq -from wreq import Message, Version, blocking +from wreq import Message, Multipart, Part, Version, blocking from wreq.header import HeaderMap, OrigHeaderMap @@ -76,6 +77,68 @@ def test_header_and_message_views(): child.release() +def test_subclass_input_cycles_are_collected(): + class Text(str): + def __str__(self): + raise AssertionError("subclass conversion must not be called") + + class Binary(bytes): + def __bytes__(self): + raise AssertionError("subclass conversion must not be called") + + class Marker: + pass + + def header_value(source, method): + headers = HeaderMap() + getattr(headers, method)("X-Buffer", source) + return headers["X-Buffer"] + + def original_name(source): + headers = OrigHeaderMap() + headers.insert(source) + return next(iter(headers))[1] + + cases = [ + (Text, "payload", lambda source: Message.from_text(source).data), + (Binary, b"payload", lambda source: Message.from_binary(source).binary), + (Binary, b"payload", lambda source: Message.from_ping(source).ping), + (Binary, b"payload", lambda source: Message.from_pong(source).pong), + (Text, "payload", lambda source: HeaderMap({"X-Buffer": source})["X-Buffer"]), + (Text, "payload", lambda source: header_value(source, "insert")), + (Text, "payload", lambda source: header_value(source, "append")), + (Text, "payload", lambda source: header_value(source, "__setitem__")), + (Binary, b"payload", lambda source: HeaderMap().get("missing", source)), + (Text, "X-Buffer", lambda source: next(iter(OrigHeaderMap([source])))[1]), + (Text, "X-Buffer", original_name), + ] + for input_type, payload, make_view in cases: + source = input_type(payload) + source.marker = Marker() + marker = weakref.ref(source.marker) + view = make_view(source) + expected = payload.encode() if isinstance(payload, str) else payload + assert_readonly_view(view, expected) + source.view = view + del source, view + gc.collect() + assert marker() is None, make_view + + for input_type, payload, make_owner in [ + (Text, "payload", lambda source: Part("field", source)), + (Binary, b"payload", lambda source: Multipart(Part("field", source))), + (Text, "payload", lambda source: Message.from_close(1000, source)), + ]: + source = input_type(payload) + source.marker = Marker() + marker = weakref.ref(source.marker) + owner = make_owner(source) + source.owner = owner + del source, owner + gc.collect() + assert marker() is None, make_owner + + @pytest.fixture def buffer_http_server(): class Handler(BaseHTTPRequestHandler): From 997f16dbf8a197fd270bde9ddba1e461a10e4dc9 Mon Sep 17 00:00:00 2001 From: gngpp Date: Wed, 30 Sep 2026 18:30:54 +0800 Subject: [PATCH 6/6] test(buffer): allow deferred PyPy garbage collection --- tests/buffer_test.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/buffer_test.py b/tests/buffer_test.py index 1f87cdfe..b78f1f30 100644 --- a/tests/buffer_test.py +++ b/tests/buffer_test.py @@ -89,6 +89,11 @@ def __bytes__(self): class Marker: pass + def collect_garbage(): + # PyPy may need several GC cycles to finalize C-extension buffers. + for _ in range(3): + gc.collect() + def header_value(source, method): headers = HeaderMap() getattr(headers, method)("X-Buffer", source) @@ -121,7 +126,7 @@ def original_name(source): assert_readonly_view(view, expected) source.view = view del source, view - gc.collect() + collect_garbage() assert marker() is None, make_view for input_type, payload, make_owner in [ @@ -135,7 +140,7 @@ def original_name(source): owner = make_owner(source) source.owner = owner del source, owner - gc.collect() + collect_garbage() assert marker() is None, make_owner