rubyx-py 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,10 +4,11 @@ use crate::python_ffi::PyObject;
4
4
  use crate::python_guard::PyGuard;
5
5
  use crate::ruby_helpers;
6
6
  use crate::stream::SendableValue;
7
+ use magnus::encoding::EncodingCapable;
7
8
  use magnus::r_hash::ForEach;
8
9
  use magnus::typed_data::Obj;
9
10
  use magnus::value::ReprValue;
10
- use magnus::{Class, IntoValue, RHash, Ruby, Symbol, TryConvert, Value};
11
+ use magnus::{Class, IntoValue, RHash, RString, Ruby, Symbol, TryConvert, Value};
11
12
  use std::ffi::CString;
12
13
 
13
14
  const RUBY_IMPLICIT_CONVERSIONS: &[&str] = &[
@@ -51,6 +52,24 @@ pub(crate) fn python_to_sendable(
51
52
  };
52
53
  return Ok(SendableValue::Str(val));
53
54
  }
55
+ if api.bytes_check(py_val) {
56
+ let Some(val) = api.bytes_to_vec(py_val) else {
57
+ if api.has_error() {
58
+ api.clear_error();
59
+ }
60
+ return Err("Cannot decode Python bytes".to_string());
61
+ };
62
+ return Ok(SendableValue::Bytes(val));
63
+ }
64
+ if api.bytearray_check(py_val) {
65
+ let Some(val) = api.bytearray_to_vec(py_val) else {
66
+ if api.has_error() {
67
+ api.clear_error();
68
+ }
69
+ return Err("Cannot read Python bytearray".to_string());
70
+ };
71
+ return Ok(SendableValue::Bytes(val));
72
+ }
54
73
  if api.tuple_check(py_val) {
55
74
  let len = api.tuple_size(py_val);
56
75
  let mut items = Vec::with_capacity(len as usize);
@@ -61,6 +80,32 @@ pub(crate) fn python_to_sendable(
61
80
  return Ok(SendableValue::List(items));
62
81
  }
63
82
 
83
+ if api.is_set(py_val) || api.is_frozen_set(py_val) {
84
+ let len = api.set_size(py_val);
85
+ let mut items = Vec::with_capacity(len as usize);
86
+ let iter = api.object_get_iter(py_val);
87
+ loop {
88
+ let item = api.iter_next(iter);
89
+ if item.is_null() {
90
+ break;
91
+ }
92
+ let result = python_to_sendable(item, api);
93
+ api.decref(item);
94
+ match result {
95
+ Ok(val) => items.push(val),
96
+ Err(e) => {
97
+ api.decref(iter);
98
+ return Err(e);
99
+ }
100
+ }
101
+ }
102
+ if api.has_error() {
103
+ api.clear_error();
104
+ }
105
+ api.decref(iter);
106
+ return Ok(SendableValue::Set(items));
107
+ }
108
+
64
109
  if api.list_check(py_val) {
65
110
  let len = api.list_size(py_val);
66
111
  let mut items = Vec::with_capacity(len as usize);
@@ -95,7 +140,7 @@ pub(crate) fn python_to_sendable(
95
140
  let name = std::ffi::CString::new("__dict__").unwrap();
96
141
  api.object_has_attr_string(py_val, name.as_ptr()) != 0
97
142
  };
98
- if api.callable_check(py_val) == 0 && has_dict {
143
+ if has_dict || api.callable_check(py_val) != 0 {
99
144
  api.incref(py_val);
100
145
  return Ok(SendableValue::PyObjectRef(py_val as usize));
101
146
  }
@@ -153,6 +198,24 @@ pub(crate) fn ruby_to_python(
153
198
  return Ok(py_str);
154
199
  }
155
200
  if value.is_kind_of(ruby.class_string()) {
201
+ let rstr = RString::try_convert(value)?;
202
+ // ASCII-8BIT means bytes/bytearray
203
+ let enc = rstr.enc_get();
204
+ let ascii_8_bit = ruby
205
+ .find_encindex("ASCII-8BIT")
206
+ .map_err(|_| magnus::Error::new(ruby_helpers::runtime_error(), "No ASCII-8BIT"))?;
207
+ if enc == ascii_8_bit {
208
+ let bytes = unsafe { rstr.as_slice() };
209
+ let py_bytes = api.bytes_from_slice(bytes);
210
+ if py_bytes.is_null() {
211
+ return Err(magnus::Error::new(
212
+ ruby_helpers::runtime_error(),
213
+ "Failed to create Python bytes from String",
214
+ ));
215
+ }
216
+ return Ok(py_bytes);
217
+ }
218
+ // UTF-8 string conversion
156
219
  let val = String::try_convert(value)?;
157
220
  return val
158
221
  .to_python(api)
@@ -762,6 +825,134 @@ impl RubyxObject {
762
825
  result
763
826
  }
764
827
 
828
+ pub fn call(&self, args: &[magnus::Value]) -> Result<Value, magnus::Error> {
829
+ let api = self.api;
830
+ let gil = api.ensure_gil();
831
+ let result = (|| -> Result<Value, magnus::Error> {
832
+ if api.callable_check(self.py_obj) == 0 {
833
+ return Err(magnus::Error::new(
834
+ ruby_helpers::type_error(),
835
+ "Python object is not callable",
836
+ ));
837
+ }
838
+ let ruby = Ruby::get().map_err(|e| {
839
+ magnus::Error::new(
840
+ ruby_helpers::runtime_error(),
841
+ format!("Ruby VM handle unavailable: {e}"),
842
+ )
843
+ })?;
844
+
845
+ // Extract positional and keyword arguments
846
+ let (positional, kwargs) = if let Some(last) = args.last() {
847
+ if last.is_kind_of(ruby.class_hash()) {
848
+ (&args[..args.len() - 1], Some(RHash::try_convert(*last)?))
849
+ } else {
850
+ (args, None)
851
+ }
852
+ } else {
853
+ (args, None)
854
+ };
855
+
856
+ // Args Tuple for args
857
+ let py_args = api.tuple_new(positional.len() as isize);
858
+ if py_args.is_null() {
859
+ return Err(magnus::Error::new(
860
+ ruby_helpers::runtime_error(),
861
+ "Failed to allocate Python args tuple",
862
+ ));
863
+ }
864
+ let py_args_guard = PyGuard::new(py_args, api).ok_or_else(|| {
865
+ magnus::Error::new(ruby_helpers::runtime_error(), "Null Python args tuple")
866
+ })?;
867
+ for (i, arg) in positional.iter().enumerate() {
868
+ let py_arg = ruby_to_python(*arg, api)?;
869
+ if api.tuple_set_item(py_args_guard.ptr(), i as isize, py_arg) != 0 {
870
+ api.decref(py_arg);
871
+ if let Some(py_err) = PythonApi::extract_exception(api) {
872
+ return Err(magnus::Error::from(py_err));
873
+ }
874
+ return Err(magnus::Error::new(
875
+ ruby_helpers::runtime_error(),
876
+ "Failed to set tuple argument",
877
+ ));
878
+ }
879
+ }
880
+
881
+ // kwargs dict
882
+ let py_kwargs_guard = if let Some(hash) = kwargs {
883
+ let dict = api.dict_new();
884
+ if dict.is_null() {
885
+ return Err(magnus::Error::new(
886
+ ruby_helpers::runtime_error(),
887
+ "Failed to allocate kwargs dict",
888
+ ));
889
+ }
890
+ let guard = PyGuard::new(dict, api).ok_or_else(|| {
891
+ magnus::Error::new(ruby_helpers::runtime_error(), "Null kwargs dict")
892
+ })?;
893
+ hash.foreach(|k: Value, v: Value| {
894
+ let key = if let Ok(s) = String::try_convert(k) {
895
+ s
896
+ } else if let Ok(sym) = Symbol::try_convert(k) {
897
+ sym.name()?.to_string()
898
+ } else {
899
+ return Err(magnus::Error::new(
900
+ ruby_helpers::type_error(),
901
+ "kwargs keys must be String or Symbol",
902
+ ));
903
+ };
904
+ let py_key = key.to_python(api).map_err(|e| {
905
+ magnus::Error::new(ruby_helpers::runtime_error(), format!("{e:?}"))
906
+ })?;
907
+ let py_val = ruby_to_python(v, api)?;
908
+ let rc = api.dict_set_item(guard.ptr(), py_key, py_val);
909
+ api.decref(py_key);
910
+ api.decref(py_val);
911
+ if rc != 0 {
912
+ if let Some(py_err) = PythonApi::extract_exception(api) {
913
+ return Err(magnus::Error::from(py_err));
914
+ }
915
+ return Err(magnus::Error::new(
916
+ ruby_helpers::runtime_error(),
917
+ "Failed to set kwargs item",
918
+ ));
919
+ }
920
+ Ok(ForEach::Continue)
921
+ })?;
922
+ Some(guard)
923
+ } else {
924
+ None
925
+ };
926
+
927
+ let py_kwargs_ptr = py_kwargs_guard
928
+ .as_ref()
929
+ .map_or(std::ptr::null_mut(), |g| g.ptr());
930
+ // call the python callable with args and kwargs
931
+ let py_result = api.object_call(self.py_obj, py_args_guard.ptr(), py_kwargs_ptr);
932
+ if py_result.is_null() {
933
+ if let Some(py_err) = PythonApi::extract_exception(api) {
934
+ return Err(magnus::Error::from(py_err));
935
+ }
936
+ return Err(magnus::Error::new(
937
+ ruby_helpers::runtime_error(),
938
+ "Python call failed",
939
+ ));
940
+ }
941
+ let py_result_guard = PyGuard::new(py_result, api).ok_or_else(|| {
942
+ magnus::Error::new(ruby_helpers::runtime_error(), "Null Python result")
943
+ })?;
944
+ let wrapper = RubyxObject::new(py_result_guard.ptr(), api).ok_or_else(|| {
945
+ magnus::Error::new(
946
+ ruby_helpers::runtime_error(),
947
+ "Failed to wrap Python result",
948
+ )
949
+ })?;
950
+ Ok(wrapper.into_value_with(&ruby))
951
+ })();
952
+ api.release_gil(gil);
953
+ result
954
+ }
955
+
765
956
  pub fn py_type(&self) -> Result<String, magnus::Error> {
766
957
  let gil = self.api.ensure_gil();
767
958
  let result = self.api.type_name(self.py_obj);
@@ -1668,6 +1859,206 @@ mod tests {
1668
1859
  });
1669
1860
  }
1670
1861
 
1862
+ // ========== call tests ==========
1863
+
1864
+ #[test]
1865
+ #[serial]
1866
+ fn test_call_lambda_no_args() {
1867
+ with_ruby_python(|_ruby, api| {
1868
+ let globals = crate::eval::make_globals(api);
1869
+ let py_func = api
1870
+ .run_string("lambda: 42", 258, globals.ptr(), globals.ptr())
1871
+ .expect("lambda eval should succeed");
1872
+ let wrapper = RubyxObject::new(py_func, api).unwrap();
1873
+
1874
+ let result = wrapper.call(&[]).expect("call should succeed");
1875
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
1876
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 42);
1877
+
1878
+ drop(wrapper);
1879
+ api.decref(py_func);
1880
+ });
1881
+ }
1882
+
1883
+ #[test]
1884
+ #[serial]
1885
+ fn test_call_lambda_with_args() {
1886
+ with_ruby_python(|ruby, api| {
1887
+ let globals = crate::eval::make_globals(api);
1888
+ let py_func = api
1889
+ .run_string("lambda x, y: x + y", 258, globals.ptr(), globals.ptr())
1890
+ .expect("lambda eval should succeed");
1891
+ let wrapper = RubyxObject::new(py_func, api).unwrap();
1892
+
1893
+ let args = vec![3_i64.into_value_with(ruby), 4_i64.into_value_with(ruby)];
1894
+ let result = wrapper.call(&args).expect("call should succeed");
1895
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
1896
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 7);
1897
+
1898
+ drop(wrapper);
1899
+ api.decref(py_func);
1900
+ });
1901
+ }
1902
+
1903
+ #[test]
1904
+ #[serial]
1905
+ fn test_call_builtin_function() {
1906
+ with_ruby_python(|ruby, api| {
1907
+ let builtins = api
1908
+ .import_module("builtins")
1909
+ .expect("builtins should import");
1910
+ let len_func = api.object_get_attr_string(builtins, "len");
1911
+ let wrapper = RubyxObject::new(len_func, api).unwrap();
1912
+
1913
+ let globals = crate::eval::make_globals(api);
1914
+ let py_list = api
1915
+ .run_string("[1, 2, 3]", 258, globals.ptr(), globals.ptr())
1916
+ .expect("list eval should succeed");
1917
+ let list_wrapper = RubyxObject::new(py_list, api).unwrap();
1918
+
1919
+ let args = vec![list_wrapper.into_value_with(ruby)];
1920
+ let result = wrapper.call(&args).expect("call should succeed");
1921
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
1922
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 3);
1923
+
1924
+ drop(wrapper);
1925
+ api.decref(len_func);
1926
+ api.decref(builtins);
1927
+ api.decref(py_list);
1928
+ });
1929
+ }
1930
+
1931
+ #[test]
1932
+ #[serial]
1933
+ fn test_call_with_kwargs() {
1934
+ with_ruby_python(|ruby, api| {
1935
+ let globals = crate::eval::make_globals(api);
1936
+ let _ = api.run_string(
1937
+ "def greet(name, greeting='Hello'): return f'{greeting}, {name}!'",
1938
+ 257,
1939
+ globals.ptr(),
1940
+ globals.ptr(),
1941
+ );
1942
+ let key = api.string_from_str("greet");
1943
+ let func = api.dict_get_item(globals.ptr(), key);
1944
+ api.decref(key);
1945
+ let wrapper = RubyxObject::new(func, api).unwrap();
1946
+
1947
+ let kwargs = ruby.hash_new();
1948
+ kwargs
1949
+ .aset(ruby.sym_new("greeting"), "Hi".into_value_with(ruby))
1950
+ .unwrap();
1951
+ let args = vec!["Alice".into_value_with(ruby), kwargs.into_value_with(ruby)];
1952
+ let result = wrapper
1953
+ .call(&args)
1954
+ .expect("call with kwargs should succeed");
1955
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
1956
+ assert_eq!(
1957
+ api.string_to_string(obj.as_ptr()),
1958
+ Some("Hi, Alice!".to_string())
1959
+ );
1960
+
1961
+ drop(wrapper);
1962
+ });
1963
+ }
1964
+
1965
+ #[test]
1966
+ #[serial]
1967
+ fn test_call_class_as_constructor() {
1968
+ with_ruby_python(|ruby, api| {
1969
+ let globals = crate::eval::make_globals(api);
1970
+ let _ = api.run_string(
1971
+ "class Pt:\n def __init__(self, x):\n self.x = x",
1972
+ 257,
1973
+ globals.ptr(),
1974
+ globals.ptr(),
1975
+ );
1976
+ let key = api.string_from_str("Pt");
1977
+ let cls = api.dict_get_item(globals.ptr(), key);
1978
+ api.decref(key);
1979
+ let wrapper = RubyxObject::new(cls, api).unwrap();
1980
+
1981
+ let args = vec![10_i64.into_value_with(ruby)];
1982
+ let result = wrapper.call(&args).expect("class call should succeed");
1983
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
1984
+
1985
+ let x_attr = api.object_get_attr_string(obj.as_ptr(), "x");
1986
+ assert!(!x_attr.is_null());
1987
+ assert_eq!(api.long_to_i64(x_attr), 10);
1988
+ api.decref(x_attr);
1989
+
1990
+ drop(wrapper);
1991
+ });
1992
+ }
1993
+
1994
+ #[test]
1995
+ #[serial]
1996
+ fn test_call_non_callable_raises_error() {
1997
+ with_ruby_python(|_ruby, api| {
1998
+ let py_int = api.long_from_i64(42);
1999
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
2000
+
2001
+ let result = wrapper.call(&[]);
2002
+ assert!(result.is_err(), "calling non-callable should error");
2003
+ let err_msg = result.unwrap_err().to_string();
2004
+ assert!(
2005
+ err_msg.contains("not callable"),
2006
+ "error should mention not callable, got: {err_msg}"
2007
+ );
2008
+
2009
+ drop(wrapper);
2010
+ api.decref(py_int);
2011
+ });
2012
+ }
2013
+
2014
+ #[test]
2015
+ #[serial]
2016
+ fn test_call_propagates_python_error() {
2017
+ with_ruby_python(|_ruby, api| {
2018
+ let globals = crate::eval::make_globals(api);
2019
+ let _ = api.run_string(
2020
+ "def explode(): raise ValueError('boom')",
2021
+ 257,
2022
+ globals.ptr(),
2023
+ globals.ptr(),
2024
+ );
2025
+ let key = api.string_from_str("explode");
2026
+ let func = api.dict_get_item(globals.ptr(), key);
2027
+ api.decref(key);
2028
+ let wrapper = RubyxObject::new(func, api).unwrap();
2029
+
2030
+ let result = wrapper.call(&[]);
2031
+ assert!(result.is_err(), "call that raises should return error");
2032
+ let err_msg = result.unwrap_err().to_string();
2033
+ assert!(
2034
+ err_msg.contains("boom"),
2035
+ "error should contain Python message, got: {err_msg}"
2036
+ );
2037
+
2038
+ drop(wrapper);
2039
+ });
2040
+ }
2041
+
2042
+ #[test]
2043
+ #[serial]
2044
+ fn test_call_returns_rubyx_object() {
2045
+ with_ruby_python(|_ruby, api| {
2046
+ let globals = crate::eval::make_globals(api);
2047
+ let py_func = api
2048
+ .run_string("lambda: [1, 2, 3]", 258, globals.ptr(), globals.ptr())
2049
+ .expect("lambda eval should succeed");
2050
+ let wrapper = RubyxObject::new(py_func, api).unwrap();
2051
+
2052
+ let result = wrapper.call(&[]).expect("call should succeed");
2053
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
2054
+ assert!(api.list_check(obj.as_ptr()));
2055
+ assert_eq!(api.list_size(obj.as_ptr()), 3);
2056
+
2057
+ drop(wrapper);
2058
+ api.decref(py_func);
2059
+ });
2060
+ }
2061
+
1671
2062
  // ========== is_truthy / is_falsy tests ==========
