Skip to content

Commit ac672ac

Browse files
committed
Add PyMemoryView::from_owned_buffer for zero-copy memoryview creation
Adds a new method to create a Python memoryview that exposes a read-only view of byte data owned by a frozen PyClass instance without copying. This is useful for libraries like pyca/cryptography that need to expose internal buffers efficiently. The method uses PyBuffer_FillInfo + PyMemoryView_FromBuffer to create a memoryview backed by the owner's data, with the owner kept alive via the buffer's obj reference. Safety is enforced at compile time: - T: PyClass<Frozen = True> prevents mutation that could invalidate pointers - for<'a> FnOnce(&'a T) -> &'a [u8] ensures the slice borrows from T or is 'static Closes #5871 https://claude.ai/code/session_01EEP1DaqJwHGCoNufi2JT9H
1 parent 96c53e3 commit ac672ac

5 files changed

Lines changed: 212 additions & 1 deletion

File tree

newsfragments/5937.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `PyMemoryView::from_owned_buffer` to create a read-only `memoryview` from data owned by a frozen `PyClass` instance without copying.

pytests/src/buf_and_str.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ pub mod buf_and_str {
5252
PyMemoryView::from(&bytes)
5353
}
5454

55+
#[pyclass(frozen)]
56+
struct OwnedData {
57+
data: Vec<u8>,
58+
}
59+
60+
#[pyfunction]
61+
fn return_owned_memoryview(py: Python<'_>) -> PyResult<Bound<'_, PyMemoryView>> {
62+
let obj = pyo3::Py::new(py, OwnedData { data: b"owned buffer data".to_vec() })?;
63+
PyMemoryView::from_owned_buffer(py, obj, |d| &d.data)
64+
}
65+
5566
#[pyfunction]
5667
fn map_byte_slice(bytes: &[u8]) -> &[u8] {
5768
bytes

pytests/tests/test_buf_and_str.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from pyo3_pytests.buf_and_str import BytesExtractor, return_memoryview
1+
from pyo3_pytests.buf_and_str import BytesExtractor, return_memoryview, return_owned_memoryview
22

33

44
def test_extract_bytes():
@@ -34,3 +34,20 @@ def test_return_memoryview():
3434
assert view.readonly
3535
assert view.contiguous
3636
assert view.tobytes() == b"hello world"
37+
38+
39+
def test_return_owned_memoryview():
40+
view = return_owned_memoryview()
41+
assert view.readonly
42+
assert view.contiguous
43+
assert view.tobytes() == b"owned buffer data"
44+
assert len(view) == len(b"owned buffer data")
45+
46+
47+
def test_owned_memoryview_keeps_data_alive():
48+
"""Ensure the memoryview keeps the owner alive even after Python-side references are dropped."""
49+
view = return_owned_memoryview()
50+
# Access the data multiple times to ensure it's still valid
51+
assert view.tobytes() == b"owned buffer data"
52+
assert view[0] == ord(b"o")
53+
assert view[-1] == ord(b"a")

src/types/memoryview.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
use crate::err::PyResult;
22
use crate::ffi_ptr_ext::FfiPtrExt;
33
use crate::py_result_ext::PyResultExt;
4+
#[cfg(any(Py_3_11, not(Py_LIMITED_API)))]
5+
use crate::pycell::impl_::PyClassObjectLayout;
46
use crate::{ffi, Bound, PyAny};
7+
#[cfg(any(Py_3_11, not(Py_LIMITED_API)))]
8+
use crate::Python;
59

610
/// Represents a Python `memoryview`.
711
///
@@ -22,6 +26,98 @@ impl PyMemoryView {
2226
.cast_into_unchecked()
2327
}
2428
}
29+
30+
/// Creates a new Python `memoryview` that exposes a read-only view of the
31+
/// byte data owned by a frozen `PyClass` instance.
32+
///
33+
/// This avoids copying data when you want to expose the internal buffer of
34+
/// a Python object as a `memoryview`. The `owner` keeps the data alive for
35+
/// the lifetime of the `memoryview`.
36+
///
37+
/// # Arguments
38+
///
39+
/// * `py` — The Python GIL token.
40+
/// * `owner` — A `Py<T>` reference to a frozen `PyClass` instance that owns
41+
/// the underlying data.
42+
/// * `getbuf` — A closure that borrows `T` and returns the byte slice to
43+
/// expose. The higher-ranked lifetime ensures the slice is derived from
44+
/// `T` (or is `'static`), preventing dangling pointers.
45+
///
46+
/// # Safety guarantees
47+
///
48+
/// * `T: PyClass<Frozen = True>` ensures the class is immutable, so the
49+
/// buffer pointer cannot be invalidated by mutation.
50+
/// * The `for<'a> FnOnce(&'a T) -> &'a [u8]` signature ensures the returned
51+
/// slice borrows from `T` or is `'static`, preventing references to
52+
/// temporaries.
53+
///
54+
/// # Example
55+
///
56+
/// ```rust
57+
/// # #[cfg(any(Py_3_11, not(Py_LIMITED_API)))]
58+
/// # {
59+
/// use pyo3::prelude::*;
60+
/// use pyo3::types::PyMemoryView;
61+
///
62+
/// #[pyclass(frozen)]
63+
/// struct MyData {
64+
/// data: Vec<u8>,
65+
/// }
66+
///
67+
/// Python::attach(|py| {
68+
/// let obj = Py::new(py, MyData { data: vec![1, 2, 3] }).unwrap();
69+
/// let view = PyMemoryView::from_owned_buffer(py, obj, |data| &data.data).unwrap();
70+
/// assert_eq!(view.len().unwrap(), 3);
71+
/// });
72+
/// # }
73+
/// ```
74+
#[cfg(any(Py_3_11, not(Py_LIMITED_API)))]
75+
pub fn from_owned_buffer<'py, T>(
76+
py: Python<'py>,
77+
owner: crate::Py<T>,
78+
getbuf: impl for<'a> FnOnce(&'a T) -> &'a [u8],
79+
) -> PyResult<Bound<'py, Self>>
80+
where
81+
T: crate::PyClass<Frozen = crate::pyclass::boolean_struct::True> + Sync,
82+
{
83+
// Get the raw object pointer. This is a borrowed reference (no refcount change).
84+
let owner_ptr = owner.as_ptr();
85+
86+
// SAFETY: T is frozen and Sync, so we can safely obtain &T via the
87+
// class object layout. The reference is valid as long as `owner` is
88+
// alive (which it is — we still hold it on the stack).
89+
let obj_ref: &T =
90+
unsafe { &*owner.bind(py).get_class_object().get_ptr() };
91+
let buf = getbuf(obj_ref);
92+
93+
let mut view = ffi::Py_buffer::new();
94+
95+
// SAFETY: PyBuffer_FillInfo initializes the Py_buffer struct. On
96+
// success it calls Py_INCREF on the owner object (via view.obj).
97+
// We pass readonly=1 since we only expose an immutable view.
98+
let rc = unsafe {
99+
ffi::PyBuffer_FillInfo(
100+
&mut view,
101+
owner_ptr,
102+
buf.as_ptr() as *mut std::ffi::c_void,
103+
buf.len() as ffi::Py_ssize_t,
104+
1, // readonly
105+
ffi::PyBUF_FULL_RO,
106+
)
107+
};
108+
if rc == -1 {
109+
return Err(crate::PyErr::fetch(py));
110+
}
111+
112+
// SAFETY: PyMemoryView_FromBuffer creates a memoryview that takes
113+
// ownership of the buffer (it will call PyBuffer_Release when the
114+
// memoryview is deallocated, which will decref view.obj).
115+
unsafe {
116+
ffi::PyMemoryView_FromBuffer(&view)
117+
.assume_owned_or_err(py)
118+
.cast_into_unchecked()
119+
}
120+
}
25121
}
26122