1672
2063
 
1673
2064
  #[test]
@@ -1960,15 +2351,16 @@ mod tests {
1960
2351
  api.decref(os);
1961
2352
  }
1962
2353
 
2354
+ // ========== python_to_sendable: set / frozenset ==========
2355
+
1963
2356
  #[test]
1964
2357
  #[serial]
1965
- fn test_python_to_sendable_set_returns_err() {
2358
+ fn test_python_to_sendable_set_returns_set() {
1966
2359
  use crate::test_helpers::skip_if_no_python;
1967
2360
  let Some(guard) = skip_if_no_python() else {
1968
2361
  return;
1969
2362
  };
1970
2363
  let api = guard.api();
1971
- // Sets don't have __dict__, so they return Err (not PyObjectRef)
1972
2364
  let globals = api.dict_new();
1973
2365
  let builtins = api
1974
2366
  .import_module("builtins")
@@ -1980,8 +2372,22 @@ mod tests {
1980
2372
  let py_set = result.expect("set eval should succeed");
1981
2373
  assert!(!py_set.is_null());
1982
2374
 
1983
- let sendable = python_to_sendable(py_set, api);
1984
- assert!(sendable.is_err(), "set should return Err (no __dict__)");
2375
+ let sendable = python_to_sendable(py_set, api).expect("set should convert to Set");
2376
+ match &sendable {
2377
+ SendableValue::Set(items) => {
2378
+ assert_eq!(items.len(), 3);
2379
+ let mut vals: Vec<i64> = items
2380
+ .iter()
2381
+ .map(|item| match item {
2382
+ SendableValue::Integer(n) => *n,
2383
+ other => panic!("expected Integer, got {other:?}"),
2384
+ })
2385
+ .collect();
2386
+ vals.sort();
2387
+ assert_eq!(vals, vec![1, 2, 3]);
2388
+ }
2389
+ other => panic!("expected Set, got {other:?}"),
2390
+ }
1985
2391
  api.decref(py_set);
1986
2392
  api.decref(builtins);
1987
2393
  api.decref(globals);
@@ -1989,13 +2395,387 @@ mod tests {
1989
2395
 
1990
2396
  #[test]
1991
2397
  #[serial]
1992
- fn test_python_to_sendable_primitives_not_py_object_ref() {
2398
+ fn test_python_to_sendable_frozenset_returns_set() {
1993
2399
  use crate::test_helpers::skip_if_no_python;
1994
2400
  let Some(guard) = skip_if_no_python() else {
1995
2401
  return;
1996
2402
  };
1997
2403
  let api = guard.api();
1998
-
2404
+ let globals = api.dict_new();
2405
+ let builtins = api
2406
+ .import_module("builtins")
2407
+ .expect("builtins should import");
2408
+ let key = api.string_from_str("__builtins__");
2409
+ api.dict_set_item(globals, key, builtins);
2410
+ api.decref(key);
2411
+ let result = api.run_string("frozenset({10, 20})", 258, globals, globals);
2412
+ let py_fset = result.expect("frozenset eval should succeed");
2413
+ assert!(!py_fset.is_null());
2414
+
2415
+ let sendable = python_to_sendable(py_fset, api).expect("frozenset should convert to Set");
2416
+ match &sendable {
2417
+ SendableValue::Set(items) => {
2418
+ assert_eq!(items.len(), 2);
2419
+ let mut vals: Vec<i64> = items
2420
+ .iter()
2421
+ .map(|item| match item {
2422
+ SendableValue::Integer(n) => *n,
2423
+ other => panic!("expected Integer, got {other:?}"),
2424
+ })
2425
+ .collect();
2426
+ vals.sort();
2427
+ assert_eq!(vals, vec![10, 20]);
2428
+ }
2429
+ other => panic!("expected Set, got {other:?}"),
2430
+ }
2431
+ api.decref(py_fset);
2432
+ api.decref(builtins);
2433
+ api.decref(globals);
2434
+ }
2435
+
2436
+ #[test]
2437
+ #[serial]
2438
+ fn test_python_to_sendable_empty_set() {
2439
+ use crate::test_helpers::skip_if_no_python;
2440
+ let Some(guard) = skip_if_no_python() else {
2441
+ return;
2442
+ };
2443
+ let api = guard.api();
2444
+ let globals = api.dict_new();
2445
+ let builtins = api
2446
+ .import_module("builtins")
2447
+ .expect("builtins should import");
2448
+ let key = api.string_from_str("__builtins__");
2449
+ api.dict_set_item(globals, key, builtins);
2450
+ api.decref(key);
2451
+ let result = api.run_string("set()", 258, globals, globals);
2452
+ let py_set = result.expect("empty set eval should succeed");
2453
+ assert!(!py_set.is_null());
2454
+
2455
+ let sendable = python_to_sendable(py_set, api).expect("empty set should convert");
2456
+ match &sendable {
2457
+ SendableValue::Set(items) => assert!(items.is_empty()),
2458
+ other => panic!("expected empty Set, got {other:?}"),
2459
+ }
2460
+ api.decref(py_set);
2461
+ api.decref(builtins);
2462
+ api.decref(globals);
2463
+ }
2464
+
2465
+ #[test]
2466
+ #[serial]
2467
+ fn test_python_to_sendable_set_with_mixed_types() {
2468
+ use crate::test_helpers::skip_if_no_python;
2469
+ let Some(guard) = skip_if_no_python() else {
2470
+ return;
2471
+ };
2472
+ let api = guard.api();
2473
+ let globals = api.dict_new();
2474
+ let builtins = api
2475
+ .import_module("builtins")
2476
+ .expect("builtins should import");
2477
+ let key = api.string_from_str("__builtins__");
2478
+ api.dict_set_item(globals, key, builtins);
2479
+ api.decref(key);
2480
+ let result = api.run_string("{42, 'hello', 3.14, True}", 258, globals, globals);
2481
+ let py_set = result.expect("mixed set eval should succeed");
2482
+ assert!(!py_set.is_null());
2483
+
2484
+ let sendable = python_to_sendable(py_set, api).expect("mixed set should convert");
2485
+ match &sendable {
2486
+ SendableValue::Set(items) => {
2487
+ assert_eq!(items.len(), 4);
2488
+ let has_int = items
2489
+ .iter()
2490
+ .any(|i| matches!(i, SendableValue::Integer(42)));
2491
+ let has_str = items
2492
+ .iter()
2493
+ .any(|i| matches!(i, SendableValue::Str(s) if s == "hello"));
2494
+ let has_float = items.iter().any(|i| matches!(i, SendableValue::Float(_)));
2495
+ let has_bool = items.iter().any(|i| matches!(i, SendableValue::Bool(true)));
2496
+ assert!(has_int, "set should contain integer 42");
2497
+ assert!(has_str, "set should contain string 'hello'");
2498
+ assert!(has_float, "set should contain float 3.14");
2499
+ assert!(has_bool, "set should contain bool True");
2500
+ }
2501
+ other => panic!("expected Set, got {other:?}"),
2502
+ }
2503
+ api.decref(py_set);
2504
+ api.decref(builtins);
2505
+ api.decref(globals);
2506
+ }
2507
+
2508
+ #[test]
2509
+ #[serial]
2510
+ fn test_python_to_sendable_set_with_strings() {
2511
+ use crate::test_helpers::skip_if_no_python;
2512
+ let Some(guard) = skip_if_no_python() else {
2513
+ return;
2514
+ };
2515
+ let api = guard.api();
2516
+ let globals = api.dict_new();
2517
+ let builtins = api
2518
+ .import_module("builtins")
2519
+ .expect("builtins should import");
2520
+ let key = api.string_from_str("__builtins__");
2521
+ api.dict_set_item(globals, key, builtins);
2522
+ api.decref(key);
2523
+ let result = api.run_string("{'apple', 'banana', 'cherry'}", 258, globals, globals);
2524
+ let py_set = result.expect("string set eval should succeed");
2525
+ assert!(!py_set.is_null());
2526
+
2527
+ let sendable = python_to_sendable(py_set, api).expect("string set should convert");
2528
+ match &sendable {
2529
+ SendableValue::Set(items) => {
2530
+ assert_eq!(items.len(), 3);
2531
+ let mut vals: Vec<&str> = items
2532
+ .iter()
2533
+ .map(|item| match item {
2534
+ SendableValue::Str(s) => s.as_str(),
2535
+ other => panic!("expected Str, got {other:?}"),
2536
+ })
2537
+ .collect();
2538
+ vals.sort();
2539
+ assert_eq!(vals, vec!["apple", "banana", "cherry"]);
2540
+ }
2541
+ other => panic!("expected Set, got {other:?}"),
2542
+ }
2543
+ api.decref(py_set);
2544
+ api.decref(builtins);
2545
+ api.decref(globals);
2546
+ }
2547
+
2548
+ // ========== python_to_sendable: callable ==========
2549
+
2550
+ #[test]
2551
+ #[serial]
2552
+ fn test_python_to_sendable_user_defined_function() {
2553
+ use crate::test_helpers::skip_if_no_python;
2554
+ let Some(guard) = skip_if_no_python() else {
2555
+ return;
2556
+ };
2557
+ let api = guard.api();
2558
+ let globals = crate::eval::make_globals(api);
2559
+ let py_func = api.run_string(
2560
+ "def greet(name): return f'Hello {name}'\ngreet",
2561
+ 257, // Py_file_input for statements
2562
+ globals.ptr(),
2563
+ globals.ptr(),
2564
+ );
2565
+ // file_input returns None; retrieve the function from globals
2566
+ drop(py_func);
2567
+ let key = api.string_from_str("greet");
2568
+ let func = api.dict_get_item(globals.ptr(), key);
2569
+ api.decref(key);
2570
+ assert!(!func.is_null(), "greet function should exist in globals");
2571
+ assert!(api.callable_check(func) != 0, "greet should be callable");
2572
+
2573
+ let sendable = python_to_sendable(func, api)
2574
+ .expect("user-defined function should convert via PyObjectRef");
2575
+ match &sendable {
2576
+ SendableValue::PyObjectRef(addr) => {
2577
+ assert_eq!(*addr, func as usize);
2578
+ }
2579
+ other => panic!("expected PyObjectRef for function, got {other:?}"),
2580
+ }
2581
+ // Clean up: decref the incref from python_to_sendable
2582
+ // dict_get_item returns a borrowed ref, so only the sendable's incref needs cleanup
2583
+ api.decref(func);
2584
+ }
2585
+
2586
+ #[test]
2587
+ #[serial]
2588
+ fn test_python_to_sendable_lambda() {
2589
+ use crate::test_helpers::skip_if_no_python;
2590
+ let Some(guard) = skip_if_no_python() else {
2591
+ return;
2592
+ };
2593
+ let api = guard.api();
2594
+ let globals = crate::eval::make_globals(api);
2595
+ let py_lambda = api
2596
+ .run_string("lambda x: x * 2", 258, globals.ptr(), globals.ptr())
2597
+ .expect("lambda eval should succeed");
2598
+ assert!(!py_lambda.is_null());
2599
+ assert!(
2600
+ api.callable_check(py_lambda) != 0,
2601
+ "lambda should be callable"
2602
+ );
2603
+
2604
+ let sendable =
2605
+ python_to_sendable(py_lambda, api).expect("lambda should convert via PyObjectRef");
2606
+ match &sendable {
2607
+ SendableValue::PyObjectRef(addr) => {
2608
+ assert_eq!(*addr, py_lambda as usize);
2609
+ }
2610
+ other => panic!("expected PyObjectRef for lambda, got {other:?}"),
2611
+ }
2612
+ api.decref(py_lambda); // sendable's incref
2613
+ api.decref(py_lambda); // run_string's ref
2614
+ }
2615
+
2616
+ #[test]
2617
+ #[serial]
2618
+ fn test_python_to_sendable_builtin_function() {
2619
+ use crate::test_helpers::skip_if_no_python;
2620
+ let Some(guard) = skip_if_no_python() else {
2621
+ return;
2622
+ };
2623
+ let api = guard.api();
2624
+ let builtins = api
2625
+ .import_module("builtins")
2626
+ .expect("builtins should import");
2627
+ let len_func = api.object_get_attr_string(builtins, "len");
2628
+ assert!(!len_func.is_null(), "len should be accessible");
2629
+ assert!(api.callable_check(len_func) != 0, "len should be callable");
2630
+
2631
+ let sendable = python_to_sendable(len_func, api)
2632
+ .expect("builtin function should convert via PyObjectRef");
2633
+ match &sendable {
2634
+ SendableValue::PyObjectRef(addr) => {
2635
+ assert_eq!(*addr, len_func as usize);
2636
+ }
2637
+ other => panic!("expected PyObjectRef for builtin function, got {other:?}"),
2638
+ }
2639
+ api.decref(len_func); // sendable's incref
2640
+ api.decref(len_func); // get_attr_string ref
2641
+ api.decref(builtins);
2642
+ }
2643
+
2644
+ #[test]
2645
+ #[serial]
2646
+ fn test_python_to_sendable_class() {
2647
+ use crate::test_helpers::skip_if_no_python;
2648
+ let Some(guard) = skip_if_no_python() else {
2649
+ return;
2650
+ };
2651
+ let api = guard.api();
2652
+ let globals = crate::eval::make_globals(api);
2653
+ // Define a class (classes are callable — calling them constructs instances)
2654
+ let _ = api.run_string(
2655
+ "class Greeter:\n def __init__(self, name):\n self.name = name",
2656
+ 257,
2657
+ globals.ptr(),
2658
+ globals.ptr(),
2659
+ );
2660
+ let key = api.string_from_str("Greeter");
2661
+ let cls = api.dict_get_item(globals.ptr(), key);
2662
+ api.decref(key);
2663
+ assert!(!cls.is_null(), "Greeter class should exist in globals");
2664
+ assert!(api.callable_check(cls) != 0, "classes should be callable");
2665
+
2666
+ let sendable = python_to_sendable(cls, api).expect("class should convert via PyObjectRef");
2667
+ match &sendable {
2668
+ SendableValue::PyObjectRef(addr) => {
2669
+ assert_eq!(*addr, cls as usize);
2670
+ }
2671
+ other => panic!("expected PyObjectRef for class, got {other:?}"),
2672
+ }
2673
+ api.decref(cls); // sendable's incref
2674
+ }
2675
+
2676
+ #[test]
2677
+ #[serial]
2678
+ fn test_python_to_sendable_instance_with_call() {
2679
+ use crate::test_helpers::skip_if_no_python;
2680
+ let Some(guard) = skip_if_no_python() else {
2681
+ return;
2682
+ };
2683
+ let api = guard.api();
2684
+ let globals = crate::eval::make_globals(api);
2685
+ // Create an instance with __call__
2686
+ let py_obj = api
2687
+ .run_string(
2688
+ "type('Adder', (), {'__call__': lambda self, x, y: x + y})()",
2689
+ 258,
2690
+ globals.ptr(),
2691
+ globals.ptr(),
2692
+ )
2693
+ .expect("callable instance eval should succeed");
2694
+ assert!(!py_obj.is_null());
2695
+ assert!(
2696
+ api.callable_check(py_obj) != 0,
2697
+ "instance with __call__ should be callable"
2698
+ );
2699
+
2700
+ let sendable = python_to_sendable(py_obj, api)
2701
+ .expect("callable instance should convert via PyObjectRef");
2702
+ match &sendable {
2703
+ SendableValue::PyObjectRef(addr) => {
2704
+ assert_eq!(*addr, py_obj as usize);
2705
+ }
2706
+ other => panic!("expected PyObjectRef for callable instance, got {other:?}"),
2707
+ }
2708
+ api.decref(py_obj); // sendable's incref
2709
+ api.decref(py_obj); // run_string ref
2710
+ }
2711
+
2712
+ #[test]
2713
+ #[serial]
2714
+ fn test_python_to_sendable_callable_is_callable_check() {
2715
+ use crate::test_helpers::skip_if_no_python;
2716
+ let Some(guard) = skip_if_no_python() else {
2717
+ return;
2718
+ };
2719
+ let api = guard.api();
2720
+ let globals = crate::eval::make_globals(api);
2721
+ let py_lambda = api
2722
+ .run_string("lambda: 42", 258, globals.ptr(), globals.ptr())
2723
+ .expect("lambda eval should succeed");
2724
+
2725
+ let sendable = python_to_sendable(py_lambda, api).expect("lambda should convert");
2726
+ match &sendable {
2727
+ SendableValue::PyObjectRef(addr) => {
2728
+ // Verify the wrapped object is still callable
2729
+ let ptr = *addr as *mut PyObject;
2730
+ assert!(
2731
+ api.callable_check(ptr) != 0,
2732
+ "wrapped callable should still report callable"
2733
+ );
2734
+ }
2735
+ other => panic!("expected PyObjectRef, got {other:?}"),
2736
+ }
2737
+ api.decref(py_lambda);
2738
+ api.decref(py_lambda);
2739
+ }
2740
+
2741
+ #[test]
2742
+ #[serial]
2743
+ fn test_python_to_sendable_callable_method() {
2744
+ use crate::test_helpers::skip_if_no_python;
2745
+ let Some(guard) = skip_if_no_python() else {
2746
+ return;
2747
+ };
2748
+ let api = guard.api();
2749
+ // Get a bound method: "hello".upper
2750
+ let globals = crate::eval::make_globals(api);
2751
+ let py_method = api
2752
+ .run_string("'hello'.upper", 258, globals.ptr(), globals.ptr())
2753
+ .expect("bound method eval should succeed");
2754
+ assert!(!py_method.is_null());
2755
+ assert!(
2756
+ api.callable_check(py_method) != 0,
2757
+ "bound method should be callable"
2758
+ );
2759
+
2760
+ let sendable = python_to_sendable(py_method, api)
2761
+ .expect("bound method should convert via PyObjectRef");
2762
+ assert!(
2763
+ matches!(sendable, SendableValue::PyObjectRef(_)),
2764
+ "bound method should be PyObjectRef"
2765
+ );
2766
+ api.decref(py_method); // sendable's incref
2767
+ api.decref(py_method); // run_string ref
2768
+ }
2769
+
2770
+ #[test]
2771
+ #[serial]
2772
+ fn test_python_to_sendable_primitives_not_py_object_ref() {
2773
+ use crate::test_helpers::skip_if_no_python;
2774
+ let Some(guard) = skip_if_no_python() else {
2775
+ return;
2776
+ };
2777
+ let api = guard.api();
2778
+
1999
2779
  // int → Integer, not PyObjectRef
2000
2780
  let py_int = api.long_from_i64(42);
2001
2781
  assert!(matches!(
@@ -2016,4 +2796,291 @@ mod tests {
2016
2796
  Ok(SendableValue::Nil)
2017
2797
  ));
2018
2798
  }
2799
+
2800
+ // ========== python_to_sendable: bytes/bytearray ==========
2801
+
2802
+ #[test]
2803
+ #[serial]
2804
+ fn test_python_to_sendable_bytes() {
2805
+ use crate::test_helpers::skip_if_no_python;
2806
+ let Some(guard) = skip_if_no_python() else {
2807
+ return;
2808
+ };
2809
+ let api = guard.api();
2810
+
2811
+ let py_bytes = api.bytes_from_slice(b"hello");
2812
+ let result = python_to_sendable(py_bytes, api);
2813
+ assert!(
2814
+ matches!(&result, Ok(SendableValue::Bytes(v)) if v == b"hello"),
2815
+ "Python bytes should convert to SendableValue::Bytes, got: {:?}",
2816
+ result
2817
+ );
2818
+ api.decref(py_bytes);
2819
+ }
2820
+
2821
+ #[test]
2822
+ #[serial]
2823
+ fn test_python_to_sendable_bytes_empty() {
2824
+ use crate::test_helpers::skip_if_no_python;
2825
+ let Some(guard) = skip_if_no_python() else {
2826
+ return;
2827
+ };
2828
+ let api = guard.api();
2829
+
2830
+ let py_bytes = api.bytes_from_slice(b"");
2831
+ let result = python_to_sendable(py_bytes, api);
2832
+ assert!(
2833
+ matches!(&result, Ok(SendableValue::Bytes(v)) if v.is_empty()),
2834
+ "Empty Python bytes should convert to empty Bytes, got: {:?}",
2835
+ result
2836
+ );
2837
+ api.decref(py_bytes);
2838
+ }
2839
+
2840
+ #[test]
2841
+ #[serial]
2842
+ fn test_python_to_sendable_bytes_with_nulls() {
2843
+ use crate::test_helpers::skip_if_no_python;
2844
+ let Some(guard) = skip_if_no_python() else {
2845
+ return;
2846
+ };
2847
+ let api = guard.api();
2848
+
2849
+ let data: &[u8] = b"\x00\x01\xff\x00\xfe";
2850
+ let py_bytes = api.bytes_from_slice(data);
2851
+ let result = python_to_sendable(py_bytes, api);
2852
+ assert!(
2853
+ matches!(&result, Ok(SendableValue::Bytes(v)) if v.as_slice() == data),
2854
+ "Bytes with NULs should roundtrip, got: {:?}",
2855
+ result
2856
+ );
2857
+ api.decref(py_bytes);
2858
+ }
2859
+
2860
+ #[test]
2861
+ #[serial]
2862
+ fn test_python_to_sendable_bytearray() {
2863
+ use crate::test_helpers::skip_if_no_python;
2864
+ let Some(guard) = skip_if_no_python() else {
2865
+ return;
2866
+ };
2867
+ let api = guard.api();
2868
+
2869
+ let py_ba = api.bytearray_from_slice(b"world");
2870
+ let result = python_to_sendable(py_ba, api);
2871
+ assert!(
2872
+ matches!(&result, Ok(SendableValue::Bytes(v)) if v == b"world"),
2873
+ "Python bytearray should convert to SendableValue::Bytes, got: {:?}",
2874
+ result
2875
+ );
2876
+ api.decref(py_ba);
2877
+ }
2878
+
2879
+ #[test]
2880
+ #[serial]
2881
+ fn test_python_to_sendable_bytearray_empty() {
2882
+ use crate::test_helpers::skip_if_no_python;
2883
+ let Some(guard) = skip_if_no_python() else {
2884
+ return;
2885
+ };
2886
+ let api = guard.api();
2887
+
2888
+ let py_ba = api.bytearray_from_slice(b"");
2889
+ let result = python_to_sendable(py_ba, api);
2890
+ assert!(
2891
+ matches!(&result, Ok(SendableValue::Bytes(v)) if v.is_empty()),
2892
+ "Empty bytearray should convert to empty Bytes, got: {:?}",
2893
+ result
2894
+ );
2895
+ api.decref(py_ba);
2896
+ }
2897
+
2898
+ #[test]
2899
+ #[serial]
2900
+ fn test_python_to_sendable_bytes_not_string() {
2901
+ use crate::test_helpers::skip_if_no_python;
2902
+ let Some(guard) = skip_if_no_python() else {
2903
+ return;
2904
+ };
2905
+ let api = guard.api();
2906
+
2907
+ // Python bytes must not produce SendableValue::Str
2908
+ let py_bytes = api.bytes_from_slice(b"hello");
2909
+ let result = python_to_sendable(py_bytes, api);
2910
+ assert!(
2911
+ !matches!(&result, Ok(SendableValue::Str(_))),
2912
+ "Python bytes must not become Str"
2913
+ );
2914
+ assert!(
2915
+ !matches!(&result, Ok(SendableValue::PyObjectRef(_))),
2916
+ "Python bytes must not fall through to PyObjectRef"
2917
+ );
2918
+ api.decref(py_bytes);
2919
+ }
2920
+
2921
+ #[test]
2922
+ #[serial]
2923
+ fn test_python_to_sendable_bytes_in_list() {
2924
+ use crate::test_helpers::skip_if_no_python;
2925
+ let Some(guard) = skip_if_no_python() else {
2926
+ return;
2927
+ };
2928
+ let api = guard.api();
2929
+
2930
+ // [b"hello", 42, b"\x00\xff"]
2931
+ let py_list = api.list_new(3);
2932
+ api.list_set_item(py_list, 0, api.bytes_from_slice(b"hello"));
2933
+ api.list_set_item(py_list, 1, api.long_from_i64(42));
2934
+ api.list_set_item(py_list, 2, api.bytes_from_slice(b"\x00\xff"));
2935
+
2936
+ let result = python_to_sendable(py_list, api).expect("list with bytes should convert");
2937
+ if let SendableValue::List(items) = result {
2938
+ assert_eq!(items.len(), 3);
2939
+ assert!(matches!(&items[0], SendableValue::Bytes(v) if v == b"hello"));
2940
+ assert!(matches!(&items[1], SendableValue::Integer(42)));
2941
+ assert!(matches!(&items[2], SendableValue::Bytes(v) if v == b"\x00\xff"));
2942
+ } else {
2943
+ panic!("Expected List, got: {:?}", result);
2944
+ }
2945
+ api.decref(py_list);
2946
+ }
2947
+
2948
+ #[test]
2949
+ #[serial]
2950
+ fn test_python_to_sendable_bytes_as_dict_value() {
2951
+ use crate::test_helpers::skip_if_no_python;
2952
+ let Some(guard) = skip_if_no_python() else {
2953
+ return;
2954
+ };
2955
+ let api = guard.api();
2956
+
2957
+ let py_dict = api.dict_new();
2958
+ let key = api.string_from_str("data");
2959
+ let val = api.bytes_from_slice(b"\xde\xad\xbe\xef");
2960
+ api.dict_set_item(py_dict, key, val);
2961
+ api.decref(key);
2962
+ api.decref(val);
2963
+
2964
+ let result =
2965
+ python_to_sendable(py_dict, api).expect("dict with bytes value should convert");
2966
+ if let SendableValue::Dict(entries) = result {
2967
+ assert_eq!(entries.len(), 1);
2968
+ assert!(matches!(&entries[0].0, SendableValue::Str(s) if s == "data"));
2969
+ assert!(matches!(&entries[0].1, SendableValue::Bytes(v) if v == b"\xde\xad\xbe\xef"));
2970
+ } else {
2971
+ panic!("Expected Dict, got: {:?}", result);
2972
+ }
2973
+ api.decref(py_dict);
2974
+ }
2975
+
2976
+ // ========== ruby_to_python: ASCII-8BIT String → Python bytes ==========
2977
+
2978
+ #[test]
2979
+ #[serial]
2980
+ fn test_ruby_to_python_ascii_8bit_string_becomes_bytes() {
2981
+ with_ruby_python(|ruby, api| {
2982
+ let s = ruby.str_from_slice(b"hello");
2983
+ s.enc_associate(
2984
+ ruby.find_encoding("ASCII-8BIT")
2985
+ .expect("ASCII-8BIT must exist"),
2986
+ )
2987
+ .unwrap();
2988
+
2989
+ let py_obj = ruby_to_python(s.as_value(), api)
2990
+ .expect("ASCII-8BIT string conversion should succeed");
2991
+
2992
+ assert!(api.bytes_check(py_obj), "Should produce Python bytes");
2993
+ assert!(!api.is_string(py_obj), "Should NOT produce Python str");
2994
+
2995
+ let back = api.bytes_to_vec(py_obj).unwrap();
2996
+ assert_eq!(back, b"hello");
2997
+ api.decref(py_obj);
2998
+ });
2999
+ }
3000
+
3001
+ #[test]
3002
+ #[serial]
3003
+ fn test_ruby_to_python_ascii_8bit_empty() {
3004
+ with_ruby_python(|ruby, api| {
3005
+ let s = ruby.str_from_slice(b"");
3006
+ s.enc_associate(
3007
+ ruby.find_encoding("ASCII-8BIT")
3008
+ .expect("ASCII-8BIT must exist"),
3009
+ )
3010
+ .unwrap();
3011
+
3012
+ let py_obj =
3013
+ ruby_to_python(s.as_value(), api).expect("empty ASCII-8BIT should convert");
3014
+
3015
+ assert!(api.bytes_check(py_obj));
3016
+ let back = api.bytes_to_vec(py_obj).unwrap();
3017
+ assert!(back.is_empty());
3018
+ api.decref(py_obj);
3019
+ });
3020
+ }
3021
+
3022
+ #[test]
3023
+ #[serial]
3024
+ fn test_ruby_to_python_ascii_8bit_with_nulls() {
3025
+ with_ruby_python(|ruby, api| {
3026
+ let data: &[u8] = b"\x00\x01\x02\xff\xfe\x00";
3027
+ let s = ruby.str_from_slice(data);
3028
+ s.enc_associate(
3029
+ ruby.find_encoding("ASCII-8BIT")
3030
+ .expect("ASCII-8BIT must exist"),
3031
+ )
3032
+ .unwrap();
3033
+
3034
+ let py_obj =
3035
+ ruby_to_python(s.as_value(), api).expect("binary data with NULs should convert");
3036
+
3037
+ assert!(api.bytes_check(py_obj));
3038
+ let back = api.bytes_to_vec(py_obj).unwrap();
3039
+ assert_eq!(back, data);
3040
+ api.decref(py_obj);
3041
+ });
3042
+ }
3043
+
3044
+ #[test]
3045
+ #[serial]
3046
+ fn test_ruby_to_python_utf8_string_stays_str() {
3047
+ with_ruby_python(|ruby, api| {
3048
+ // Regular UTF-8 string must still become Python str, not bytes
3049
+ let py_str = ruby_to_python("hello".into_value_with(ruby), api)
3050
+ .expect("UTF-8 string should convert");
3051
+
3052
+ assert!(api.is_string(py_str), "UTF-8 should produce Python str");
3053
+ assert!(!api.bytes_check(py_str), "UTF-8 should NOT produce bytes");
3054
+ assert_eq!(api.string_to_string(py_str), Some("hello".to_string()));
3055
+ api.decref(py_str);
3056
+ });
3057
+ }
3058
+
3059
+ #[test]
3060
+ #[serial]
3061
+ fn test_ruby_to_python_ascii_8bit_roundtrip_via_sendable() {
3062
+ with_ruby_python(|ruby, api| {
3063
+ // Ruby ASCII-8BIT String → Python bytes → SendableValue::Bytes
3064
+ let data: &[u8] = b"\xca\xfe\xba\xbe";
3065
+ let s = ruby.str_from_slice(data);
3066
+ s.enc_associate(
3067
+ ruby.find_encoding("ASCII-8BIT")
3068
+ .expect("ASCII-8BIT must exist"),
3069
+ )
3070
+ .unwrap();
3071
+
3072
+ // Ruby → Python
3073
+ let py_obj = ruby_to_python(s.as_value(), api).expect("should convert to Python");
3074
+ assert!(api.bytes_check(py_obj));
3075
+
3076
+ // Python → SendableValue
3077
+ let sendable = python_to_sendable(py_obj, api).expect("should convert to sendable");
3078
+ assert!(
3079
+ matches!(&sendable, SendableValue::Bytes(v) if v == data),
3080
+ "Full roundtrip should preserve bytes, got: {:?}",
3081
+ sendable
3082
+ );
3083
+ api.decref(py_obj);
3084
+ });
3085
+ }
2019
3086
  }