27123
impl<'py> TryFrom<&Bound<'py, PyAny>> for Bound<'py, PyMemoryView> {

tests/test_memoryview.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#![cfg(feature = "macros")]
2+
#![cfg(any(Py_3_11, not(Py_LIMITED_API)))]
3+
4+
use pyo3::prelude::*;
5+
use pyo3::types::PyMemoryView;
6+
7+
#[pyclass(frozen)]
8+
struct ByteOwner {
9+
data: Vec<u8>,
10+
}
11+
12+
#[test]
13+
fn test_from_owned_buffer_basic() {
14+
Python::attach(|py| {
15+
let owner = Py::new(py, ByteOwner { data: vec![1, 2, 3, 4, 5] }).unwrap();
16+
let view = PyMemoryView::from_owned_buffer(py, owner, |o| &o.data).unwrap();
17+
assert_eq!(view.len().unwrap(), 5);
18+
assert!(view.is_truthy().unwrap());
19+
});
20+
}
21+
22+
#[test]
23+
fn test_from_owned_buffer_readonly() {
24+
Python::attach(|py| {
25+
let owner = Py::new(py, ByteOwner { data: vec![42] }).unwrap();
26+
let view = PyMemoryView::from_owned_buffer(py, owner, |o| &o.data).unwrap();
27+
// Verify the memoryview is readonly via Python
28+
let readonly: bool = view.getattr("readonly").unwrap().extract().unwrap();
29+
assert!(readonly);
30+
});
31+
}
32+
33+
#[test]
34+
fn test_from_owned_buffer_content() {
35+
Python::attach(|py| {
36+
let owner = Py::new(
37+
py,
38+
ByteOwner {
39+
data: b"hello".to_vec(),
40+
},
41+
)
42+
.unwrap();
43+
let view = PyMemoryView::from_owned_buffer(py, owner, |o| &o.data).unwrap();
44+
let bytes: Vec<u8> = view.call_method0("tobytes").unwrap().extract().unwrap();
45+
assert_eq!(bytes, b"hello");
46+
});
47+
}
48+
49+
#[test]
50+
fn test_from_owned_buffer_empty() {
51+
Python::attach(|py| {
52+
let owner = Py::new(py, ByteOwner { data: vec![] }).unwrap();
53+
let view = PyMemoryView::from_owned_buffer(py, owner, |o| &o.data).unwrap();
54+
assert_eq!(view.len().unwrap(), 0);
55+
});
56+
}
57+
58+
#[test]
59+
fn test_from_owned_buffer_static_data() {
60+
// The closure can also return a &'static [u8]
61+
Python::attach(|py| {
62+
let owner = Py::new(py, ByteOwner { data: vec![] }).unwrap();
63+
let view =
64+
PyMemoryView::from_owned_buffer(py, owner, |_o| b"static data" as &[u8]).unwrap();
65+
let bytes: Vec<u8> = view.call_method0("tobytes").unwrap().extract().unwrap();
66+
assert_eq!(bytes, b"static data");
67+
});
68+
}
69+
70+
#[test]
71+
fn test_from_owned_buffer_keeps_owner_alive() {
72+
Python::attach(|py| {
73+
let owner = Py::new(
74+
py,
75+
ByteOwner {
76+
data: b"kept alive".to_vec(),
77+
},
78+
)
79+
.unwrap();
80+
let view = PyMemoryView::from_owned_buffer(py, owner, |o| &o.data).unwrap();
81+
// Force GC to ensure the owner is kept alive by the memoryview
82+
py.run(c"import gc; gc.collect()", None, None).unwrap();
83+
let bytes: Vec<u8> = view.call_method0("tobytes").unwrap().extract().unwrap();
84+
assert_eq!(bytes, b"kept alive");
85+
});
86+
}

0 commit comments

Comments
 (0)