rubyx-py 0.1.0

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.
@@ -0,0 +1,4212 @@
1
+ use crate::async_gen::{AsyncGeneratorStream, AsyncStrategy};
2
+ use crate::nonblocking_stream::NonBlockingStream;
3
+ use crate::pipe_notify::PipeNotify;
4
+ use crate::python_api::PythonApi;
5
+ use crate::rubyx_object::python_to_sendable;
6
+ use crate::rubyx_object::RubyxObject;
7
+ use crate::rubyx_stream::RubyxStream;
8
+ use crate::stream::StreamItem;
9
+ use crossbeam_channel::unbounded;
10
+ use magnus::typed_data::Obj;
11
+ use magnus::{function, method, Module, Object, RArray, Ruby, TryConvert, Value};
12
+ use std::sync::Arc;
13
+ use std::sync::OnceLock;
14
+
15
+ mod async_gen;
16
+ mod context;
17
+ mod convert;
18
+ mod eval;
19
+ mod exception;
20
+ mod future;
21
+ mod import;
22
+ mod nonblocking_stream;
23
+ mod pipe_notify;
24
+ #[allow(dead_code)]
25
+ mod python_api;
26
+ mod python_ffi;
27
+ #[cfg(test)]
28
+ mod python_finder;
29
+ mod python_guard;
30
+ mod ruby_helpers;
31
+ mod rubyx_object;
32
+ mod rubyx_stream;
33
+ mod stream;
34
+ #[cfg(test)]
35
+ pub mod test_helpers;
36
+
37
+ // Shared Python API Instance
38
+ static API: OnceLock<PythonApi> = OnceLock::new();
39
+
40
+ /// Global accessor for the Python API.
41
+ /// Panics if `Rubyx.init` has not been called yet.
42
+ fn api() -> &'static PythonApi {
43
+ API.get().expect("Python not initialized — call Rubyx.init(python_dl, python_home, python_exe, sys_paths) first")
44
+ }
45
+
46
+ #[magnus::init]
47
+ fn init(ruby: &magnus::Ruby) -> Result<(), magnus::Error> {
48
+ // Define Rubyx Module
49
+ let rubyx_module = ruby.define_module("Rubyx")?;
50
+
51
+ rubyx_module.define_singleton_method("init", function!(rubyx_init, 4))?;
52
+ rubyx_module.define_singleton_method("initialized?", function!(rubyx_initialized, 0))?;
53
+
54
+ // Module Class Methods
55
+ rubyx_module.define_singleton_method("_import", function!(crate::import::rubyx_import, 1))?;
56
+ rubyx_module.define_singleton_method("_eval", function!(crate::eval::rubyx_eval, 1))?;
57
+ rubyx_module.define_singleton_method(
58
+ "_eval_with_globals",
59
+ function!(crate::eval::rubyx_eval_with_globals, 2),
60
+ )?;
61
+ rubyx_module.define_singleton_method("_await", function!(crate::eval::rubyx_await, 1))?;
62
+ rubyx_module.define_singleton_method(
63
+ "_await_with_globals",
64
+ function!(crate::eval::rubyx_await_with_globals, 2),
65
+ )?;
66
+ rubyx_module.define_singleton_method("_async_await", function!(rubyx_async_await, 1))?;
67
+ rubyx_module.define_singleton_method(
68
+ "_async_await_with_globals",
69
+ function!(crate::eval::rubyx_async_await_with_globals, 2),
70
+ )?;
71
+
72
+ // RubyxObject class for wrapped Python objects
73
+ let py_object = ruby.define_class("RubyxObject", ruby.class_object())?;
74
+ py_object.define_method(
75
+ "method_missing",
76
+ method!(crate::rubyx_object::RubyxObject::method_missing, -1),
77
+ )?;
78
+ py_object.define_method("to_s", method!(crate::rubyx_object::RubyxObject::to_s, 0))?;
79
+ py_object.define_method(
80
+ "inspect",
81
+ method!(crate::rubyx_object::RubyxObject::inspect, 0),
82
+ )?;
83
+ py_object.define_method(
84
+ "to_ruby",
85
+ method!(crate::rubyx_object::RubyxObject::to_ruby, 0),
86
+ )?;
87
+ py_object.define_method("[]", method!(RubyxObject::getitem, 1))?;
88
+ py_object.define_method("[]=", method!(RubyxObject::setitem, 2))?;
89
+ py_object.define_method("delete", method!(RubyxObject::delitem, 1))?;
90
+ py_object.define_method(
91
+ "respond_to_missing?",
92
+ method!(RubyxObject::respond_to_missing, -1),
93
+ )?;
94
+ py_object.define_method("truthy?", method!(RubyxObject::is_truthy, 0))?;
95
+ py_object.define_method("falsy?", method!(RubyxObject::is_falsy, 0))?;
96
+ py_object.define_method("callable?", method!(RubyxObject::is_callable, 0))?;
97
+ py_object.define_method("py_type", method!(RubyxObject::py_type, 0))?;
98
+ py_object.define_method("each", method!(RubyxObject::each, 0))?;
99
+ py_object.include_module(ruby.module_enumerable())?;
100
+
101
+ // RubyxStream class with Enumerable
102
+ let stream_class = rubyx_module.define_class("Stream", ruby.class_object())?;
103
+ stream_class.define_method("each", method!(crate::rubyx_stream::RubyxStream::each, 0))?;
104
+ stream_class.define_method(
105
+ "next",
106
+ method!(crate::rubyx_stream::RubyxStream::next_item, 0),
107
+ )?;
108
+ stream_class.include_module(ruby.module_enumerable())?;
109
+
110
+ // Rubyx.stream(iterable) — creates a RubyxStream from a Python iterable
111
+ rubyx_module.define_singleton_method("stream", function!(create_stream, -1))?;
112
+ // Rubyx.async_stream(iterable) - creates a RubyxStream from rust event loop
113
+ rubyx_module.define_singleton_method("async_stream", function!(create_async_stream, -1))?;
114
+
115
+ // NonBlockingStream class with Enumerable
116
+ let nb_stream_class = rubyx_module.define_class("NonBlockingStream", ruby.class_object())?;
117
+ nb_stream_class.define_method(
118
+ "each",
119
+ method!(crate::nonblocking_stream::NonBlockingStream::each, 0),
120
+ )?;
121
+ nb_stream_class.include_module(ruby.module_enumerable())?;
122
+
123
+ // Rubyx.nb_stream(iterable) — creates a NonBlockingStream from a Python iterable
124
+ rubyx_module.define_singleton_method("nb_stream", function!(create_nb_stream, 1))?;
125
+
126
+ let context_class = rubyx_module.define_class("Context", ruby.class_object())?;
127
+ context_class
128
+ .define_singleton_method("new", function!(crate::context::RubyxContext::new, 0))?;
129
+ context_class.define_method("_eval", method!(crate::context::RubyxContext::eval, 1))?;
130
+ context_class.define_method(
131
+ "_eval_with_globals",
132
+ method!(crate::context::RubyxContext::eval_with_globals, 2),
133
+ )?;
134
+ context_class.define_method(
135
+ "_await",
136
+ method!(crate::context::RubyxContext::await_eval, 1),
137
+ )?;
138
+ context_class.define_method(
139
+ "_await_with_globals",
140
+ method!(crate::context::RubyxContext::await_eval_with_globals, 2),
141
+ )?;
142
+ context_class.define_method(
143
+ "_async_await",
144
+ method!(crate::context::RubyxContext::async_await_eval, 1),
145
+ )?;
146
+ context_class.define_method(
147
+ "_async_await_with_globals",
148
+ method!(
149
+ crate::context::RubyxContext::async_await_eval_with_globals,
150
+ 2
151
+ ),
152
+ )?;
153
+ rubyx_module
154
+ .define_singleton_method("context", function!(crate::context::RubyxContext::new, 0))?;
155
+
156
+ // Rubyx::Future class
157
+ let future_class = rubyx_module.define_class("Future", ruby.class_object())?;
158
+ future_class.define_method("value", method!(crate::future::RubyxFuture::value, 0))?;
159
+ future_class.define_method("ready?", method!(crate::future::RubyxFuture::is_ready, 0))?;
160
+
161
+ Ok(())
162
+ }
163
+
164
+ /// Rubyx.async_await(coroutine) — runs a Python coroutine on a background thread.
165
+ /// Returns a Rubyx::Future immediately. Call future.value to get the result.
166
+ fn rubyx_async_await(coroutine: Value) -> Result<future::RubyxFuture, magnus::Error> {
167
+ let obj = Obj::<RubyxObject>::try_convert(coroutine).map_err(|_| {
168
+ magnus::Error::new(
169
+ ruby_helpers::type_error(),
170
+ "Rubyx.async_await requires a Python coroutine (RubyxObject)",
171
+ )
172
+ })?;
173
+ let api = crate::api();
174
+ let gil = api.ensure_gil();
175
+
176
+ let future = future::RubyxFuture::from_coroutine(obj.as_ptr(), api);
177
+
178
+ api.release_gil(gil);
179
+ Ok(future)
180
+ }
181
+
182
+ fn rubyx_initialized() -> bool {
183
+ API.get().is_some()
184
+ }
185
+
186
+ /// `rubyx_init`: accept config paths and initialize from ruby
187
+ fn rubyx_init(
188
+ python_dl: String,
189
+ python_home: String,
190
+ python_exe: String,
191
+ sys_paths: RArray,
192
+ ) -> Result<bool, magnus::Error> {
193
+ if API.get().is_some() {
194
+ return Err(magnus::Error::new(
195
+ ruby_helpers::runtime_error(),
196
+ "Python Interpreter already initialized",
197
+ ));
198
+ }
199
+
200
+ let mut api = unsafe {
201
+ PythonApi::load(std::path::Path::new(&python_dl)).map_err(|e| {
202
+ magnus::Error::new(
203
+ ruby_helpers::runtime_error(),
204
+ format!("Error loading Python interpreter: {e}"),
205
+ )
206
+ })?
207
+ };
208
+
209
+ api.set_python_home(&python_home);
210
+ api.set_program_name(&python_exe);
211
+
212
+ api.initialize_ex(0);
213
+
214
+ if !api.is_initialized() {
215
+ return Err(magnus::Error::new(
216
+ ruby_helpers::runtime_error(),
217
+ "Python Interpreter failed to initialize",
218
+ ));
219
+ }
220
+
221
+ inject_sys_paths(&api, &sys_paths)?;
222
+
223
+ let _ = api.install_async_to_sync_class();
224
+
225
+ // Release the GIL that Py_InitializeEx() acquired on the main thread.
226
+ // This allows worker threads to acquire it via ensure_gil()/release_gil().
227
+ // Without this, the main thread permanently holds the GIL and any
228
+ // background thread calling ensure_gil() will deadlock.
229
+ api.save_thread();
230
+
231
+ API.set(api).map_err(|_| {
232
+ magnus::Error::new(ruby_helpers::runtime_error(), "Failed to store Python API")
233
+ })?;
234
+
235
+ Ok(true)
236
+ }
237
+
238
+ fn inject_sys_paths(api: &PythonApi, sys_paths: &RArray) -> Result<(), magnus::Error> {
239
+ let sys_module = api.import_module("sys").map_err(|e| {
240
+ magnus::Error::new(
241
+ ruby_helpers::runtime_error(),
242
+ format!("Failed to import sys module: {e}"),
243
+ )
244
+ })?;
245
+ let sys = api.object_get_attr_string(sys_module, "path");
246
+ if sys.is_null() {
247
+ api.decref(sys_module);
248
+ return Err(magnus::Error::new(
249
+ ruby_helpers::runtime_error(),
250
+ "Failed to get sys.path",
251
+ ));
252
+ }
253
+
254
+ // Append sys_paths to sys.path
255
+ let len = sys_paths.len();
256
+ for i in 0..len {
257
+ let path: String = sys_paths.entry(i as isize).map_err(|e| {
258
+ magnus::Error::new(
259
+ ruby_helpers::runtime_error(),
260
+ format!("Failed to get path at index {i}: {e}"),
261
+ )
262
+ })?;
263
+ let py_str = api.string_from_str(&path);
264
+ if py_str.is_null() {
265
+ continue;
266
+ }
267
+ let result = api.list_append(sys, py_str);
268
+ if result == -1 {
269
+ api.decref(py_str);
270
+ continue;
271
+ }
272
+ api.decref(py_str);
273
+ }
274
+ api.decref(sys);
275
+ api.decref(sys_module);
276
+
277
+ api.clear_error();
278
+ Ok(())
279
+ }
280
+
281
+ fn create_stream(args: &[Value]) -> Result<rubyx_stream::RubyxStream, magnus::Error> {
282
+ let ruby = Ruby::get().map_err(|e| {
283
+ magnus::Error::new(
284
+ ruby_helpers::runtime_error(),
285
+ format!("Error getting Ruby: {e}"),
286
+ )
287
+ })?;
288
+ let has_block = ruby.block_given();
289
+ if args.len() == 1 && !has_block {
290
+ create_stream_from_iterable(args[0])
291
+ } else if args.is_empty() && has_block {
292
+ // Get proc
293
+ let proc = ruby.block_proc().map_err(|e| {
294
+ magnus::Error::new(
295
+ ruby_helpers::runtime_error(),
296
+ format!("Error getting block proc: {e}"),
297
+ )
298
+ })?;
299
+ // Run Proc
300
+ let iterable: Value = proc.call(())?;
301
+ create_stream_from_iterable(iterable)
302
+ } else {
303
+ Err(magnus::Error::new(
304
+ ruby_helpers::arg_error(),
305
+ "Rubyx.stream takes either 0 or 1 arguments",
306
+ ))
307
+ }
308
+ }
309
+
310
+ fn create_nb_stream(
311
+ iterable: Value,
312
+ ) -> Result<nonblocking_stream::NonBlockingStream, magnus::Error> {
313
+ let obj = Obj::<RubyxObject>::try_convert(iterable).map_err(|_| {
314
+ magnus::Error::new(
315
+ ruby_helpers::type_error(),
316
+ "Rubyx.nb_stream requires a Python object (RubyxObject)",
317
+ )
318
+ })?;
319
+
320
+ let api = crate::api();
321
+ let gil = api.ensure_gil();
322
+
323
+ // Get a Python iterator — handle both sync and async iterables
324
+ let py_iter = if api.is_async_iterable(obj.as_ptr()) {
325
+ let sync_iter = api.wrap_async_generator(obj.as_ptr());
326
+ if sync_iter.is_null() {
327
+ api.clear_error();
328
+ api.release_gil(gil);
329
+ return Err(magnus::Error::new(
330
+ ruby_helpers::runtime_error(),
331
+ "Failed to wrap async generator",
332
+ ));
333
+ }
334
+ sync_iter
335
+ } else {
336
+ let iter = api.object_get_iter(obj.as_ptr());
337
+ if iter.is_null() {
338
+ api.clear_error();
339
+ api.release_gil(gil);
340
+ return Err(magnus::Error::new(
341
+ ruby_helpers::type_error(),
342
+ "Object is not iterable",
343
+ ));
344
+ }
345
+ iter
346
+ };
347
+
348
+ api.release_gil(gil);
349
+
350
+ // Use unbounded channel so the producer never blocks on send().
351
+ // With a bounded channel, the producer blocks when the channel is
352
+ // full. If the consumer is in IO.select (fiber-aware path) waiting
353
+ // for a pipe notification, and the producer can't reach notify()
354
+ // because it's blocked on send(), both sides deadlock.
355
+ let (tx, rx) = unbounded();
356
+ let pipe = Arc::new(PipeNotify::new().map_err(|e| {
357
+ magnus::Error::new(
358
+ ruby_helpers::runtime_error(),
359
+ format!("Failed to create pipe: {e}"),
360
+ )
361
+ })?);
362
+ let pipe_clone = pipe.clone();
363
+ let py_iter_addr = py_iter as usize;
364
+
365
+ std::thread::spawn(move || {
366
+ let py_iter = py_iter_addr as *mut crate::python_ffi::PyObject;
367
+ let api = crate::api();
368
+ let gil = api.ensure_gil();
369
+
370
+ loop {
371
+ let item = api.iter_next(py_iter);
372
+ if item.is_null() {
373
+ if api.has_error() {
374
+ if let Some(exc) = crate::python_api::PythonApi::extract_exception(api) {
375
+ let _ = tx.send(StreamItem::Error(exc.to_string()));
376
+ } else {
377
+ let _ = tx.send(StreamItem::End);
378
+ }
379
+ } else {
380
+ let _ = tx.send(StreamItem::End);
381
+ }
382
+ pipe_clone.notify();
383
+ break;
384
+ }
385
+
386
+ let ruby_value = python_to_sendable(item, api)
387
+ .map_err(|e| format!("Error converting Python value: {e}"));
388
+ api.decref(item);
389
+
390
+ match ruby_value {
391
+ Ok(value) => {
392
+ if tx.send(StreamItem::Value(value)).is_err() {
393
+ break; // Consumer dropped
394
+ }
395
+ pipe_clone.notify();
396
+ }
397
+ Err(e) => {
398
+ let _ = tx.send(StreamItem::Error(e));
399
+ pipe_clone.notify();
400
+ break;
401
+ }
402
+ }
403
+ }
404
+
405
+ api.decref(py_iter);
406
+ api.release_gil(gil);
407
+ });
408
+
409
+ Ok(NonBlockingStream::new(rx, pipe))
410
+ }
411
+
412
+ fn create_async_stream(args: &[Value]) -> Result<rubyx_stream::RubyxStream, magnus::Error> {
413
+ let ruby = Ruby::get().map_err(|e| {
414
+ magnus::Error::new(
415
+ ruby_helpers::runtime_error(),
416
+ format!("Error getting Ruby: {e}"),
417
+ )
418
+ })?;
419
+ let has_block = ruby.block_given();
420
+ if args.len() == 1 && !has_block {
421
+ create_async_stream_from_iterable(args[0])
422
+ } else if args.is_empty() && has_block {
423
+ // Get proc
424
+ let proc = ruby.block_proc().map_err(|e| {
425
+ magnus::Error::new(
426
+ ruby_helpers::runtime_error(),
427
+ format!("Error getting block proc: {e}"),
428
+ )
429
+ })?;
430
+ // Run Proc
431
+ let iterable: Value = proc.call(())?;
432
+ create_async_stream_from_iterable(iterable)
433
+ } else {
434
+ Err(magnus::Error::new(
435
+ ruby_helpers::arg_error(),
436
+ "Rubyx.stream takes either 0 or 1 arguments",
437
+ ))
438
+ }
439
+ }
440
+
441
+ /// Create a RubyxStream from a Python iterable (RubyxObject).
442
+ ///
443
+ /// Acquires the GIL, calls PyObject_GetIter on the wrapped Python object,
444
+ /// and passes the resulting iterator to AsyncStream::from_python_iterator.
445
+ fn create_stream_from_iterable(iterable: Value) -> Result<RubyxStream, magnus::Error> {
446
+ let obj = Obj::<RubyxObject>::try_convert(iterable).map_err(|_| {
447
+ magnus::Error::new(
448
+ ruby_helpers::type_error(),
449
+ "Rubyx.stream requires a Python object (RubyxObject)",
450
+ )
451
+ })?;
452
+
453
+ let stream =
454
+ AsyncGeneratorStream::from_python_object(obj.as_ptr(), AsyncStrategy::PythonAdapter)
455
+ .map_err(|e| magnus::Error::new(ruby_helpers::runtime_error(), e))?;
456
+
457
+ Ok(RubyxStream::from_stream(stream))
458
+ }
459
+
460
+ fn create_async_stream_from_iterable(iterable: Value) -> Result<RubyxStream, magnus::Error> {
461
+ let obj = Obj::<RubyxObject>::try_convert(iterable).map_err(|_| {
462
+ magnus::Error::new(
463
+ ruby_helpers::type_error(),
464
+ "Rubyx.stream requires a Python object (RubyxObject)",
465
+ )
466
+ })?;
467
+
468
+ // Verify the object is actually an async iterable before using RustDriving
469
+ let api = crate::api();
470
+ let gil = api.ensure_gil();
471
+ let is_async = api.is_async_iterable(obj.as_ptr());
472
+ api.release_gil(gil);
473
+
474
+ if !is_async {
475
+ return Err(magnus::Error::new(
476
+ ruby_helpers::type_error(),
477
+ "Object is not an async iterable (missing __aiter__/__anext__)",
478
+ ));
479
+ }
480
+
481
+ let stream = AsyncGeneratorStream::from_python_object(obj.as_ptr(), AsyncStrategy::RustDriving)
482
+ .map_err(|e| magnus::Error::new(ruby_helpers::runtime_error(), e))?;
483
+
484
+ Ok(RubyxStream::from_stream(stream))
485
+ }
486
+ // export LD_LIBRARY_PATH="~/.asdf/installs/ruby/3.4.7/lib:$LD_LIBRARY_PATH"
487
+ // cargo test
488
+
489
+ #[cfg(test)]
490
+ mod tests {
491
+ use crate::rubyx_object::RubyxObject;
492
+ use crate::test_helpers::{skip_if_no_python, with_ruby_python};
493
+ use magnus::typed_data::Obj;
494
+ use magnus::value::ReprValue;
495
+ use magnus::{IntoValue, TryConvert};
496
+ use serial_test::serial;
497
+
498
+ #[test]
499
+ #[serial]
500
+ fn test_rubyx_object_wraps_pyobject() {
501
+ let Some(guard) = skip_if_no_python() else {
502
+ return;
503
+ };
504
+ let api = guard.api();
505
+
506
+ let py_int = api.long_from_i64(42);
507
+ assert!(!py_int.is_null());
508
+
509
+ let wrapper = RubyxObject::new(py_int, api).expect("Should wrap non-null PyObject");
510
+ assert_eq!(
511
+ wrapper.as_ptr(),
512
+ py_int,
513
+ "as_ptr should return the original pointer"
514
+ );
515
+
516
+ // Drop wrapper (decrefs: refcount 2 → 1), then decref original (1 → 0)
517
+ drop(wrapper);
518
+ api.decref(py_int);
519
+ }
520
+
521
+ #[test]
522
+ #[serial]
523
+ fn test_rubyx_object_null_returns_none() {
524
+ let Some(guard) = skip_if_no_python() else {
525
+ return;
526
+ };
527
+ let api = guard.api();
528
+
529
+ let result = RubyxObject::new(std::ptr::null_mut(), api);
530
+ assert!(result.is_none(), "null pointer should return None");
531
+ }
532
+
533
+ #[test]
534
+ #[serial]
535
+ fn test_rubyx_object_increfs_on_create() {
536
+ let Some(guard) = skip_if_no_python() else {
537
+ return;
538
+ };
539
+ let api = guard.api();
540
+
541
+ // long_from_i64 returns a new reference (refcount = 1)
542
+ let py_int = api.long_from_i64(42);
543
+
544
+ // Wrapping increfs (refcount = 2)
545
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
546
+
547
+ // Release our original reference (refcount = 1, wrapper still holds it)
548
+ api.decref(py_int);
549
+
550
+ // Object must still be alive — the wrapper's incref keeps it alive
551
+ let value = api.long_to_i64(wrapper.as_ptr());
552
+ assert_eq!(
553
+ value, 42,
554
+ "Object should still be alive after decref'ing original ref"
555
+ );
556
+
557
+ // wrapper drops here → decref → refcount 0 → freed
558
+ }
559
+
560
+ #[test]
561
+ #[serial]
562
+ fn test_rubyx_object_decrefs_on_drop() {
563
+ let Some(guard) = skip_if_no_python() else {
564
+ return;
565
+ };
566
+ let api = guard.api();
567
+
568
+ // Create object (refcount = 1)
569
+ let py_int = api.long_from_i64(99);
570
+
571
+ // Take an extra ref so we can safely observe after wrapper drops (refcount = 2)
572
+ api.incref(py_int);
573
+
574
+ {
575
+ // Wrap it (incref → refcount = 3)
576
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
577
+ assert_eq!(api.long_to_i64(wrapper.as_ptr()), 99);
578
+ // wrapper drops here → decref → refcount = 2
579
+ }
580
+
581
+ // Object should still be alive (refcount = 2)
582
+ let value = api.long_to_i64(py_int);
583
+ assert_eq!(value, 99, "Object should survive after wrapper drop");
584
+
585
+ // Clean up our two remaining references
586
+ api.decref(py_int);
587
+ api.decref(py_int);
588
+ }
589
+
590
+ #[test]
591
+ #[serial]
592
+ fn test_multiple_wrappers_same_object() {
593
+ let Some(guard) = skip_if_no_python() else {
594
+ return;
595
+ };
596
+ let api = guard.api();
597
+
598
+ // refcount = 1
599
+ let py_int = api.long_from_i64(77);
600
+
601
+ // Each wrapper increfs: refcount 1 → 2 → 3 → 4
602
+ let w1 = RubyxObject::new(py_int, api).unwrap();
603
+ let w2 = RubyxObject::new(py_int, api).unwrap();
604
+ let w3 = RubyxObject::new(py_int, api).unwrap();
605
+
606
+ // All point to the same object
607
+ assert_eq!(w1.as_ptr(), py_int);
608
+ assert_eq!(w2.as_ptr(), py_int);
609
+ assert_eq!(w3.as_ptr(), py_int);
610
+
611
+ // Drop wrappers: each decrefs (refcount 4 → 3 → 2 → 1)
612
+ drop(w1);
613
+ drop(w2);
614
+ drop(w3);
615
+
616
+ // Object still alive (refcount = 1, our original ref)
617
+ assert_eq!(api.long_to_i64(py_int), 77);
618
+
619
+ // Clean up
620
+ api.decref(py_int);
621
+ }
622
+
623
+ #[test]
624
+ #[serial]
625
+ fn test_gc_stress() {
626
+ let Some(guard) = skip_if_no_python() else {
627
+ return;
628
+ };
629
+ let api = guard.api();
630
+
631
+ // Rapid create-and-drop: 1000 wrappers sequentially
632
+ for i in 0..1000 {
633
+ let py_int = api.long_from_i64(i);
634
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
635
+
636
+ assert_eq!(api.long_to_i64(wrapper.as_ptr()), i);
637
+
638
+ drop(wrapper);
639
+ api.decref(py_int);
640
+ }
641
+
642
+ // Many wrappers alive simultaneously
643
+ let mut wrappers = Vec::new();
644
+ for i in 0..100 {
645
+ let py_int = api.long_from_i64(i);
646
+ wrappers.push((py_int, RubyxObject::new(py_int, api).unwrap()));
647
+ }
648
+
649
+ // Verify all still valid while all alive
650
+ for (i, (_ptr, wrapper)) in wrappers.iter().enumerate() {
651
+ assert_eq!(api.long_to_i64(wrapper.as_ptr()), i as i64);
652
+ }
653
+
654
+ // Drop all wrappers and clean up
655
+ for (ptr, wrapper) in wrappers {
656
+ drop(wrapper);
657
+ api.decref(ptr);
658
+ }
659
+ }
660
+
661
+ // ========== Multi-threading safety tests ==========
662
+ //
663
+ // These tests validate the `unsafe impl Send` and `unsafe impl Sync`
664
+ // on RubyxObject. They exercise cross-thread GIL acquisition, concurrent
665
+ // Drop, and shared-reference access from multiple threads.
666
+ //
667
+ // Python's GIL serialises interpreter access, so "thread safety" here means:
668
+ // 1. No crashes / UB when operations happen from different OS threads.
669
+ // 2. Reference counts remain consistent after concurrent create/drop.
670
+ // 3. ensure_gil / release_gil work correctly from non-main threads.
671
+
672
+ /// SAFETY: the wrapped pointer is only dereferenced while holding
673
+ /// the Python GIL, which serialises all interpreter access.
674
+ struct SendPtr(*mut crate::python_ffi::PyObject);
675
+ unsafe impl Send for SendPtr {}
676
+ unsafe impl Sync for SendPtr {}
677
+
678
+ fn get_static_api() -> Option<&'static crate::python_api::PythonApi> {
679
+ crate::test_helpers::get_api()
680
+ }
681
+
682
+ #[test]
683
+ #[serial]
684
+ fn test_send_wrapper_to_another_thread() {
685
+ use std::thread;
686
+
687
+ let Some(guard) = skip_if_no_python() else {
688
+ return;
689
+ };
690
+ let api = guard.api();
691
+
692
+ let py_int = api.long_from_i64(42);
693
+ api.incref(py_int);
694
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
695
+
696
+ let saved = api.save_thread();
697
+ let static_api = get_static_api().unwrap();
698
+
699
+ let handle = thread::spawn(move || {
700
+ let gil = static_api.ensure_gil();
701
+ let val = static_api.long_to_i64(wrapper.as_ptr());
702
+ assert_eq!(val, 42, "Value should survive cross-thread move");
703
+ drop(wrapper);
704
+ static_api.release_gil(gil);
705
+ });
706
+
707
+ handle.join().expect("Worker thread panicked");
708
+ api.restore_thread(saved);
709
+
710
+ let val = api.long_to_i64(py_int);
711
+ assert_eq!(
712
+ val, 42,
713
+ "Object should survive after cross-thread wrapper drop"
714
+ );
715
+ api.decref(py_int);
716
+ }
717
+
718
+ #[test]
719
+ #[serial]
720
+ fn test_concurrent_create_and_drop_different_objects() {
721
+ use std::sync::{Arc, Barrier};
722
+ use std::thread;
723
+
724
+ let Some(guard) = skip_if_no_python() else {
725
+ return;
726
+ };
727
+ let api = guard.api();
728
+ let num_threads = 4;
729
+ let objects_per_thread = 100;
730
+
731
+ let saved = api.save_thread();
732
+ let static_api = get_static_api().unwrap();
733
+
734
+ let barrier = Arc::new(Barrier::new(num_threads));
735
+ let mut handles = Vec::new();
736
+
737
+ for t in 0..num_threads {
738
+ let barrier = Arc::clone(&barrier);
739
+ handles.push(thread::spawn(move || {
740
+ barrier.wait();
741
+
742
+ for i in 0..objects_per_thread {
743
+ let gil = static_api.ensure_gil();
744
+ let value = (t * objects_per_thread + i) as i64;
745
+ let py_int = static_api.long_from_i64(value);
746
+ let wrapper = RubyxObject::new(py_int, static_api).unwrap();
747
+ assert_eq!(static_api.long_to_i64(wrapper.as_ptr()), value);
748
+ drop(wrapper);
749
+ static_api.decref(py_int);
750
+ static_api.release_gil(gil);
751
+ }
752
+ }));
753
+ }
754
+
755
+ for h in handles {
756
+ h.join().expect("Worker thread panicked");
757
+ }
758
+
759
+ api.restore_thread(saved);
760
+ }
761
+
762
+ #[test]
763
+ #[serial]
764
+ fn test_concurrent_wrappers_same_object() {
765
+ use std::sync::{Arc, Barrier};
766
+ use std::thread;
767
+
768
+ let Some(guard) = skip_if_no_python() else {
769
+ return;
770
+ };
771
+ let api = guard.api();
772
+
773
+ let py_int = api.long_from_i64(999);
774
+ api.incref(py_int);
775
+
776
+ let num_threads = 4;
777
+ let wraps_per_thread = 50;
778
+ let shared_ptr = Arc::new(SendPtr(py_int));
779
+
780
+ let saved = api.save_thread();
781
+ let static_api = get_static_api().unwrap();
782
+
783
+ let barrier = Arc::new(Barrier::new(num_threads));
784
+ let mut handles = Vec::new();
785
+
786
+ for _ in 0..num_threads {
787
+ let barrier = Arc::clone(&barrier);
788
+ let shared_ptr = Arc::clone(&shared_ptr);
789
+ handles.push(thread::spawn(move || {
790
+ barrier.wait();
791
+
792
+ for _ in 0..wraps_per_thread {
793
+ let gil = static_api.ensure_gil();
794
+ let wrapper = RubyxObject::new(shared_ptr.0, static_api).unwrap();
795
+ assert_eq!(static_api.long_to_i64(wrapper.as_ptr()), 999);
796
+ drop(wrapper);
797
+ static_api.release_gil(gil);
798
+ }
799
+ }));
800
+ }
801
+
802
+ for h in handles {
803
+ h.join().expect("Worker thread panicked");
804
+ }
805
+
806
+ api.restore_thread(saved);
807
+
808
+ let val = api.long_to_i64(py_int);
809
+ assert_eq!(
810
+ val, 999,
811
+ "Object should survive concurrent wrap/drop cycles"
812
+ );
813
+
814
+ api.decref(py_int);
815
+ api.decref(py_int);
816
+ }
817
+
818
+ #[test]
819
+ #[serial]
820
+ fn test_drop_on_different_thread_than_create() {
821
+ use std::thread;
822
+
823
+ let Some(guard) = skip_if_no_python() else {
824
+ return;
825
+ };
826
+ let api = guard.api();
827
+
828
+ let py_ints: Vec<_> = (0..10)
829
+ .map(|i| {
830
+ let p = api.long_from_i64(i);
831
+ api.incref(p);
832
+ let wrapper = RubyxObject::new(p, api).unwrap();
833
+ (SendPtr(p), wrapper)
834
+ })
835
+ .collect();
836
+
837
+ let saved = api.save_thread();
838
+ let static_api = get_static_api().unwrap();
839
+
840
+ let mut handles = Vec::new();
841
+ for (send_ptr, wrapper) in py_ints.into_iter() {
842
+ handles.push(thread::spawn(move || {
843
+ let gil = static_api.ensure_gil();
844
+ drop(wrapper);
845
+ static_api.release_gil(gil);
846
+ send_ptr
847
+ }));
848
+ }
849
+
850
+ let returned_ptrs: Vec<_> = handles
851
+ .into_iter()
852
+ .map(|h| h.join().expect("Worker panicked"))
853
+ .collect();
854
+
855
+ let gil = static_api.ensure_gil();
856
+ for (i, send_ptr) in returned_ptrs.iter().enumerate() {
857
+ let val = static_api.long_to_i64(send_ptr.0);
858
+ assert_eq!(val, i as i64, "Object {i} should survive cross-thread drop");
859
+ static_api.decref(send_ptr.0);
860
+ }
861
+ static_api.release_gil(gil);
862
+
863
+ api.restore_thread(saved);
864
+ }
865
+
866
+ #[test]
867
+ #[serial]
868
+ fn test_shared_ref_across_threads() {
869
+ use std::sync::{Arc, Barrier};
870
+ use std::thread;
871
+
872
+ let Some(guard) = skip_if_no_python() else {
873
+ return;
874
+ };
875
+ let api = guard.api();
876
+
877
+ let py_int = api.long_from_i64(777);
878
+ // Arc<RubyxObject> exercises Sync — multiple threads hold &RubyxObject
879
+ let wrapper = Arc::new(RubyxObject::new(py_int, api).unwrap());
880
+
881
+ let num_threads = 4;
882
+ let reads_per_thread = 100;
883
+
884
+ let saved = api.save_thread();
885
+ let static_api = get_static_api().unwrap();
886
+
887
+ let barrier = Arc::new(Barrier::new(num_threads));
888
+ let mut handles = Vec::new();
889
+
890
+ for _ in 0..num_threads {
891
+ let wrapper = Arc::clone(&wrapper);
892
+ let barrier = Arc::clone(&barrier);
893
+ handles.push(thread::spawn(move || {
894
+ barrier.wait();
895
+ for _ in 0..reads_per_thread {
896
+ let gil = static_api.ensure_gil();
897
+ let val = static_api.long_to_i64(wrapper.as_ptr());
898
+ assert_eq!(val, 777, "Concurrent reads should be consistent");
899
+ static_api.release_gil(gil);
900
+ }
901
+ }));
902
+ }
903
+
904
+ for h in handles {
905
+ h.join().expect("Worker panicked");
906
+ }
907
+
908
+ api.restore_thread(saved);
909
+
910
+ drop(wrapper);
911
+ api.decref(py_int);
912
+ }
913
+
914
+ #[test]
915
+ #[serial]
916
+ fn test_concurrent_stress_mixed_operations() {
917
+ use std::sync::{Arc, Barrier};
918
+ use std::thread;
919
+
920
+ let Some(guard) = skip_if_no_python() else {
921
+ return;
922
+ };
923
+ let api = guard.api();
924
+
925
+ let num_threads = 8;
926
+ let iterations = 50;
927
+
928
+ let saved = api.save_thread();
929
+ let static_api = get_static_api().unwrap();
930
+
931
+ let barrier = Arc::new(Barrier::new(num_threads));
932
+ let mut handles = Vec::new();
933
+
934
+ for t in 0..num_threads {
935
+ let barrier = Arc::clone(&barrier);
936
+ handles.push(thread::spawn(move || {
937
+ barrier.wait();
938
+
939
+ let mut wrappers = Vec::new();
940
+
941
+ for i in 0..iterations {
942
+ let gil = static_api.ensure_gil();
943
+
944
+ let val = (t * 1000 + i) as i64;
945
+ let py_int = static_api.long_from_i64(val);
946
+ let w = RubyxObject::new(py_int, static_api).unwrap();
947
+ assert_eq!(static_api.long_to_i64(w.as_ptr()), val);
948
+ wrappers.push((py_int, w));
949
+
950
+ if wrappers.len() > 5 {
951
+ let (ptr, w) = wrappers.remove(0);
952
+ drop(w);
953
+ static_api.decref(ptr);
954
+ }
955
+
956
+ static_api.release_gil(gil);
957
+ }
958
+
959
+ let gil = static_api.ensure_gil();
960
+ for (ptr, w) in wrappers {
961
+ drop(w);
962
+ static_api.decref(ptr);
963
+ }
964
+ static_api.release_gil(gil);
965
+ }));
966
+ }
967
+
968
+ for h in handles {
969
+ h.join().expect("Worker panicked");
970
+ }
971
+
972
+ api.restore_thread(saved);
973
+ }
974
+
975
+ #[test]
976
+ #[serial]
977
+ fn test_gil_ensure_is_reentrant() {
978
+ let Some(guard) = skip_if_no_python() else {
979
+ return;
980
+ };
981
+ let api = guard.api();
982
+
983
+ // RubyxObject::new calls ensure_gil internally.
984
+ // The guard already holds the GIL, so this exercises reentrant acquire.
985
+ let py_int = api.long_from_i64(123);
986
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
987
+ assert_eq!(api.long_to_i64(wrapper.as_ptr()), 123);
988
+
989
+ let inner_gil = api.ensure_gil();
990
+ let py_int2 = api.long_from_i64(456);
991
+ let wrapper2 = RubyxObject::new(py_int2, api).unwrap();
992
+ assert_eq!(api.long_to_i64(wrapper2.as_ptr()), 456);
993
+ drop(wrapper2);
994
+ api.decref(py_int2);
995
+ api.release_gil(inner_gil);
996
+
997
+ drop(wrapper);
998
+ api.decref(py_int);
999
+ }
1000
+
1001
+ // ========== Import tests ==========
1002
+
1003
+ #[test]
1004
+ #[serial]
1005
+ fn test_import_builtin_module() {
1006
+ let Some(guard) = skip_if_no_python() else {
1007
+ return;
1008
+ };
1009
+ let api = guard.api();
1010
+
1011
+ // Import 'sys' — a builtin module that's always available
1012
+ let module = api.import_module("sys");
1013
+ assert!(module.is_ok(), "Should import 'sys' module");
1014
+ let module = module.unwrap();
1015
+ assert!(!module.is_null(), "sys module should be non-null");
1016
+
1017
+ // Wrap in RubyxObject to verify the full pipeline
1018
+ let wrapper = RubyxObject::new(module, api);
1019
+ assert!(wrapper.is_some(), "Should wrap imported module");
1020
+ let wrapper = wrapper.unwrap();
1021
+ assert_eq!(wrapper.as_ptr(), module);
1022
+
1023
+ drop(wrapper);
1024
+ api.decref(module);
1025
+ }
1026
+
1027
+ #[test]
1028
+ #[serial]
1029
+ fn test_import_nonexistent_raises() {
1030
+ let Some(guard) = skip_if_no_python() else {
1031
+ return;
1032
+ };
1033
+ let api = guard.api();
1034
+
1035
+ // Importing a non-existent module should fail
1036
+ let result = api.import_module("nonexistent_module_xyz_12345");
1037
+ assert!(result.is_err(), "Importing nonexistent module should fail");
1038
+
1039
+ // Python should have set an error (ModuleNotFoundError / ImportError)
1040
+ // import_module returns Err on null, but the error state may or may not
1041
+ // still be set depending on implementation. Clear to be safe.
1042
+ if api.has_error() {
1043
+ let exc = crate::python_api::PythonApi::extract_exception(api);
1044
+ assert!(exc.is_some(), "Should have a Python exception");
1045
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
1046
+ // Python 3.6+ raises ModuleNotFoundError (subclass of ImportError)
1047
+ assert!(
1048
+ kind == "ModuleNotFoundError" || kind == "ImportError",
1049
+ "Expected ModuleNotFoundError or ImportError, got: {}",
1050
+ kind
1051
+ );
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ #[test]
1057
+ #[serial]
1058
+ fn test_import_json_module_and_use() {
1059
+ let Some(guard) = skip_if_no_python() else {
1060
+ return;
1061
+ };
1062
+ let api = guard.api();
1063
+
1064
+ // json is a pure-Python module — always safe to import via libloading
1065
+ let module = api.import_module("json").expect("json should import");
1066
+ assert!(!module.is_null());
1067
+
1068
+ // Wrap in RubyxObject like rubyx_import does
1069
+ let wrapper = RubyxObject::new(module, api).expect("Should wrap json module");
1070
+ assert!(!wrapper.as_ptr().is_null());
1071
+
1072
+ drop(wrapper);
1073
+ api.decref(module);
1074
+ }
1075
+
1076
+ #[test]
1077
+ #[serial]
1078
+ fn test_import_os_module() {
1079
+ let Some(guard) = skip_if_no_python() else {
1080
+ return;
1081
+ };
1082
+ let api = guard.api();
1083
+
1084
+ let module = api.import_module("os").expect("os should import");
1085
+ assert!(!module.is_null());
1086
+
1087
+ let wrapper = RubyxObject::new(module, api).expect("Should wrap os module");
1088
+ drop(wrapper);
1089
+ api.decref(module);
1090
+ }
1091
+
1092
+ #[test]
1093
+ #[serial]
1094
+ fn test_import_same_module_twice() {
1095
+ let Some(guard) = skip_if_no_python() else {
1096
+ return;
1097
+ };
1098
+ let api = guard.api();
1099
+
1100
+ let module1 = api
1101
+ .import_module("sys")
1102
+ .expect("First sys import should work");
1103
+ let module2 = api
1104
+ .import_module("sys")
1105
+ .expect("Second sys import should work");
1106
+
1107
+ // Python caches modules — both should be the same object
1108
+ assert_eq!(
1109
+ module1, module2,
1110
+ "Importing the same module twice should return the same object"
1111
+ );
1112
+
1113
+ api.decref(module1);
1114
+ api.decref(module2);
1115
+ }
1116
+
1117
+ // ========== Eval tests ==========
1118
+ //
1119
+ // Same constraint as import: rubyx_eval() returns magnus::Value and uses
1120
+ // crate::api(), so we test the underlying operations directly.
1121
+ // We replicate what eval.rs does: make_globals → run_string → wrap result.
1122
+
1123
+ /// Py_eval_input = 258 (for expressions)
1124
+ const EVAL_INPUT: i64 = 258;
1125
+ /// Py_file_input = 257 (for statements)
1126
+ const FILE_INPUT: i64 = 257;
1127
+
1128
+ /// Helper: create globals dict with __builtins__ (mirrors eval::make_globals)
1129
+ fn test_make_globals(api: &crate::python_api::PythonApi) -> *mut crate::python_ffi::PyObject {
1130
+ let globals = api.dict_new();
1131
+ let builtins_key = api.string_from_str("__builtins__");
1132
+ let builtins = api
1133
+ .import_module("builtins")
1134
+ .expect("builtins should exist");
1135
+ api.dict_set_item(globals, builtins_key, builtins);
1136
+ api.decref(builtins_key);
1137
+ api.decref(builtins);
1138
+ globals
1139
+ }
1140
+
1141
+ #[test]
1142
+ #[serial]
1143
+ fn test_eval_expression() {
1144
+ let Some(guard) = skip_if_no_python() else {
1145
+ return;
1146
+ };
1147
+ let api = guard.api();
1148
+ let globals = test_make_globals(api);
1149
+
1150
+ // Evaluate "1 + 2" as expression
1151
+ let result = api.run_string("1 + 2", EVAL_INPUT, globals, globals);
1152
+ assert!(result.is_ok(), "run_string should succeed");
1153
+ let py_obj = result.unwrap();
1154
+ assert!(!py_obj.is_null(), "Expression result should be non-null");
1155
+ assert_eq!(api.long_to_i64(py_obj), 3, "1 + 2 should equal 3");
1156
+
1157
+ // Wrap in RubyxObject like rubyx_eval does
1158
+ let wrapper = RubyxObject::new(py_obj, api).expect("Should wrap eval result");
1159
+ assert_eq!(api.long_to_i64(wrapper.as_ptr()), 3);
1160
+
1161
+ drop(wrapper);
1162
+ api.decref(py_obj);
1163
+ api.decref(globals);
1164
+ }
1165
+
1166
+ #[test]
1167
+ #[serial]
1168
+ fn test_eval_string_expression() {
1169
+ let Some(guard) = skip_if_no_python() else {
1170
+ return;
1171
+ };
1172
+ let api = guard.api();
1173
+ let globals = test_make_globals(api);
1174
+
1175
+ let result = api.run_string("'hello' + ' ' + 'world'", EVAL_INPUT, globals, globals);
1176
+ let py_obj = result.unwrap();
1177
+ assert!(!py_obj.is_null());
1178
+ assert_eq!(
1179
+ api.string_to_string(py_obj),
1180
+ Some("hello world".to_string())
1181
+ );
1182
+
1183
+ let wrapper = RubyxObject::new(py_obj, api).expect("Should wrap string result");
1184
+ assert_eq!(
1185
+ api.string_to_string(wrapper.as_ptr()),
1186
+ Some("hello world".to_string())
1187
+ );
1188
+
1189
+ drop(wrapper);
1190
+ api.decref(py_obj);
1191
+ api.decref(globals);
1192
+ }
1193
+
1194
+ #[test]
1195
+ #[serial]
1196
+ fn test_eval_with_syntax_error() {
1197
+ let Some(guard) = skip_if_no_python() else {
1198
+ return;
1199
+ };
1200
+ let api = guard.api();
1201
+ let globals = test_make_globals(api);
1202
+
1203
+ // "def" alone is invalid as an expression (Py_eval_input)
1204
+ let result = api.run_string("def", EVAL_INPUT, globals, globals);
1205
+ let py_obj = result.unwrap();
1206
+ assert!(py_obj.is_null(), "Invalid expression should return null");
1207
+ assert!(api.has_error(), "Python error should be set");
1208
+
1209
+ // Verify it's a SyntaxError
1210
+ let exc = crate::python_api::PythonApi::extract_exception(api);
1211
+ assert!(exc.is_some(), "Should have extracted an exception");
1212
+ assert!(
1213
+ matches!(
1214
+ exc,
1215
+ Some(crate::exception::PythonException::SyntaxError { .. })
1216
+ ),
1217
+ "Expected SyntaxError, got: {:?}",
1218
+ exc
1219
+ );
1220
+
1221
+ api.decref(globals);
1222
+ }
1223
+
1224
+ #[test]
1225
+ #[serial]
1226
+ fn test_eval_syntax_error_then_retry_as_statement() {
1227
+ let Some(guard) = skip_if_no_python() else {
1228
+ return;
1229
+ };
1230
+ let api = guard.api();
1231
+ let globals = test_make_globals(api);
1232
+
1233
+ // "x = 42" is invalid as expression but valid as statement
1234
+ let result = api.run_string("x = 42", EVAL_INPUT, globals, globals);
1235
+ let py_obj = result.unwrap();
1236
+ assert!(
1237
+ py_obj.is_null(),
1238
+ "Assignment should fail as expression (eval_input)"
1239
+ );
1240
+
1241
+ // Check it's a syntax error
1242
+ let is_syntax = api.has_error() && {
1243
+ let exc = crate::python_api::PythonApi::extract_exception(api);
1244
+ matches!(
1245
+ exc,
1246
+ Some(crate::exception::PythonException::SyntaxError { .. })
1247
+ )
1248
+ };
1249
+ assert!(
1250
+ is_syntax,
1251
+ "Assignment should produce SyntaxError as expression"
1252
+ );
1253
+
1254
+ // Retry as statement (Py_file_input) — this is what rubyx_eval does
1255
+ let result = api.run_string("x = 42", FILE_INPUT, globals, globals);
1256
+ let stmt_obj = result.unwrap();
1257
+ assert!(
1258
+ !stmt_obj.is_null(),
1259
+ "Assignment should succeed as statement"
1260
+ );
1261
+ assert!(api.is_none(stmt_obj), "Statement should return Py_None");
1262
+
1263
+ // Verify the variable was set
1264
+ let key = api.string_from_str("x");
1265
+ let val = api.dict_get_item(globals, key);
1266
+ assert!(!val.is_null(), "x should exist in globals");
1267
+ assert_eq!(api.long_to_i64(val), 42, "x should be 42");
1268
+
1269
+ api.decref(key);
1270
+ api.decref(stmt_obj);
1271
+ api.decref(globals);
1272
+ }
1273
+
1274
+ #[test]
1275
+ #[serial]
1276
+ fn test_eval_name_error() {
1277
+ let Some(guard) = skip_if_no_python() else {
1278
+ return;
1279
+ };
1280
+ let api = guard.api();
1281
+ let globals = test_make_globals(api);
1282
+
1283
+ let result = api.run_string("undefined_variable_xyz", EVAL_INPUT, globals, globals);
1284
+ let py_obj = result.unwrap();
1285
+ assert!(py_obj.is_null(), "Undefined variable should return null");
1286
+ assert!(api.has_error(), "NameError should be set");
1287
+
1288
+ let exc = crate::python_api::PythonApi::extract_exception(api);
1289
+ assert!(exc.is_some());
1290
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
1291
+ assert_eq!(kind, "NameError", "Should be a NameError");
1292
+ } else {
1293
+ panic!("Expected Exception variant with NameError, got: {:?}", exc);
1294
+ }
1295
+
1296
+ api.decref(globals);
1297
+ }
1298
+
1299
+ #[test]
1300
+ #[serial]
1301
+ fn test_eval_division_by_zero() {
1302
+ let Some(guard) = skip_if_no_python() else {
1303
+ return;
1304
+ };
1305
+ let api = guard.api();
1306
+ let globals = test_make_globals(api);
1307
+
1308
+ let result = api.run_string("1 / 0", EVAL_INPUT, globals, globals);
1309
+ let py_obj = result.unwrap();
1310
+ assert!(py_obj.is_null(), "Division by zero should return null");
1311
+ assert!(api.has_error());
1312
+
1313
+ let exc = crate::python_api::PythonApi::extract_exception(api);
1314
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
1315
+ assert_eq!(kind, "ZeroDivisionError");
1316
+ } else {
1317
+ panic!("Expected ZeroDivisionError, got: {:?}", exc);
1318
+ }
1319
+
1320
+ api.decref(globals);
1321
+ }
1322
+
1323
+ #[test]
1324
+ #[serial]
1325
+ fn test_eval_builtin_function() {
1326
+ let Some(guard) = skip_if_no_python() else {
1327
+ return;
1328
+ };
1329
+ let api = guard.api();
1330
+ let globals = test_make_globals(api);
1331
+
1332
+ let result = api.run_string("len([1, 2, 3, 4, 5])", EVAL_INPUT, globals, globals);
1333
+ let py_obj = result.unwrap();
1334
+ assert!(!py_obj.is_null());
1335
+ assert_eq!(api.long_to_i64(py_obj), 5, "len([1,2,3,4,5]) should be 5");
1336
+
1337
+ api.decref(py_obj);
1338
+ api.decref(globals);
1339
+ }
1340
+
1341
+ #[test]
1342
+ #[serial]
1343
+ fn test_eval_list_expression() {
1344
+ let Some(guard) = skip_if_no_python() else {
1345
+ return;
1346
+ };
1347
+ let api = guard.api();
1348
+ let globals = test_make_globals(api);
1349
+
1350
+ let result = api.run_string("[x * 2 for x in range(5)]", EVAL_INPUT, globals, globals);
1351
+ let py_obj = result.unwrap();
1352
+ assert!(!py_obj.is_null());
1353
+ assert!(api.list_check(py_obj), "Should return a list");
1354
+ assert_eq!(api.list_size(py_obj), 5, "List should have 5 elements");
1355
+
1356
+ // Check values: [0, 2, 4, 6, 8]
1357
+ assert_eq!(api.long_to_i64(api.list_get_item(py_obj, 0)), 0);
1358
+ assert_eq!(api.long_to_i64(api.list_get_item(py_obj, 2)), 4);
1359
+ assert_eq!(api.long_to_i64(api.list_get_item(py_obj, 4)), 8);
1360
+
1361
+ api.decref(py_obj);
1362
+ api.decref(globals);
1363
+ }
1364
+
1365
+ #[test]
1366
+ #[serial]
1367
+ fn test_eval_dict_expression() {
1368
+ let Some(guard) = skip_if_no_python() else {
1369
+ return;
1370
+ };
1371
+ let api = guard.api();
1372
+ let globals = test_make_globals(api);
1373
+
1374
+ let result = api.run_string("{'a': 1, 'b': 2}", EVAL_INPUT, globals, globals);
1375
+ let py_obj = result.unwrap();
1376
+ assert!(!py_obj.is_null());
1377
+ assert!(api.dict_check(py_obj), "Should return a dict");
1378
+ assert_eq!(api.dict_size(py_obj), 2);
1379
+
1380
+ let key_a = api.string_from_str("a");
1381
+ let val_a = api.dict_get_item(py_obj, key_a);
1382
+ assert_eq!(api.long_to_i64(val_a), 1);
1383
+ api.decref(key_a);
1384
+
1385
+ api.decref(py_obj);
1386
+ api.decref(globals);
1387
+ }
1388
+
1389
+ #[test]
1390
+ #[serial]
1391
+ fn test_eval_none_result() {
1392
+ let Some(guard) = skip_if_no_python() else {
1393
+ return;
1394
+ };
1395
+ let api = guard.api();
1396
+ let globals = test_make_globals(api);
1397
+
1398
+ // print() returns None
1399
+ let result = api.run_string("print('hello from test')", FILE_INPUT, globals, globals);
1400
+ let py_obj = result.unwrap();
1401
+ assert!(!py_obj.is_null());
1402
+ assert!(api.is_none(py_obj), "Statement result should be Py_None");
1403
+
1404
+ api.decref(py_obj);
1405
+ api.decref(globals);
1406
+ }
1407
+
1408
+ #[test]
1409
+ #[serial]
1410
+ fn test_eval_bool_expression() {
1411
+ let Some(guard) = skip_if_no_python() else {
1412
+ return;
1413
+ };
1414
+ let api = guard.api();
1415
+ let globals = test_make_globals(api);
1416
+
1417
+ let result = api.run_string("10 > 5", EVAL_INPUT, globals, globals);
1418
+ let py_obj = result.unwrap();
1419
+ assert!(!py_obj.is_null());
1420
+ assert!(api.is_true(py_obj), "10 > 5 should be True");
1421
+
1422
+ let result2 = api.run_string("10 < 5", EVAL_INPUT, globals, globals);
1423
+ let py_obj2 = result2.unwrap();
1424
+ assert!(!py_obj2.is_null());
1425
+ assert!(api.is_false(py_obj2), "10 < 5 should be False");
1426
+
1427
+ api.decref(py_obj);
1428
+ api.decref(py_obj2);
1429
+ api.decref(globals);
1430
+ }
1431
+
1432
+ #[test]
1433
+ #[serial]
1434
+ fn test_eval_float_expression() {
1435
+ let Some(guard) = skip_if_no_python() else {
1436
+ return;
1437
+ };
1438
+ let api = guard.api();
1439
+ let globals = test_make_globals(api);
1440
+
1441
+ let result = api.run_string("3.14 * 2", EVAL_INPUT, globals, globals);
1442
+ let py_obj = result.unwrap();
1443
+ assert!(!py_obj.is_null());
1444
+ assert!(api.is_float(py_obj), "Should return a float");
1445
+ let value = api.float_to_f64(py_obj);
1446
+ assert!(
1447
+ (value - 6.28).abs() < 0.001,
1448
+ "3.14 * 2 should be ~6.28, got {}",
1449
+ value
1450
+ );
1451
+
1452
+ api.decref(py_obj);
1453
+ api.decref(globals);
1454
+ }
1455
+
1456
+ #[test]
1457
+ #[serial]
1458
+ fn test_eval_multiline_statement() {
1459
+ let Some(guard) = skip_if_no_python() else {
1460
+ return;
1461
+ };
1462
+ let api = guard.api();
1463
+ let globals = test_make_globals(api);
1464
+
1465
+ let code = "def factorial(n):\n return 1 if n <= 1 else n * factorial(n - 1)\nresult = factorial(5)";
1466
+ let result = api.run_string(code, FILE_INPUT, globals, globals);
1467
+ let py_obj = result.unwrap();
1468
+ assert!(!py_obj.is_null(), "Multiline statement should succeed");
1469
+
1470
+ let key = api.string_from_str("result");
1471
+ let val = api.dict_get_item(globals, key);
1472
+ assert!(!val.is_null(), "result should exist in globals");
1473
+ assert_eq!(api.long_to_i64(val), 120, "factorial(5) should be 120");
1474
+
1475
+ api.decref(key);
1476
+ api.decref(py_obj);
1477
+ api.decref(globals);
1478
+ }
1479
+
1480
+ #[test]
1481
+ #[serial]
1482
+ fn test_eval_with_import_in_expression() {
1483
+ let Some(guard) = skip_if_no_python() else {
1484
+ return;
1485
+ };
1486
+ let api = guard.api();
1487
+ let globals = test_make_globals(api);
1488
+
1489
+ // First set up the import as a statement
1490
+ let setup = api.run_string("import json", FILE_INPUT, globals, globals);
1491
+ assert!(!setup.unwrap().is_null());
1492
+
1493
+ // Then evaluate using the imported module
1494
+ let result = api.run_string(
1495
+ "json.loads('{\"key\": 42}')['key']",
1496
+ EVAL_INPUT,
1497
+ globals,
1498
+ globals,
1499
+ );
1500
+ let py_obj = result.unwrap();
1501
+ assert!(!py_obj.is_null());
1502
+ assert_eq!(api.long_to_i64(py_obj), 42);
1503
+
1504
+ api.decref(py_obj);
1505
+ api.decref(globals);
1506
+ }
1507
+
1508
+ #[test]
1509
+ #[serial]
1510
+ fn test_eval_result_wraps_in_rubyx_object() {
1511
+ let Some(guard) = skip_if_no_python() else {
1512
+ return;
1513
+ };
1514
+ let api = guard.api();
1515
+ let globals = test_make_globals(api);
1516
+
1517
+ let result = api.run_string("42 * 10", EVAL_INPUT, globals, globals);
1518
+ let py_obj = result.unwrap();
1519
+ assert!(!py_obj.is_null());
1520
+
1521
+ // This is what rubyx_eval does: wrap in RubyxObject
1522
+ let wrapper = RubyxObject::new(py_obj, api).expect("Should wrap result");
1523
+ assert_eq!(api.long_to_i64(wrapper.as_ptr()), 420);
1524
+
1525
+ // After wrapping, the original ref can be decref'd safely
1526
+ api.decref(py_obj);
1527
+
1528
+ // Wrapper still holds a valid reference
1529
+ assert_eq!(api.long_to_i64(wrapper.as_ptr()), 420);
1530
+
1531
+ drop(wrapper);
1532
+ api.decref(globals);
1533
+ }
1534
+
1535
+ #[test]
1536
+ #[serial]
1537
+ fn test_eval_error_does_not_leak_globals() {
1538
+ let Some(guard) = skip_if_no_python() else {
1539
+ return;
1540
+ };
1541
+ let api = guard.api();
1542
+
1543
+ // Run multiple eval-like cycles with errors — should not leak
1544
+ for _ in 0..100 {
1545
+ let globals = test_make_globals(api);
1546
+ let result = api.run_string("undefined_var", EVAL_INPUT, globals, globals);
1547
+ let py_obj = result.unwrap();
1548
+ assert!(py_obj.is_null());
1549
+ api.clear_error();
1550
+ api.decref(globals);
1551
+ }
1552
+
1553
+ // If we get here without crash, no memory corruption from repeated cycles
1554
+ }
1555
+
1556
+ #[test]
1557
+ #[serial]
1558
+ fn test_eval_sequential_expressions_share_globals() {
1559
+ let Some(guard) = skip_if_no_python() else {
1560
+ return;
1561
+ };
1562
+ let api = guard.api();
1563
+ let globals = test_make_globals(api);
1564
+
1565
+ // Set a variable via statement
1566
+ let r1 = api.run_string("x = 10", FILE_INPUT, globals, globals);
1567
+ assert!(!r1.unwrap().is_null());
1568
+
1569
+ // Use it in a subsequent expression
1570
+ let r2 = api.run_string("x * 5", EVAL_INPUT, globals, globals);
1571
+ let py_obj = r2.unwrap();
1572
+ assert!(!py_obj.is_null());
1573
+ assert_eq!(api.long_to_i64(py_obj), 50, "x * 5 should be 50");
1574
+
1575
+ api.decref(py_obj);
1576
+ api.decref(globals);
1577
+ }
1578
+
1579
+ #[test]
1580
+ #[serial]
1581
+ fn test_method_call() {
1582
+ use magnus::typed_data::Obj;
1583
+ use magnus::{IntoValue, TryConvert};
1584
+
1585
+ with_ruby_python(|ruby, api| {
1586
+ let json = api.import_module("json").expect("json should import");
1587
+ let wrapper = RubyxObject::new(json, api).expect("wrapper should be created");
1588
+
1589
+ let args = vec![
1590
+ "loads".into_value_with(ruby),
1591
+ r#"{"x": 42}"#.into_value_with(ruby),
1592
+ ];
1593
+ let result = wrapper
1594
+ .method_missing(&args)
1595
+ .expect("loads call should succeed");
1596
+ let py_result = Obj::<RubyxObject>::try_convert(result)
1597
+ .expect("result should be wrapped Python object");
1598
+ assert!(
1599
+ api.dict_check(py_result.as_ptr()),
1600
+ "json.loads should return a dict"
1601
+ );
1602
+
1603
+ let key = api.string_from_str("x");
1604
+ let val = api.dict_get_item(py_result.as_ptr(), key);
1605
+ assert_eq!(api.long_to_i64(val), 42);
1606
+ api.decref(key);
1607
+
1608
+ drop(wrapper);
1609
+ api.decref(json);
1610
+ });
1611
+ }
1612
+
1613
+ #[test]
1614
+ #[serial]
1615
+ fn test_attribute_get() {
1616
+ use magnus::typed_data::Obj;
1617
+ use magnus::{IntoValue, TryConvert};
1618
+
1619
+ with_ruby_python(|ruby, api| {
1620
+ let sys = api.import_module("sys").expect("sys should import");
1621
+ let wrapper = RubyxObject::new(sys, api).expect("wrapper should be created");
1622
+
1623
+ let args = vec!["version".into_value_with(ruby)];
1624
+ let result = wrapper
1625
+ .method_missing(&args)
1626
+ .expect("attribute read should succeed");
1627
+ let py_result = Obj::<RubyxObject>::try_convert(result)
1628
+ .expect("result should be wrapped Python object");
1629
+ assert!(
1630
+ api.is_string(py_result.as_ptr()),
1631
+ "sys.version should be a string"
1632
+ );
1633
+ let version = api
1634
+ .string_to_string(py_result.as_ptr())
1635
+ .expect("version should decode as string");
1636
+ assert!(!version.is_empty(), "version string should not be empty");
1637
+
1638
+ drop(wrapper);
1639
+ api.decref(sys);
1640
+ });
1641
+ }
1642
+
1643
+ #[test]
1644
+ #[serial]
1645
+ fn test_attribute_set() {
1646
+ use magnus::typed_data::Obj;
1647
+ use magnus::{IntoValue, TryConvert};
1648
+
1649
+ with_ruby_python(|ruby, api| {
1650
+ let types = api.import_module("types").expect("types should import");
1651
+ let wrapper = RubyxObject::new(types, api).expect("wrapper should be created");
1652
+
1653
+ // ns = types.SimpleNamespace()
1654
+ let args = vec!["SimpleNamespace".into_value_with(ruby)];
1655
+ let ns_result = wrapper
1656
+ .method_missing(&args)
1657
+ .expect("SimpleNamespace() should succeed");
1658
+ let ns = Obj::<RubyxObject>::try_convert(ns_result)
1659
+ .expect("result should be wrapped Python object");
1660
+
1661
+ // ns.foo = 99
1662
+ let ns_obj = RubyxObject::new(ns.as_ptr(), api).expect("rewrap should succeed");
1663
+ let set_args = vec!["foo=".into_value_with(ruby), 99_i64.into_value_with(ruby)];
1664
+ ns_obj
1665
+ .method_missing(&set_args)
1666
+ .expect("setter should succeed");
1667
+
1668
+ // ns.foo → 99
1669
+ let get_args = vec!["foo".into_value_with(ruby)];
1670
+ let get_result = ns_obj
1671
+ .method_missing(&get_args)
1672
+ .expect("getter should succeed");
1673
+ let py_val = Obj::<RubyxObject>::try_convert(get_result)
1674
+ .expect("result should be wrapped Python object");
1675
+ assert_eq!(api.long_to_i64(py_val.as_ptr()), 99);
1676
+
1677
+ drop(ns_obj);
1678
+ drop(wrapper);
1679
+ api.decref(types);
1680
+ });
1681
+ }
1682
+
1683
+ #[test]
1684
+ #[serial]
1685
+ fn test_keyword_arguments() {
1686
+ use magnus::typed_data::Obj;
1687
+ use magnus::{IntoValue, TryConvert};
1688
+
1689
+ with_ruby_python(|ruby, api| {
1690
+ let json = api.import_module("json").expect("json should import");
1691
+ let wrapper = RubyxObject::new(json, api).expect("wrapper should be created");
1692
+
1693
+ // Build a Python dict to serialize: {"a": 1}
1694
+ let py_dict = api.dict_new();
1695
+ let py_key = api.string_from_str("a");
1696
+ let py_val = api.long_from_i64(1);
1697
+ api.dict_set_item(py_dict, py_key, py_val);
1698
+ api.decref(py_key);
1699
+ api.decref(py_val);
1700
+ let dict_wrapper =
1701
+ RubyxObject::new(py_dict, api).expect("dict wrapper should be created");
1702
+ let dict_value = magnus::IntoValue::into_value_with(dict_wrapper, ruby);
1703
+
1704
+ // Build kwargs hash: { sort_keys: true }
1705
+ let kwargs = ruby.hash_new();
1706
+ let _ = kwargs.aset(ruby.to_symbol("sort_keys"), true.into_value_with(ruby));
1707
+
1708
+ let args = vec![
1709
+ "dumps".into_value_with(ruby),
1710
+ dict_value,
1711
+ kwargs.into_value_with(ruby),
1712
+ ];
1713
+ let result = wrapper
1714
+ .method_missing(&args)
1715
+ .expect("dumps with kwargs should succeed");
1716
+ let py_result = Obj::<RubyxObject>::try_convert(result)
1717
+ .expect("result should be wrapped Python object");
1718
+ assert!(
1719
+ api.is_string(py_result.as_ptr()),
1720
+ "json.dumps should return a string"
1721
+ );
1722
+ let json_str = api
1723
+ .string_to_string(py_result.as_ptr())
1724
+ .expect("result should decode as string");
1725
+ assert!(
1726
+ json_str.contains("\"a\""),
1727
+ "JSON should contain key 'a': {}",
1728
+ json_str
1729
+ );
1730
+
1731
+ drop(wrapper);
1732
+ api.decref(py_dict);
1733
+ api.decref(json);
1734
+ });
1735
+ }
1736
+ // ========== Async Streaming Integration Tests ==========
1737
+ //
1738
+ // GIL choreography: with_ruby_python holds both Ruby GVL + Python GIL.
1739
+ // AsyncStream::from_python_iterator spawns a worker that needs the GIL.
1740
+ // To avoid deadlock: save_thread() releases the GIL before consuming the
1741
+ // stream, then restore_thread() re-acquires it for cleanup.
1742
+
1743
+ const PY_EVAL_INPUT: i64 = 258;
1744
+ const PY_FILE_INPUT: i64 = 257;
1745
+
1746
+ use crate::python_api::PythonApi;
1747
+ use crate::python_ffi::PyObject;
1748
+
1749
+ fn make_globals(api: &PythonApi) -> *mut PyObject {
1750
+ let globals = api.dict_new();
1751
+ let builtins_key = api.string_from_str("__builtins__");
1752
+ let builtins = api
1753
+ .import_module("builtins")
1754
+ .expect("builtins should exist");
1755
+ api.dict_set_item(globals, builtins_key, builtins);
1756
+ api.decref(builtins_key);
1757
+ api.decref(builtins);
1758
+ globals
1759
+ }
1760
+
1761
+ #[test]
1762
+ #[serial]
1763
+ fn test_stream_python_generator() {
1764
+ with_ruby_python(|_ruby, api| {
1765
+ let globals = make_globals(api);
1766
+
1767
+ // Define a Python generator
1768
+ api.run_string(
1769
+ "def gen():\n yield 10\n yield 20\n yield 30\n",
1770
+ PY_FILE_INPUT,
1771
+ globals,
1772
+ globals,
1773
+ )
1774
+ .expect("should define generator");
1775
+
1776
+ let py_iter = api
1777
+ .run_string("gen()", PY_EVAL_INPUT, globals, globals)
1778
+ .expect("should create generator iterator");
1779
+ assert!(!py_iter.is_null());
1780
+
1781
+ // Release GIL so worker thread can acquire it
1782
+ let tstate = api.save_thread();
1783
+
1784
+ let mut stream = crate::stream::AsyncStream::from_python_iterator(py_iter);
1785
+
1786
+ let v1 = stream.next().unwrap().unwrap();
1787
+ assert_eq!(i64::try_convert(v1).unwrap(), 10_i64);
1788
+
1789
+ let v2 = stream.next().unwrap().unwrap();
1790
+ assert_eq!(i64::try_convert(v2).unwrap(), 20_i64);
1791
+
1792
+ let v3 = stream.next().unwrap().unwrap();
1793
+ assert_eq!(i64::try_convert(v3).unwrap(), 30_i64);
1794
+
1795
+ assert!(stream.next().is_none());
1796
+
1797
+ // Drop stream before re-acquiring GIL (join waits for worker to release GIL)
1798
+ drop(stream);
1799
+ api.restore_thread(tstate);
1800
+ api.decref(globals);
1801
+ });
1802
+ }
1803
+
1804
+ #[test]
1805
+ #[serial]
1806
+ fn test_stream_is_lazy() {
1807
+ with_ruby_python(|_ruby, api| {
1808
+ let globals = make_globals(api);
1809
+
1810
+ // Create a large range iterator — if not lazy, would try to materialize all items
1811
+ let py_iter = api
1812
+ .run_string("iter(range(1000000))", PY_EVAL_INPUT, globals, globals)
1813
+ .expect("should create range iterator");
1814
+
1815
+ let tstate = api.save_thread();
1816
+
1817
+ let mut stream = crate::stream::AsyncStream::from_python_iterator(py_iter);
1818
+
1819
+ // Only consume 5 items from a million-item stream
1820
+ for expected in 0..5_i64 {
1821
+ let val = stream.next().unwrap().unwrap();
1822
+ assert_eq!(i64::try_convert(val).unwrap(), expected);
1823
+ }
1824
+
1825
+ // Drop without consuming the rest — should not hang or OOM
1826
+ let start = std::time::Instant::now();
1827
+ drop(stream);
1828
+ let elapsed = start.elapsed();
1829
+
1830
+ api.restore_thread(tstate);
1831
+ api.decref(globals);
1832
+
1833
+ assert!(
1834
+ elapsed < std::time::Duration::from_secs(2),
1835
+ "dropping lazy stream should be fast, took {:?}",
1836
+ elapsed
1837
+ );
1838
+ });
1839
+ }
1840
+
1841
+ #[test]
1842
+ #[serial]
1843
+ fn test_stream_cancellation() {
1844
+ with_ruby_python(|_ruby, api| {
1845
+ let globals = make_globals(api);
1846
+
1847
+ // Infinite generator — only cancellation can stop it
1848
+ api.run_string(
1849
+ "def infinite():\n i = 0\n while True:\n yield i\n i += 1\n",
1850
+ PY_FILE_INPUT,
1851
+ globals,
1852
+ globals,
1853
+ )
1854
+ .expect("should define infinite generator");
1855
+
1856
+ let py_iter = api
1857
+ .run_string("infinite()", PY_EVAL_INPUT, globals, globals)
1858
+ .expect("should create infinite iterator");
1859
+
1860
+ let tstate = api.save_thread();
1861
+
1862
+ let mut stream = crate::stream::AsyncStream::from_python_iterator(py_iter);
1863
+
1864
+ // Read a few items
1865
+ for expected in 0..3_i64 {
1866
+ let val = stream.next().unwrap().unwrap();
1867
+ assert_eq!(i64::try_convert(val).unwrap(), expected);
1868
+ }
1869
+
1870
+ // Drop triggers cancellation — must not hang on an infinite generator
1871
+ let start = std::time::Instant::now();
1872
+ drop(stream);
1873
+ let elapsed = start.elapsed();
1874
+
1875
+ api.restore_thread(tstate);
1876
+ api.decref(globals);
1877
+
1878
+ assert!(
1879
+ elapsed < std::time::Duration::from_secs(2),
1880
+ "cancelling infinite stream should be fast, took {:?}",
1881
+ elapsed
1882
+ );
1883
+ });
1884
+ }
1885
+
1886
+ #[test]
1887
+ #[serial]
1888
+ fn test_concurrent_streams() {
1889
+ with_ruby_python(|_ruby, api| {
1890
+ let globals = make_globals(api);
1891
+
1892
+ // Create two separate iterators
1893
+ let py_iter1 = api
1894
+ .run_string("iter([100, 200, 300])", PY_EVAL_INPUT, globals, globals)
1895
+ .expect("should create first iterator");
1896
+
1897
+ let py_iter2 = api
1898
+ .run_string("iter([400, 500, 600])", PY_EVAL_INPUT, globals, globals)
1899
+ .expect("should create second iterator");
1900
+
1901
+ let tstate = api.save_thread();
1902
+
1903
+ let mut stream1 = crate::stream::AsyncStream::from_python_iterator(py_iter1);
1904
+ let mut stream2 = crate::stream::AsyncStream::from_python_iterator(py_iter2);
1905
+
1906
+ // Interleave consumption from both streams
1907
+ let v1a = stream1.next().unwrap().unwrap();
1908
+ assert_eq!(i64::try_convert(v1a).unwrap(), 100_i64);
1909
+
1910
+ let v2a = stream2.next().unwrap().unwrap();
1911
+ assert_eq!(i64::try_convert(v2a).unwrap(), 400_i64);
1912
+
1913
+ let v1b = stream1.next().unwrap().unwrap();
1914
+ assert_eq!(i64::try_convert(v1b).unwrap(), 200_i64);
1915
+
1916
+ let v2b = stream2.next().unwrap().unwrap();
1917
+ assert_eq!(i64::try_convert(v2b).unwrap(), 500_i64);
1918
+
1919
+ let v1c = stream1.next().unwrap().unwrap();
1920
+ assert_eq!(i64::try_convert(v1c).unwrap(), 300_i64);
1921
+
1922
+ let v2c = stream2.next().unwrap().unwrap();
1923
+ assert_eq!(i64::try_convert(v2c).unwrap(), 600_i64);
1924
+
1925
+ assert!(stream1.next().is_none());
1926
+ assert!(stream2.next().is_none());
1927
+
1928
+ drop(stream1);
1929
+ drop(stream2);
1930
+ api.restore_thread(tstate);
1931
+ api.decref(globals);
1932
+ });
1933
+ }
1934
+
1935
+ #[test]
1936
+ #[serial]
1937
+ fn test_stream_error_propagation() {
1938
+ with_ruby_python(|_ruby, api| {
1939
+ let globals = make_globals(api);
1940
+
1941
+ // Generator that raises after yielding some values
1942
+ api.run_string(
1943
+ "def error_gen():\n yield 1\n yield 2\n raise ValueError('test error')\n",
1944
+ PY_FILE_INPUT,
1945
+ globals,
1946
+ globals,
1947
+ )
1948
+ .expect("should define error generator");
1949
+
1950
+ let py_iter = api
1951
+ .run_string("error_gen()", PY_EVAL_INPUT, globals, globals)
1952
+ .expect("should create error generator");
1953
+
1954
+ let tstate = api.save_thread();
1955
+
1956
+ let mut stream = crate::stream::AsyncStream::from_python_iterator(py_iter);
1957
+
1958
+ // First two values succeed
1959
+ let v1 = stream.next().unwrap().unwrap();
1960
+ assert_eq!(i64::try_convert(v1).unwrap(), 1_i64);
1961
+
1962
+ let v2 = stream.next().unwrap().unwrap();
1963
+ assert_eq!(i64::try_convert(v2).unwrap(), 2_i64);
1964
+
1965
+ // Third call should propagate the error or end the stream
1966
+ // (depends on whether iter_next detects the ValueError)
1967
+ let v3 = stream.next();
1968
+ match v3 {
1969
+ Some(Err(err)) => {
1970
+ // Error propagated — good
1971
+ assert!(
1972
+ err.to_string().contains("Error") || err.to_string().contains("error"),
1973
+ "error should contain relevant message, got: {}",
1974
+ err
1975
+ );
1976
+ }
1977
+ None => {
1978
+ // Stream ended (PyIter_Next returned null with error set,
1979
+ // but error was cleared) — acceptable behavior
1980
+ }
1981
+ Some(Ok(_)) => {
1982
+ panic!("expected error or end-of-stream after ValueError, got a value");
1983
+ }
1984
+ }
1985
+
1986
+ drop(stream);
1987
+ api.restore_thread(tstate);
1988
+
1989
+ // Clear any lingering Python error
1990
+ if api.has_error() {
1991
+ api.clear_error();
1992
+ }
1993
+
1994
+ api.decref(globals);
1995
+ });
1996
+ }
1997
+
1998
+ // ========== AsyncGeneratorStream Integration Tests ==========
1999
+ //
2000
+ // These tests exercise AsyncGeneratorStream::from_python_object, which is
2001
+ // used by both Rubyx.stream() (PythonAdapter) and Rubyx.async_stream()
2002
+ // (RustDriving). They verify the full pipeline: Python object → detection
2003
+ // → strategy selection → Iterator → magnus::Value.
2004
+ //
2005
+ // GIL choreography: with_ruby_python holds the GIL via ensure_gil.
2006
+ // from_python_object also calls ensure_gil/release_gil internally (re-entrant).
2007
+ // IMPORTANT: call from_python_object WHILE GIL is held, then save_thread()
2008
+ // AFTER to release the GIL so the worker thread can proceed.
2009
+
2010
+ use crate::async_gen::{AsyncGeneratorStream, AsyncStrategy};
2011
+
2012
+ #[test]
2013
+ #[serial]
2014
+ fn test_async_gen_stream_sync_iterable_via_python_adapter() {
2015
+ with_ruby_python(|_ruby, api| {
2016
+ let globals = make_globals(api);
2017
+
2018
+ let py_obj = api
2019
+ .run_string("range(4)", PY_EVAL_INPUT, globals, globals)
2020
+ .expect("should create range object");
2021
+
2022
+ // Create stream while GIL is held (from_python_object is re-entrant)
2023
+ let mut stream =
2024
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter)
2025
+ .expect("should create stream from sync iterable");
2026
+
2027
+ // Release GIL so worker thread can acquire it
2028
+ let tstate = api.save_thread();
2029
+
2030
+ for expected in 0..4_i64 {
2031
+ let val = stream.next().unwrap().unwrap();
2032
+ assert_eq!(i64::try_convert(val).unwrap(), expected);
2033
+ }
2034
+ assert!(stream.next().is_none());
2035
+
2036
+ drop(stream);
2037
+ api.restore_thread(tstate);
2038
+ api.decref(globals);
2039
+ });
2040
+ }
2041
+
2042
+ #[test]
2043
+ #[serial]
2044
+ fn test_async_gen_stream_sync_generator_via_python_adapter() {
2045
+ with_ruby_python(|_ruby, api| {
2046
+ let globals = make_globals(api);
2047
+
2048
+ api.run_string(
2049
+ "def countdown(n):\n while n > 0:\n yield n\n n -= 1\n",
2050
+ PY_FILE_INPUT,
2051
+ globals,
2052
+ globals,
2053
+ )
2054
+ .expect("should define generator");
2055
+
2056
+ let py_obj = api
2057
+ .run_string("countdown(3)", PY_EVAL_INPUT, globals, globals)
2058
+ .expect("should create generator");
2059
+
2060
+ let mut stream =
2061
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter)
2062
+ .expect("should create stream from sync generator");
2063
+
2064
+ let tstate = api.save_thread();
2065
+
2066
+ let v1 = stream.next().unwrap().unwrap();
2067
+ assert_eq!(i64::try_convert(v1).unwrap(), 3_i64);
2068
+ let v2 = stream.next().unwrap().unwrap();
2069
+ assert_eq!(i64::try_convert(v2).unwrap(), 2_i64);
2070
+ let v3 = stream.next().unwrap().unwrap();
2071
+ assert_eq!(i64::try_convert(v3).unwrap(), 1_i64);
2072
+ assert!(stream.next().is_none());
2073
+
2074
+ drop(stream);
2075
+ api.restore_thread(tstate);
2076
+ api.decref(globals);
2077
+ });
2078
+ }
2079
+
2080
+ #[test]
2081
+ #[serial]
2082
+ fn test_async_gen_stream_async_generator_via_python_adapter() {
2083
+ with_ruby_python(|_ruby, api| {
2084
+ let globals = make_globals(api);
2085
+
2086
+ api.run_string(
2087
+ "async def async_range(n):\n for i in range(n):\n yield i\n",
2088
+ PY_FILE_INPUT,
2089
+ globals,
2090
+ globals,
2091
+ )
2092
+ .expect("should define async generator");
2093
+
2094
+ let py_obj = api
2095
+ .run_string("async_range(3)", PY_EVAL_INPUT, globals, globals)
2096
+ .expect("should create async generator instance");
2097
+
2098
+ // PythonAdapter wraps async gen → sync iter via AsyncToSync adapter
2099
+ let mut stream =
2100
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter)
2101
+ .expect("should create stream from async generator via adapter");
2102
+
2103
+ let tstate = api.save_thread();
2104
+
2105
+ for expected in 0..3_i64 {
2106
+ let val = stream.next().unwrap().unwrap();
2107
+ assert_eq!(i64::try_convert(val).unwrap(), expected);
2108
+ }
2109
+ assert!(stream.next().is_none());
2110
+
2111
+ drop(stream);
2112
+ api.restore_thread(tstate);
2113
+ api.decref(globals);
2114
+ });
2115
+ }
2116
+
2117
+ #[test]
2118
+ #[serial]
2119
+ fn test_async_gen_stream_async_generator_via_rust_driving() {
2120
+ with_ruby_python(|_ruby, api| {
2121
+ let globals = make_globals(api);
2122
+
2123
+ api.run_string(
2124
+ "async def async_squares(n):\n for i in range(n):\n yield i * i\n",
2125
+ PY_FILE_INPUT,
2126
+ globals,
2127
+ globals,
2128
+ )
2129
+ .expect("should define async generator");
2130
+
2131
+ let py_obj = api
2132
+ .run_string("async_squares(4)", PY_EVAL_INPUT, globals, globals)
2133
+ .expect("should create async generator instance");
2134
+
2135
+ // RustDriving uses Rust-side event loop to drive __anext__() coroutines
2136
+ let mut stream =
2137
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::RustDriving)
2138
+ .expect("should create stream from async generator via rust driving");
2139
+
2140
+ let tstate = api.save_thread();
2141
+
2142
+ let expected = [0_i64, 1, 4, 9];
2143
+ for exp in expected {
2144
+ let val = stream.next().unwrap().unwrap();
2145
+ assert_eq!(i64::try_convert(val).unwrap(), exp);
2146
+ }
2147
+ assert!(stream.next().is_none());
2148
+
2149
+ drop(stream);
2150
+ api.restore_thread(tstate);
2151
+ api.decref(globals);
2152
+ });
2153
+ }
2154
+
2155
+ #[test]
2156
+ #[serial]
2157
+ fn test_async_gen_stream_async_with_arguments() {
2158
+ with_ruby_python(|_ruby, api| {
2159
+ let globals = make_globals(api);
2160
+
2161
+ // Async generator that takes arguments
2162
+ api.run_string(
2163
+ "async def async_countdown(start, step=1):\n n = start\n while n > 0:\n yield n\n n -= step\n",
2164
+ PY_FILE_INPUT,
2165
+ globals,
2166
+ globals,
2167
+ )
2168
+ .expect("should define async generator with args");
2169
+
2170
+ // Call with arguments — the async gen is already instantiated
2171
+ let py_obj = api
2172
+ .run_string(
2173
+ "async_countdown(6, step=2)",
2174
+ PY_EVAL_INPUT,
2175
+ globals,
2176
+ globals,
2177
+ )
2178
+ .expect("should create async generator with args");
2179
+
2180
+ let mut stream =
2181
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter)
2182
+ .expect("should create stream from async generator with args");
2183
+
2184
+ let tstate = api.save_thread();
2185
+
2186
+ let expected = [6_i64, 4, 2];
2187
+ for exp in expected {
2188
+ let val = stream.next().unwrap().unwrap();
2189
+ assert_eq!(i64::try_convert(val).unwrap(), exp);
2190
+ }
2191
+ assert!(stream.next().is_none());
2192
+
2193
+ drop(stream);
2194
+ api.restore_thread(tstate);
2195
+ api.decref(globals);
2196
+ });
2197
+ }
2198
+
2199
+ #[test]
2200
+ #[serial]
2201
+ fn test_async_gen_stream_async_error_propagation() {
2202
+ with_ruby_python(|_ruby, api| {
2203
+ let globals = make_globals(api);
2204
+
2205
+ api.run_string(
2206
+ "async def async_error_gen():\n yield 1\n raise ValueError('async error')\n",
2207
+ PY_FILE_INPUT,
2208
+ globals,
2209
+ globals,
2210
+ )
2211
+ .expect("should define async error generator");
2212
+
2213
+ let py_obj = api
2214
+ .run_string("async_error_gen()", PY_EVAL_INPUT, globals, globals)
2215
+ .expect("should create async error generator");
2216
+
2217
+ let mut stream =
2218
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter)
2219
+ .expect("should create stream");
2220
+
2221
+ let tstate = api.save_thread();
2222
+
2223
+ // First value succeeds
2224
+ let v1 = stream.next().unwrap().unwrap();
2225
+ assert_eq!(i64::try_convert(v1).unwrap(), 1_i64);
2226
+
2227
+ // Second call should propagate the error
2228
+ let v2 = stream.next();
2229
+ match v2 {
2230
+ Some(Err(err)) => {
2231
+ assert!(
2232
+ err.to_string().contains("ValueError")
2233
+ || err.to_string().contains("async error"),
2234
+ "expected ValueError, got: {}",
2235
+ err
2236
+ );
2237
+ }
2238
+ None => {
2239
+ // Stream ended — acceptable if adapter swallows the error
2240
+ }
2241
+ Some(Ok(_)) => {
2242
+ panic!("expected error or end-of-stream after ValueError");
2243
+ }
2244
+ }
2245
+
2246
+ drop(stream);
2247
+ api.restore_thread(tstate);
2248
+
2249
+ if api.has_error() {
2250
+ api.clear_error();
2251
+ }
2252
+ api.decref(globals);
2253
+ });
2254
+ }
2255
+
2256
+ #[test]
2257
+ #[serial]
2258
+ fn test_async_gen_stream_rust_driving_error_propagation() {
2259
+ with_ruby_python(|_ruby, api| {
2260
+ let globals = make_globals(api);
2261
+
2262
+ api.run_string(
2263
+ "async def async_boom():\n yield 42\n raise RuntimeError('boom from async')\n",
2264
+ PY_FILE_INPUT,
2265
+ globals,
2266
+ globals,
2267
+ )
2268
+ .expect("should define async boom generator");
2269
+
2270
+ let py_obj = api
2271
+ .run_string("async_boom()", PY_EVAL_INPUT, globals, globals)
2272
+ .expect("should create async boom generator");
2273
+
2274
+ let mut stream =
2275
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::RustDriving)
2276
+ .expect("should create stream via rust driving");
2277
+
2278
+ let tstate = api.save_thread();
2279
+
2280
+ // First value succeeds
2281
+ let v1 = stream.next().unwrap().unwrap();
2282
+ assert_eq!(i64::try_convert(v1).unwrap(), 42_i64);
2283
+
2284
+ // Second call should propagate the error
2285
+ let v2 = stream.next();
2286
+ match v2 {
2287
+ Some(Err(err)) => {
2288
+ assert!(
2289
+ err.to_string().contains("RuntimeError")
2290
+ || err.to_string().contains("boom from async"),
2291
+ "expected RuntimeError, got: {}",
2292
+ err
2293
+ );
2294
+ }
2295
+ None => {
2296
+ // Stream ended — acceptable
2297
+ }
2298
+ Some(Ok(_)) => {
2299
+ panic!("expected error or end-of-stream after RuntimeError");
2300
+ }
2301
+ }
2302
+
2303
+ drop(stream);
2304
+ api.restore_thread(tstate);
2305
+
2306
+ if api.has_error() {
2307
+ api.clear_error();
2308
+ }
2309
+ api.decref(globals);
2310
+ });
2311
+ }
2312
+
2313
+ #[test]
2314
+ #[serial]
2315
+ fn test_async_gen_stream_non_iterable_returns_error() {
2316
+ with_ruby_python(|_ruby, api| {
2317
+ let globals = make_globals(api);
2318
+
2319
+ let py_obj = api
2320
+ .run_string("42", PY_EVAL_INPUT, globals, globals)
2321
+ .expect("should create integer");
2322
+
2323
+ // from_python_object handles GIL internally — no save_thread needed
2324
+ // since no worker thread is spawned on error
2325
+ let result =
2326
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter);
2327
+ match result {
2328
+ Err(msg) => {
2329
+ assert!(
2330
+ msg.contains("not iterable"),
2331
+ "error should mention 'not iterable', got: {msg}"
2332
+ );
2333
+ }
2334
+ Ok(_) => panic!("non-iterable should return error"),
2335
+ }
2336
+
2337
+ api.decref(globals);
2338
+ });
2339
+ }
2340
+
2341
+ #[test]
2342
+ #[serial]
2343
+ fn test_async_gen_stream_empty_async_generator() {
2344
+ with_ruby_python(|_ruby, api| {
2345
+ let globals = make_globals(api);
2346
+
2347
+ api.run_string(
2348
+ "async def async_empty():\n return\n yield\n",
2349
+ PY_FILE_INPUT,
2350
+ globals,
2351
+ globals,
2352
+ )
2353
+ .expect("should define empty async generator");
2354
+
2355
+ let py_obj = api
2356
+ .run_string("async_empty()", PY_EVAL_INPUT, globals, globals)
2357
+ .expect("should create empty async generator");
2358
+
2359
+ let mut stream =
2360
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter)
2361
+ .expect("should create stream from empty async gen");
2362
+
2363
+ let tstate = api.save_thread();
2364
+
2365
+ assert!(
2366
+ stream.next().is_none(),
2367
+ "empty async gen should yield nothing"
2368
+ );
2369
+
2370
+ drop(stream);
2371
+ api.restore_thread(tstate);
2372
+ api.decref(globals);
2373
+ });
2374
+ }
2375
+
2376
+ #[test]
2377
+ #[serial]
2378
+ fn test_async_gen_stream_cancellation_mid_iteration() {
2379
+ with_ruby_python(|_ruby, api| {
2380
+ let globals = make_globals(api);
2381
+
2382
+ // Async generator that yields many values
2383
+ api.run_string(
2384
+ "async def async_infinite():\n i = 0\n while True:\n yield i\n i += 1\n",
2385
+ PY_FILE_INPUT,
2386
+ globals,
2387
+ globals,
2388
+ )
2389
+ .expect("should define infinite async generator");
2390
+
2391
+ let py_obj = api
2392
+ .run_string("async_infinite()", PY_EVAL_INPUT, globals, globals)
2393
+ .expect("should create infinite async generator");
2394
+
2395
+ let mut stream =
2396
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter)
2397
+ .expect("should create stream");
2398
+
2399
+ let tstate = api.save_thread();
2400
+
2401
+ // Read a few items
2402
+ for expected in 0..3_i64 {
2403
+ let val = stream.next().unwrap().unwrap();
2404
+ assert_eq!(i64::try_convert(val).unwrap(), expected);
2405
+ }
2406
+
2407
+ // Drop should cancel cleanly without hanging
2408
+ let start = std::time::Instant::now();
2409
+ drop(stream);
2410
+ let elapsed = start.elapsed();
2411
+
2412
+ api.restore_thread(tstate);
2413
+ api.decref(globals);
2414
+
2415
+ assert!(
2416
+ elapsed < std::time::Duration::from_secs(2),
2417
+ "cancelling async generator should be fast, took {:?}",
2418
+ elapsed
2419
+ );
2420
+ });
2421
+ }
2422
+
2423
+ #[test]
2424
+ #[serial]
2425
+ fn test_async_gen_stream_yields_mixed_types() {
2426
+ with_ruby_python(|_ruby, api| {
2427
+ let globals = make_globals(api);
2428
+
2429
+ api.run_string(
2430
+ "async def async_mixed():\n yield 42\n yield 'hello'\n yield 3.14\n yield True\n yield None\n",
2431
+ PY_FILE_INPUT,
2432
+ globals,
2433
+ globals,
2434
+ )
2435
+ .expect("should define mixed-type async generator");
2436
+
2437
+ let py_obj = api
2438
+ .run_string("async_mixed()", PY_EVAL_INPUT, globals, globals)
2439
+ .expect("should create mixed-type async generator");
2440
+
2441
+ let mut stream =
2442
+ AsyncGeneratorStream::from_python_object(py_obj, AsyncStrategy::PythonAdapter)
2443
+ .expect("should create stream");
2444
+
2445
+ let tstate = api.save_thread();
2446
+
2447
+ let v1 = stream.next().unwrap().unwrap();
2448
+ assert_eq!(i64::try_convert(v1).unwrap(), 42);
2449
+
2450
+ let v2 = stream.next().unwrap().unwrap();
2451
+ assert_eq!(String::try_convert(v2).unwrap(), "hello");
2452
+
2453
+ let v3 = stream.next().unwrap().unwrap();
2454
+ assert!((f64::try_convert(v3).unwrap() - 3.14).abs() < 0.001);
2455
+
2456
+ let v4 = stream.next().unwrap().unwrap();
2457
+ assert!(bool::try_convert(v4).unwrap());
2458
+
2459
+ let v5 = stream.next().unwrap().unwrap();
2460
+ assert!(magnus::value::ReprValue::is_nil(v5));
2461
+
2462
+ assert!(stream.next().is_none());
2463
+
2464
+ drop(stream);
2465
+ api.restore_thread(tstate);
2466
+ api.decref(globals);
2467
+ });
2468
+ }
2469
+
2470
+ #[test]
2471
+ #[serial]
2472
+ fn test_inject_sys_paths_adds_paths() {
2473
+ with_ruby_python(|ruby, api| {
2474
+ let paths = ruby.ary_new();
2475
+ paths
2476
+ .push(ruby.str_new("/tmp/rubyx_inject_test_a"))
2477
+ .unwrap();
2478
+ paths
2479
+ .push(ruby.str_new("/tmp/rubyx_inject_test_b"))
2480
+ .unwrap();
2481
+
2482
+ super::inject_sys_paths(api, &paths).expect("inject_sys_paths should succeed");
2483
+
2484
+ // Verify paths were added
2485
+ let globals = test_make_globals(api);
2486
+ let result = api
2487
+ .run_string(
2488
+ "'/tmp/rubyx_inject_test_a' in __import__('sys').path",
2489
+ 258,
2490
+ globals,
2491
+ std::ptr::null_mut(),
2492
+ )
2493
+ .unwrap();
2494
+ assert!(api.is_true(result), "path a should be in sys.path");
2495
+ api.decref(result);
2496
+
2497
+ let result = api
2498
+ .run_string(
2499
+ "'/tmp/rubyx_inject_test_b' in __import__('sys').path",
2500
+ 258,
2501
+ globals,
2502
+ std::ptr::null_mut(),
2503
+ )
2504
+ .unwrap();
2505
+ assert!(api.is_true(result), "path b should be in sys.path");
2506
+ api.decref(result);
2507
+
2508
+ api.decref(globals);
2509
+ });
2510
+ }
2511
+
2512
+ #[test]
2513
+ #[serial]
2514
+ fn test_inject_sys_paths_empty_array() {
2515
+ with_ruby_python(|ruby, api| {
2516
+ let sys_module = api.import_module("sys").expect("should import sys");
2517
+ let sys_path = api.object_get_attr_string(sys_module, "path");
2518
+ let size_before = unsafe { (api.py_list_size)(sys_path) };
2519
+ api.decref(sys_path);
2520
+ api.decref(sys_module);
2521
+
2522
+ let paths = ruby.ary_new();
2523
+ super::inject_sys_paths(api, &paths).expect("empty inject should succeed");
2524
+
2525
+ let sys_module = api.import_module("sys").expect("should import sys");
2526
+ let sys_path = api.object_get_attr_string(sys_module, "path");
2527
+ let size_after = unsafe { (api.py_list_size)(sys_path) };
2528
+ api.decref(sys_path);
2529
+ api.decref(sys_module);
2530
+
2531
+ assert_eq!(
2532
+ size_before, size_after,
2533
+ "empty inject should not change sys.path"
2534
+ );
2535
+ });
2536
+ }
2537
+
2538
+ #[test]
2539
+ #[serial]
2540
+ fn test_inject_sys_paths_enables_local_module_import() {
2541
+ with_ruby_python(|ruby, api| {
2542
+ // Create a temp module
2543
+ let tmp_dir = std::env::temp_dir().join("rubyx_inject_import_test");
2544
+ std::fs::create_dir_all(&tmp_dir).unwrap();
2545
+ std::fs::write(
2546
+ tmp_dir.join("rubyx_inject_calc.py"),
2547
+ "RESULT = 100\ndef double(x): return x * 2\n",
2548
+ )
2549
+ .unwrap();
2550
+
2551
+ let paths = ruby.ary_new();
2552
+ paths.push(ruby.str_new(tmp_dir.to_str().unwrap())).unwrap();
2553
+ super::inject_sys_paths(api, &paths).expect("inject should succeed");
2554
+
2555
+ // Import the module
2556
+ let module = api
2557
+ .import_module("rubyx_inject_calc")
2558
+ .expect("should import module from injected path");
2559
+
2560
+ let result_attr = api.object_get_attr_string(module, "RESULT");
2561
+ assert!(!result_attr.is_null());
2562
+ assert_eq!(api.long_to_i64(result_attr), 100);
2563
+
2564
+ api.decref(result_attr);
2565
+ api.decref(module);
2566
+ let _ = std::fs::remove_dir_all(&tmp_dir);
2567
+ });
2568
+ }
2569
+
2570
+ #[test]
2571
+ #[serial]
2572
+ fn test_inject_sys_paths_preserves_existing() {
2573
+ with_ruby_python(|ruby, api| {
2574
+ // Get current sys.path size
2575
+ let sys_module = api.import_module("sys").expect("should import sys");
2576
+ let sys_path = api.object_get_attr_string(sys_module, "path");
2577
+ let size_before = unsafe { (api.py_list_size)(sys_path) };
2578
+ api.decref(sys_path);
2579
+ api.decref(sys_module);
2580
+
2581
+ // Inject 3 paths
2582
+ let paths = ruby.ary_new();
2583
+ paths.push(ruby.str_new("/tmp/p1")).unwrap();
2584
+ paths.push(ruby.str_new("/tmp/p2")).unwrap();
2585
+ paths.push(ruby.str_new("/tmp/p3")).unwrap();
2586
+ super::inject_sys_paths(api, &paths).unwrap();
2587
+
2588
+ let sys_module = api.import_module("sys").expect("should import sys");
2589
+ let sys_path = api.object_get_attr_string(sys_module, "path");
2590
+ let size_after = unsafe { (api.py_list_size)(sys_path) };
2591
+ api.decref(sys_path);
2592
+ api.decref(sys_module);
2593
+
2594
+ assert_eq!(
2595
+ size_after,
2596
+ size_before + 3,
2597
+ "sys.path should grow by exactly 3"
2598
+ );
2599
+ });
2600
+ }
2601
+
2602
+ #[test]
2603
+ #[serial]
2604
+ fn test_api_not_initialized_gives_clear_message() {
2605
+ // Skip if Python isn't available (the test harness couldn't find libpython)
2606
+ if crate::API.get().is_none() {
2607
+ println!("Skipping: Python not available");
2608
+ return;
2609
+ }
2610
+ // If we get here, API was initialized — verify it works
2611
+ let api = crate::api();
2612
+ assert!(api.is_initialized());
2613
+ }
2614
+
2615
+ #[test]
2616
+ #[serial]
2617
+ fn test_rubyx_initialized_returns_true_after_init() {
2618
+ if crate::API.get().is_none() {
2619
+ println!("Skipping: Python not available");
2620
+ return;
2621
+ }
2622
+ assert!(crate::rubyx_initialized());
2623
+ }
2624
+
2625
+ // ========== to_s tests ==========
2626
+
2627
+ #[test]
2628
+ #[serial]
2629
+ fn test_to_s_integer() {
2630
+ let Some(guard) = skip_if_no_python() else {
2631
+ return;
2632
+ };
2633
+ let api = guard.api();
2634
+
2635
+ let py_int = api.long_from_i64(42);
2636
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
2637
+ let result = wrapper.to_s().expect("to_s should succeed");
2638
+ assert_eq!(result, "42");
2639
+
2640
+ drop(wrapper);
2641
+ api.decref(py_int);
2642
+ }
2643
+
2644
+ #[test]
2645
+ #[serial]
2646
+ fn test_to_s_string() {
2647
+ let Some(guard) = skip_if_no_python() else {
2648
+ return;
2649
+ };
2650
+ let api = guard.api();
2651
+
2652
+ let py_str = api.string_from_str("hello world");
2653
+ let wrapper = RubyxObject::new(py_str, api).unwrap();
2654
+ let result = wrapper.to_s().expect("to_s should succeed");
2655
+ assert_eq!(result, "hello world");
2656
+
2657
+ drop(wrapper);
2658
+ api.decref(py_str);
2659
+ }
2660
+
2661
+ #[test]
2662
+ #[serial]
2663
+ fn test_to_s_float() {
2664
+ let Some(guard) = skip_if_no_python() else {
2665
+ return;
2666
+ };
2667
+ let api = guard.api();
2668
+
2669
+ let py_float = api.float_from_f64(3.14);
2670
+ let wrapper = RubyxObject::new(py_float, api).unwrap();
2671
+ let result = wrapper.to_s().expect("to_s should succeed");
2672
+ assert!(
2673
+ result.starts_with("3.14"),
2674
+ "to_s of 3.14 should start with '3.14', got: {result}"
2675
+ );
2676
+
2677
+ drop(wrapper);
2678
+ api.decref(py_float);
2679
+ }
2680
+
2681
+ #[test]
2682
+ #[serial]
2683
+ fn test_to_s_bool() {
2684
+ let Some(guard) = skip_if_no_python() else {
2685
+ return;
2686
+ };
2687
+ let api = guard.api();
2688
+
2689
+ let py_true = api.bool_from_i64(1);
2690
+ let wrapper = RubyxObject::new(py_true, api).unwrap();
2691
+ let result = wrapper.to_s().expect("to_s should succeed");
2692
+ assert_eq!(result, "True");
2693
+
2694
+ drop(wrapper);
2695
+ api.decref(py_true);
2696
+ }
2697
+
2698
+ #[test]
2699
+ #[serial]
2700
+ fn test_to_s_none() {
2701
+ let Some(guard) = skip_if_no_python() else {
2702
+ return;
2703
+ };
2704
+ let api = guard.api();
2705
+
2706
+ let py_none = api.py_none;
2707
+ api.incref(py_none);
2708
+ let wrapper = RubyxObject::new(py_none, api).unwrap();
2709
+ let result = wrapper.to_s().expect("to_s should succeed");
2710
+ assert_eq!(result, "None");
2711
+
2712
+ drop(wrapper);
2713
+ }
2714
+
2715
+ #[test]
2716
+ #[serial]
2717
+ fn test_to_s_list() {
2718
+ let Some(guard) = skip_if_no_python() else {
2719
+ return;
2720
+ };
2721
+ let api = guard.api();
2722
+
2723
+ let list = unsafe { (api.py_list_new)(3) };
2724
+ unsafe {
2725
+ (api.py_list_set_item)(list, 0, api.long_from_i64(1));
2726
+ (api.py_list_set_item)(list, 1, api.long_from_i64(2));
2727
+ (api.py_list_set_item)(list, 2, api.long_from_i64(3));
2728
+ }
2729
+ let wrapper = RubyxObject::new(list, api).unwrap();
2730
+ let result = wrapper.to_s().expect("to_s should succeed");
2731
+ assert_eq!(result, "[1, 2, 3]");
2732
+
2733
+ drop(wrapper);
2734
+ api.decref(list);
2735
+ }
2736
+
2737
+ // ========== inspect tests ==========
2738
+
2739
+ #[test]
2740
+ #[serial]
2741
+ fn test_inspect_integer() {
2742
+ let Some(guard) = skip_if_no_python() else {
2743
+ return;
2744
+ };
2745
+ let api = guard.api();
2746
+
2747
+ let py_int = api.long_from_i64(42);
2748
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
2749
+ let result = wrapper.inspect().expect("inspect should succeed");
2750
+ assert_eq!(result, "42");
2751
+
2752
+ drop(wrapper);
2753
+ api.decref(py_int);
2754
+ }
2755
+
2756
+ #[test]
2757
+ #[serial]
2758
+ fn test_inspect_string() {
2759
+ let Some(guard) = skip_if_no_python() else {
2760
+ return;
2761
+ };
2762
+ let api = guard.api();
2763
+
2764
+ let py_str = api.string_from_str("hello");
2765
+ let wrapper = RubyxObject::new(py_str, api).unwrap();
2766
+ let result = wrapper.inspect().expect("inspect should succeed");
2767
+ // Python repr of a string includes quotes
2768
+ assert_eq!(result, "'hello'");
2769
+
2770
+ drop(wrapper);
2771
+ api.decref(py_str);
2772
+ }
2773
+
2774
+ #[test]
2775
+ #[serial]
2776
+ fn test_inspect_none() {
2777
+ let Some(guard) = skip_if_no_python() else {
2778
+ return;
2779
+ };
2780
+ let api = guard.api();
2781
+
2782
+ let py_none = api.py_none;
2783
+ api.incref(py_none);
2784
+ let wrapper = RubyxObject::new(py_none, api).unwrap();
2785
+ let result = wrapper.inspect().expect("inspect should succeed");
2786
+ assert_eq!(result, "None");
2787
+
2788
+ drop(wrapper);
2789
+ }
2790
+
2791
+ #[test]
2792
+ #[serial]
2793
+ fn test_inspect_dict() {
2794
+ let Some(guard) = skip_if_no_python() else {
2795
+ return;
2796
+ };
2797
+ let api = guard.api();
2798
+
2799
+ let dict = api.dict_new();
2800
+ let key = api.string_from_str("x");
2801
+ let val = api.long_from_i64(1);
2802
+ api.dict_set_item(dict, key, val);
2803
+ api.decref(key);
2804
+ api.decref(val);
2805
+
2806
+ let wrapper = RubyxObject::new(dict, api).unwrap();
2807
+ let result = wrapper.inspect().expect("inspect should succeed");
2808
+ assert_eq!(result, "{'x': 1}");
2809
+
2810
+ drop(wrapper);
2811
+ api.decref(dict);
2812
+ }
2813
+
2814
+ // ========== to_ruby tests ==========
2815
+
2816
+ #[test]
2817
+ #[serial]
2818
+ fn test_to_ruby_integer() {
2819
+ with_ruby_python(|_ruby, api| {
2820
+ let py_int = api.long_from_i64(42);
2821
+ let wrapper = RubyxObject::new(py_int, api).unwrap();
2822
+ let result = wrapper.to_ruby().expect("to_ruby should succeed");
2823
+ let val = i64::try_convert(result).expect("should convert to i64");
2824
+ assert_eq!(val, 42);
2825
+
2826
+ drop(wrapper);
2827
+ api.decref(py_int);
2828
+ });
2829
+ }
2830
+
2831
+ #[test]
2832
+ #[serial]
2833
+ fn test_to_ruby_string() {
2834
+ with_ruby_python(|_ruby, api| {
2835
+ let py_str = api.string_from_str("hello");
2836
+ let wrapper = RubyxObject::new(py_str, api).unwrap();
2837
+ let result = wrapper.to_ruby().expect("to_ruby should succeed");
2838
+ let val = String::try_convert(result).expect("should convert to String");
2839
+ assert_eq!(val, "hello");
2840
+
2841
+ drop(wrapper);
2842
+ api.decref(py_str);
2843
+ });
2844
+ }
2845
+
2846
+ #[test]
2847
+ #[serial]
2848
+ fn test_to_ruby_float() {
2849
+ with_ruby_python(|_ruby, api| {
2850
+ let py_float = api.float_from_f64(3.14);
2851
+ let wrapper = RubyxObject::new(py_float, api).unwrap();
2852
+ let result = wrapper.to_ruby().expect("to_ruby should succeed");
2853
+ let val = f64::try_convert(result).expect("should convert to f64");
2854
+ assert!((val - 3.14).abs() < 0.001);
2855
+
2856
+ drop(wrapper);
2857
+ api.decref(py_float);
2858
+ });
2859
+ }
2860
+
2861
+ #[test]
2862
+ #[serial]
2863
+ fn test_to_ruby_bool_true() {
2864
+ with_ruby_python(|_ruby, api| {
2865
+ let py_true = api.bool_from_i64(1);
2866
+ let wrapper = RubyxObject::new(py_true, api).unwrap();
2867
+ let result = wrapper.to_ruby().expect("to_ruby should succeed");
2868
+ let val = bool::try_convert(result).expect("should convert to bool");
2869
+ assert!(val);
2870
+
2871
+ drop(wrapper);
2872
+ api.decref(py_true);
2873
+ });
2874
+ }
2875
+
2876
+ #[test]
2877
+ #[serial]
2878
+ fn test_to_ruby_bool_false() {
2879
+ with_ruby_python(|_ruby, api| {
2880
+ let py_false = api.bool_from_i64(0);
2881
+ let wrapper = RubyxObject::new(py_false, api).unwrap();
2882
+ let result = wrapper.to_ruby().expect("to_ruby should succeed");
2883
+ let val = bool::try_convert(result).expect("should convert to bool");
2884
+ assert!(!val);
2885
+
2886
+ drop(wrapper);
2887
+ api.decref(py_false);
2888
+ });
2889
+ }
2890
+
2891
+ #[test]
2892
+ #[serial]
2893
+ fn test_to_ruby_none_returns_nil() {
2894
+ with_ruby_python(|_ruby, api| {
2895
+ let py_none = api.py_none;
2896
+ api.incref(py_none);
2897
+ let wrapper = RubyxObject::new(py_none, api).unwrap();
2898
+ let result = wrapper.to_ruby().expect("to_ruby should succeed");
2899
+ assert!(
2900
+ magnus::value::ReprValue::is_nil(result),
2901
+ "Python None should convert to Ruby nil"
2902
+ );
2903
+
2904
+ drop(wrapper);
2905
+ });
2906
+ }
2907
+
2908
+ #[test]
2909
+ #[serial]
2910
+ fn test_to_ruby_list() {
2911
+ with_ruby_python(|_ruby, api| {
2912
+ let list = unsafe { (api.py_list_new)(3) };
2913
+ unsafe {
2914
+ (api.py_list_set_item)(list, 0, api.long_from_i64(10));
2915
+ (api.py_list_set_item)(list, 1, api.long_from_i64(20));
2916
+ (api.py_list_set_item)(list, 2, api.long_from_i64(30));
2917
+ }
2918
+ let wrapper = RubyxObject::new(list, api).unwrap();
2919
+ let result = wrapper.to_ruby().expect("to_ruby should succeed");
2920
+
2921
+ // Result should be a Ruby array
2922
+ let arr = magnus::RArray::try_convert(result).expect("should be an Array");
2923
+ assert_eq!(arr.len(), 3);
2924
+ assert_eq!(
2925
+ i64::try_convert(arr.entry::<magnus::Value>(0).unwrap()).unwrap(),
2926
+ 10
2927
+ );
2928
+ assert_eq!(
2929
+ i64::try_convert(arr.entry::<magnus::Value>(1).unwrap()).unwrap(),
2930
+ 20
2931
+ );
2932
+ assert_eq!(
2933
+ i64::try_convert(arr.entry::<magnus::Value>(2).unwrap()).unwrap(),
2934
+ 30
2935
+ );
2936
+
2937
+ drop(wrapper);
2938
+ api.decref(list);
2939
+ });
2940
+ }
2941
+
2942
+ #[test]
2943
+ #[serial]
2944
+ fn test_to_ruby_dict() {
2945
+ with_ruby_python(|_ruby, api| {
2946
+ let dict = api.dict_new();
2947
+ let key = api.string_from_str("name");
2948
+ let val = api.string_from_str("rubyx");
2949
+ api.dict_set_item(dict, key, val);
2950
+ api.decref(key);
2951
+ api.decref(val);
2952
+
2953
+ let wrapper = RubyxObject::new(dict, api).unwrap();
2954
+ let result = wrapper.to_ruby().expect("to_ruby should succeed");
2955
+
2956
+ // Result should be a Ruby hash
2957
+ let hash = magnus::RHash::try_convert(result).expect("should be a Hash");
2958
+ let name: String = hash
2959
+ .aref::<_, magnus::Value>("name")
2960
+ .and_then(|v| String::try_convert(v))
2961
+ .expect("should have 'name' key");
2962
+ assert_eq!(name, "rubyx");
2963
+
2964
+ drop(wrapper);
2965
+ api.decref(dict);
2966
+ });
2967
+ }
2968
+
2969
+ #[test]
2970
+ #[serial]
2971
+ fn test_to_ruby_unconvertible_returns_error() {
2972
+ with_ruby_python(|_ruby, api| {
2973
+ // Python module objects can't be converted to Ruby primitives
2974
+ let module = api.import_module("sys").expect("sys should import");
2975
+ let wrapper = RubyxObject::new(module, api).unwrap();
2976
+ let result = wrapper.to_ruby();
2977
+ assert!(result.is_err(), "module should not be convertible to Ruby");
2978
+
2979
+ drop(wrapper);
2980
+ api.decref(module);
2981
+ });
2982
+ }
2983
+
2984
+ // ========== to_s / inspect difference ==========
2985
+
2986
+ #[test]
2987
+ #[serial]
2988
+ fn test_to_s_vs_inspect_string() {
2989
+ let Some(guard) = skip_if_no_python() else {
2990
+ return;
2991
+ };
2992
+ let api = guard.api();
2993
+
2994
+ let py_str = api.string_from_str("hello");
2995
+ let wrapper = RubyxObject::new(py_str, api).unwrap();
2996
+
2997
+ // to_s returns the string value (Python str())
2998
+ let to_s_result = wrapper.to_s().expect("to_s should succeed");
2999
+ assert_eq!(to_s_result, "hello");
3000
+
3001
+ // inspect returns the repr (Python repr(), with quotes)
3002
+ let inspect_result = wrapper.inspect().expect("inspect should succeed");
3003
+ assert_eq!(inspect_result, "'hello'");
3004
+
3005
+ drop(wrapper);
3006
+ api.decref(py_str);
3007
+ }
3008
+
3009
+ // ========== Rubyx::Future / async_await tests ==========
3010
+
3011
+ /// Helper: define an async function in globals, create coroutine,
3012
+ /// release GIL, run future, restore GIL, return result.
3013
+ fn run_future_test(
3014
+ api: &'static PythonApi,
3015
+ func_def: &str,
3016
+ call_expr: &str,
3017
+ ) -> Result<magnus::Value, magnus::Error> {
3018
+ let globals = make_globals(api);
3019
+ api.run_string(func_def, PY_FILE_INPUT, globals, globals)
3020
+ .expect("should define async function");
3021
+
3022
+ let coroutine = api
3023
+ .run_string(call_expr, PY_EVAL_INPUT, globals, globals)
3024
+ .expect("should create coroutine");
3025
+
3026
+ let tstate = api.save_thread();
3027
+ let future = crate::future::RubyxFuture::from_coroutine(coroutine, api);
3028
+ let result = future.value();
3029
+ drop(future);
3030
+ api.restore_thread(tstate);
3031
+
3032
+ api.decref(coroutine);
3033
+ api.decref(globals);
3034
+ result
3035
+ }
3036
+
3037
+ #[test]
3038
+ #[serial]
3039
+ fn test_future_from_async_coroutine() {
3040
+ with_ruby_python(|_ruby, api| {
3041
+ let result = run_future_test(
3042
+ api,
3043
+ "import asyncio\nasync def simple(): return 42\n",
3044
+ "simple()",
3045
+ )
3046
+ .expect("future should resolve");
3047
+ assert_eq!(i64::try_convert(result).unwrap(), 42);
3048
+ });
3049
+ }
3050
+
3051
+ #[test]
3052
+ #[serial]
3053
+ fn test_future_from_async_returning_string() {
3054
+ with_ruby_python(|_ruby, api| {
3055
+ let result = run_future_test(
3056
+ api,
3057
+ "import asyncio\nasync def greet(): return 'hello async'\n",
3058
+ "greet()",
3059
+ )
3060
+ .expect("future should resolve");
3061
+ assert_eq!(String::try_convert(result).unwrap(), "hello async");
3062
+ });
3063
+ }
3064
+
3065
+ #[test]
3066
+ #[serial]
3067
+ fn test_future_from_async_returning_list() {
3068
+ with_ruby_python(|_ruby, api| {
3069
+ let result = run_future_test(
3070
+ api,
3071
+ "import asyncio\nasync def get_list(): return [10, 20, 30]\n",
3072
+ "get_list()",
3073
+ )
3074
+ .expect("future should resolve");
3075
+ let arr = magnus::RArray::try_convert(result).expect("should be array");
3076
+ assert_eq!(arr.len(), 3);
3077
+ assert_eq!(
3078
+ i64::try_convert(arr.entry::<magnus::Value>(0).unwrap()).unwrap(),
3079
+ 10
3080
+ );
3081
+ assert_eq!(
3082
+ i64::try_convert(arr.entry::<magnus::Value>(1).unwrap()).unwrap(),
3083
+ 20
3084
+ );
3085
+ assert_eq!(
3086
+ i64::try_convert(arr.entry::<magnus::Value>(2).unwrap()).unwrap(),
3087
+ 30
3088
+ );
3089
+ });
3090
+ }
3091
+
3092
+ #[test]
3093
+ #[serial]
3094
+ fn test_future_from_async_returning_none() {
3095
+ with_ruby_python(|_ruby, api| {
3096
+ let result = run_future_test(api, "import asyncio\nasync def noop(): pass\n", "noop()")
3097
+ .expect("future should resolve");
3098
+ assert!(
3099
+ magnus::value::ReprValue::is_nil(result),
3100
+ "None should become nil"
3101
+ );
3102
+ });
3103
+ }
3104
+
3105
+ #[test]
3106
+ #[serial]
3107
+ fn test_future_propagates_async_error() {
3108
+ with_ruby_python(|_ruby, api| {
3109
+ let result = run_future_test(
3110
+ api,
3111
+ "import asyncio\nasync def boom(): raise ValueError('async boom')\n",
3112
+ "boom()",
3113
+ );
3114
+ assert!(result.is_err(), "async error should propagate");
3115
+ let err_msg = format!("{}", result.unwrap_err());
3116
+ assert!(err_msg.contains("async boom"), "got: {}", err_msg);
3117
+ });
3118
+ }
3119
+
3120
+ #[test]
3121
+ #[serial]
3122
+ fn test_future_with_await_in_coroutine() {
3123
+ with_ruby_python(|_ruby, api| {
3124
+ let result = run_future_test(
3125
+ api,
3126
+ "import asyncio\nasync def chained():\n await asyncio.sleep(0.01)\n return 'done'\n",
3127
+ "chained()",
3128
+ )
3129
+ .expect("future should resolve");
3130
+ assert_eq!(String::try_convert(result).unwrap(), "done");
3131
+ });
3132
+ }
3133
+
3134
+ #[test]
3135
+ #[serial]
3136
+ fn test_future_sequential_multiple() {
3137
+ with_ruby_python(|_ruby, api| {
3138
+ let r1 = run_future_test(
3139
+ api,
3140
+ "import asyncio\nasync def add(a, b): return a + b\n",
3141
+ "add(1, 2)",
3142
+ )
3143
+ .expect("future1 should resolve");
3144
+ assert_eq!(i64::try_convert(r1).unwrap(), 3);
3145
+
3146
+ let r2 = run_future_test(
3147
+ api,
3148
+ "import asyncio\nasync def add2(a, b): return a + b\n",
3149
+ "add2(10, 20)",
3150
+ )
3151
+ .expect("future2 should resolve");
3152
+ assert_eq!(i64::try_convert(r2).unwrap(), 30);
3153
+ });
3154
+ }
3155
+
3156
+ #[test]
3157
+ #[serial]
3158
+ fn test_future_drop_joins_thread() {
3159
+ with_ruby_python(|_ruby, api| {
3160
+ let globals = make_globals(api);
3161
+ api.run_string(
3162
+ "import asyncio\nasync def slow(): await asyncio.sleep(0.05); return 1\n",
3163
+ PY_FILE_INPUT,
3164
+ globals,
3165
+ globals,
3166
+ )
3167
+ .expect("should define async function");
3168
+
3169
+ let coroutine = api
3170
+ .run_string("slow()", PY_EVAL_INPUT, globals, globals)
3171
+ .expect("should create coroutine");
3172
+
3173
+ let tstate = api.save_thread();
3174
+ let future = crate::future::RubyxFuture::from_coroutine(coroutine, api);
3175
+ drop(future); // Should not hang or crash
3176
+ api.restore_thread(tstate);
3177
+
3178
+ api.decref(coroutine);
3179
+ api.decref(globals);
3180
+ });
3181
+ }
3182
+
3183
+ #[test]
3184
+ #[serial]
3185
+ fn test_blocking_await_returns_rubyx_object() {
3186
+ with_ruby_python(|ruby, api| {
3187
+ let globals = make_globals(api);
3188
+ api.run_string(
3189
+ "import asyncio\nasync def get_val(): return 99\n",
3190
+ PY_FILE_INPUT,
3191
+ globals,
3192
+ globals,
3193
+ )
3194
+ .expect("should define async function");
3195
+
3196
+ let coroutine = api
3197
+ .run_string("get_val()", PY_EVAL_INPUT, globals, globals)
3198
+ .expect("should create coroutine");
3199
+
3200
+ let wrapper = RubyxObject::new(coroutine, api).expect("should wrap coroutine");
3201
+ let coro_value: magnus::Value = magnus::IntoValue::into_value_with(wrapper, ruby);
3202
+ let result =
3203
+ crate::eval::rubyx_await(coro_value).expect("blocking await should succeed");
3204
+
3205
+ let obj = magnus::typed_data::Obj::<RubyxObject>::try_convert(result)
3206
+ .expect("should be RubyxObject");
3207
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 99);
3208
+
3209
+ api.decref(globals);
3210
+ });
3211
+ }
3212
+
3213
+ #[test]
3214
+ #[serial]
3215
+ fn test_await_eval_with_globals() {
3216
+ with_ruby_python(|_ruby, api| {
3217
+ let globals = make_globals(api);
3218
+ api.run_string(
3219
+ "import asyncio\nasync def double(n): return n * 2\n",
3220
+ PY_FILE_INPUT,
3221
+ globals,
3222
+ globals,
3223
+ )
3224
+ .expect("should define async function");
3225
+
3226
+ let result = crate::eval::await_eval_with_globals("double(21)", globals, api)
3227
+ .expect("await_eval should succeed");
3228
+
3229
+ let obj = magnus::typed_data::Obj::<RubyxObject>::try_convert(result)
3230
+ .expect("should be RubyxObject");
3231
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 42);
3232
+
3233
+ api.decref(globals);
3234
+ });
3235
+ }
3236
+
3237
+ #[test]
3238
+ #[serial]
3239
+ fn test_await_eval_with_globals_error() {
3240
+ with_ruby_python(|_ruby, api| {
3241
+ let globals = make_globals(api);
3242
+ api.run_string(
3243
+ "import asyncio\nasync def fail(): raise RuntimeError('eval boom')\n",
3244
+ PY_FILE_INPUT,
3245
+ globals,
3246
+ globals,
3247
+ )
3248
+ .expect("should define async function");
3249
+
3250
+ let result = crate::eval::await_eval_with_globals("fail()", globals, api);
3251
+ assert!(result.is_err(), "should propagate error");
3252
+
3253
+ api.decref(globals);
3254
+ });
3255
+ }
3256
+
3257
+ // ========== eval_with_globals tests ==========
3258
+
3259
+ #[test]
3260
+ #[serial]
3261
+ fn test_eval_with_globals_simple_addition() {
3262
+ with_ruby_python(|ruby, api| {
3263
+ let globals = make_globals(api);
3264
+
3265
+ // Inject x=10 and y=20 into globals
3266
+ let py_x = crate::rubyx_object::ruby_to_python(10_i64.into_value_with(ruby), api)
3267
+ .expect("should convert x");
3268
+ let py_y = crate::rubyx_object::ruby_to_python(20_i64.into_value_with(ruby), api)
3269
+ .expect("should convert y");
3270
+ let key_x = api.string_from_str("x");
3271
+ let key_y = api.string_from_str("y");
3272
+ api.dict_set_item(globals, key_x, py_x);
3273
+ api.dict_set_item(globals, key_y, py_y);
3274
+ api.decref(key_x);
3275
+ api.decref(key_y);
3276
+ api.decref(py_x);
3277
+ api.decref(py_y);
3278
+
3279
+ let result =
3280
+ crate::eval::eval_with_globals("x + y", globals, api).expect("eval should succeed");
3281
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
3282
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 30);
3283
+
3284
+ api.decref(globals);
3285
+ });
3286
+ }
3287
+
3288
+ #[test]
3289
+ #[serial]
3290
+ fn test_eval_with_globals_string_interpolation() {
3291
+ with_ruby_python(|ruby, api| {
3292
+ let globals = make_globals(api);
3293
+
3294
+ let py_name = crate::rubyx_object::ruby_to_python("Alice".into_value_with(ruby), api)
3295
+ .expect("should convert name");
3296
+ let key = api.string_from_str("name");
3297
+ api.dict_set_item(globals, key, py_name);
3298
+ api.decref(key);
3299
+ api.decref(py_name);
3300
+
3301
+ let result = crate::eval::eval_with_globals("f'Hello, {name}!'", globals, api)
3302
+ .expect("eval should succeed");
3303
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
3304
+ assert_eq!(
3305
+ api.string_to_string(obj.as_ptr()),
3306
+ Some("Hello, Alice!".to_string())
3307
+ );
3308
+
3309
+ api.decref(globals);
3310
+ });
3311
+ }
3312
+
3313
+ #[test]
3314
+ #[serial]
3315
+ fn test_eval_with_globals_list_operations() {
3316
+ with_ruby_python(|ruby, api| {
3317
+ let globals = make_globals(api);
3318
+
3319
+ let arr = magnus::RArray::new();
3320
+ arr.push(1_i64.into_value_with(ruby)).unwrap();
3321
+ arr.push(2_i64.into_value_with(ruby)).unwrap();
3322
+ arr.push(3_i64.into_value_with(ruby)).unwrap();
3323
+ let py_items = crate::rubyx_object::ruby_to_python(arr.into_value_with(ruby), api)
3324
+ .expect("should convert list");
3325
+ let key = api.string_from_str("items");
3326
+ api.dict_set_item(globals, key, py_items);
3327
+ api.decref(key);
3328
+ api.decref(py_items);
3329
+
3330
+ let result = crate::eval::eval_with_globals("sum(items)", globals, api)
3331
+ .expect("eval should succeed");
3332
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
3333
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 6);
3334
+
3335
+ api.decref(globals);
3336
+ });
3337
+ }
3338
+
3339
+ #[test]
3340
+ #[serial]
3341
+ fn test_eval_with_globals_dict_access() {
3342
+ with_ruby_python(|ruby, api| {
3343
+ let globals = make_globals(api);
3344
+
3345
+ let hash = magnus::RHash::new();
3346
+ hash.aset(ruby.sym_new("a"), 100_i64.into_value_with(ruby))
3347
+ .unwrap();
3348
+ hash.aset(ruby.sym_new("b"), 200_i64.into_value_with(ruby))
3349
+ .unwrap();
3350
+ let py_dict = crate::rubyx_object::ruby_to_python(hash.into_value_with(ruby), api)
3351
+ .expect("should convert dict");
3352
+ let key = api.string_from_str("data");
3353
+ api.dict_set_item(globals, key, py_dict);
3354
+ api.decref(key);
3355
+ api.decref(py_dict);
3356
+
3357
+ let result = crate::eval::eval_with_globals("data['a'] + data['b']", globals, api)
3358
+ .expect("eval should succeed");
3359
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
3360
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 300);
3361
+
3362
+ api.decref(globals);
3363
+ });
3364
+ }
3365
+
3366
+ #[test]
3367
+ #[serial]
3368
+ fn test_eval_with_globals_undefined_variable_errors() {
3369
+ with_ruby_python(|_ruby, api| {
3370
+ let globals = make_globals(api);
3371
+
3372
+ let result = crate::eval::eval_with_globals("undefined_var", globals, api);
3373
+ assert!(result.is_err(), "should fail for undefined variable");
3374
+
3375
+ api.decref(globals);
3376
+ });
3377
+ }
3378
+
3379
+ #[test]
3380
+ #[serial]
3381
+ fn test_eval_with_globals_multiline_with_globals() {
3382
+ with_ruby_python(|ruby, api| {
3383
+ let globals = make_globals(api);
3384
+
3385
+ let py_n = crate::rubyx_object::ruby_to_python(5_i64.into_value_with(ruby), api)
3386
+ .expect("should convert n");
3387
+ let key = api.string_from_str("n");
3388
+ api.dict_set_item(globals, key, py_n);
3389
+ api.decref(key);
3390
+ api.decref(py_n);
3391
+
3392
+ let code = "result = 0\nfor i in range(n):\n result += i\nresult";
3393
+ let result = crate::eval::eval_with_globals(code, globals, api)
3394
+ .expect("multiline eval should succeed");
3395
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
3396
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 10); // 0+1+2+3+4 = 10
3397
+
3398
+ api.decref(globals);
3399
+ });
3400
+ }
3401
+
3402
+ // ========== await_with_globals tests ==========
3403
+
3404
+ #[test]
3405
+ #[serial]
3406
+ fn test_await_with_globals_simple() {
3407
+ with_ruby_python(|ruby, api| {
3408
+ let globals = make_globals(api);
3409
+
3410
+ // Define async function
3411
+ api.run_string(
3412
+ "import asyncio\nasync def multiply(a, b): return a * b\n",
3413
+ PY_FILE_INPUT,
3414
+ globals,
3415
+ globals,
3416
+ )
3417
+ .expect("should define async function");
3418
+
3419
+ // Inject globals
3420
+ let py_a = crate::rubyx_object::ruby_to_python(7_i64.into_value_with(ruby), api)
3421
+ .expect("should convert a");
3422
+ let py_b = crate::rubyx_object::ruby_to_python(6_i64.into_value_with(ruby), api)
3423
+ .expect("should convert b");
3424
+ let key_a = api.string_from_str("a");
3425
+ let key_b = api.string_from_str("b");
3426
+ api.dict_set_item(globals, key_a, py_a);
3427
+ api.dict_set_item(globals, key_b, py_b);
3428
+ api.decref(key_a);
3429
+ api.decref(key_b);
3430
+ api.decref(py_a);
3431
+ api.decref(py_b);
3432
+
3433
+ let result = crate::eval::await_eval_with_globals("multiply(a, b)", globals, api)
3434
+ .expect("await should succeed");
3435
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
3436
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 42);
3437
+
3438
+ api.decref(globals);
3439
+ });
3440
+ }
3441
+
3442
+ #[test]
3443
+ #[serial]
3444
+ fn test_await_with_globals_string_result() {
3445
+ with_ruby_python(|ruby, api| {
3446
+ let globals = make_globals(api);
3447
+
3448
+ api.run_string(
3449
+ "import asyncio\nasync def greet(who): return f'hi {who}'\n",
3450
+ PY_FILE_INPUT,
3451
+ globals,
3452
+ globals,
3453
+ )
3454
+ .expect("should define async function");
3455
+
3456
+ let py_who = crate::rubyx_object::ruby_to_python("world".into_value_with(ruby), api)
3457
+ .expect("should convert who");
3458
+ let key = api.string_from_str("who");
3459
+ api.dict_set_item(globals, key, py_who);
3460
+ api.decref(key);
3461
+ api.decref(py_who);
3462
+
3463
+ let result = crate::eval::await_eval_with_globals("greet(who)", globals, api)
3464
+ .expect("await should succeed");
3465
+ let obj = Obj::<RubyxObject>::try_convert(result).expect("should be RubyxObject");
3466
+ assert_eq!(
3467
+ api.string_to_string(obj.as_ptr()),
3468
+ Some("hi world".to_string())
3469
+ );
3470
+
3471
+ api.decref(globals);
3472
+ });
3473
+ }
3474
+
3475
+ #[test]
3476
+ #[serial]
3477
+ fn test_await_with_globals_error_propagation() {
3478
+ with_ruby_python(|ruby, api| {
3479
+ let globals = make_globals(api);
3480
+
3481
+ api.run_string(
3482
+ "import asyncio\nasync def check(val):\n if val < 0: raise ValueError('negative')\n return val\n",
3483
+ PY_FILE_INPUT,
3484
+ globals,
3485
+ globals,
3486
+ )
3487
+ .expect("should define async function");
3488
+
3489
+ let py_val = crate::rubyx_object::ruby_to_python((-1_i64).into_value_with(ruby), api)
3490
+ .expect("should convert val");
3491
+ let key = api.string_from_str("val");
3492
+ api.dict_set_item(globals, key, py_val);
3493
+ api.decref(key);
3494
+ api.decref(py_val);
3495
+
3496
+ let result = crate::eval::await_eval_with_globals("check(val)", globals, api);
3497
+ assert!(result.is_err(), "should propagate ValueError");
3498
+
3499
+ api.decref(globals);
3500
+ });
3501
+ }
3502
+
3503
+ // ========== async_await_with_globals tests ==========
3504
+
3505
+ #[test]
3506
+ #[serial]
3507
+ fn test_async_await_with_globals_future() {
3508
+ with_ruby_python(|ruby, api| {
3509
+ let globals = make_globals(api);
3510
+
3511
+ api.run_string(
3512
+ "import asyncio\nasync def add(x, y): return x + y\n",
3513
+ PY_FILE_INPUT,
3514
+ globals,
3515
+ globals,
3516
+ )
3517
+ .expect("should define async function");
3518
+
3519
+ let py_x = crate::rubyx_object::ruby_to_python(15_i64.into_value_with(ruby), api)
3520
+ .expect("should convert x");
3521
+ let py_y = crate::rubyx_object::ruby_to_python(27_i64.into_value_with(ruby), api)
3522
+ .expect("should convert y");
3523
+ let key_x = api.string_from_str("x");
3524
+ let key_y = api.string_from_str("y");
3525
+ api.dict_set_item(globals, key_x, py_x);
3526
+ api.dict_set_item(globals, key_y, py_y);
3527
+ api.decref(key_x);
3528
+ api.decref(key_y);
3529
+ api.decref(py_x);
3530
+ api.decref(py_y);
3531
+
3532
+ // Create coroutine
3533
+ let coroutine = api
3534
+ .run_string("add(x, y)", PY_EVAL_INPUT, globals, globals)
3535
+ .expect("should create coroutine");
3536
+
3537
+ // Release GIL so the background thread can acquire it
3538
+ let tstate = api.save_thread();
3539
+ let future = crate::future::RubyxFuture::from_coroutine(coroutine, api);
3540
+ let result = future.value().expect("future should resolve");
3541
+ drop(future);
3542
+ api.restore_thread(tstate);
3543
+
3544
+ assert_eq!(i64::try_convert(result).unwrap(), 42);
3545
+
3546
+ api.decref(coroutine);
3547
+ api.decref(globals);
3548
+ });
3549
+ }
3550
+
3551
+ #[test]
3552
+ #[serial]
3553
+ fn test_async_await_with_globals_future_string() {
3554
+ with_ruby_python(|ruby, api| {
3555
+ let globals = make_globals(api);
3556
+
3557
+ api.run_string(
3558
+ "import asyncio\nasync def fmt(prefix, val): return f'{prefix}: {val}'\n",
3559
+ PY_FILE_INPUT,
3560
+ globals,
3561
+ globals,
3562
+ )
3563
+ .expect("should define async function");
3564
+
3565
+ let py_prefix =
3566
+ crate::rubyx_object::ruby_to_python("result".into_value_with(ruby), api)
3567
+ .expect("should convert prefix");
3568
+ let py_val = crate::rubyx_object::ruby_to_python(99_i64.into_value_with(ruby), api)
3569
+ .expect("should convert val");
3570
+ let key_p = api.string_from_str("prefix");
3571
+ let key_v = api.string_from_str("val");
3572
+ api.dict_set_item(globals, key_p, py_prefix);
3573
+ api.dict_set_item(globals, key_v, py_val);
3574
+ api.decref(key_p);
3575
+ api.decref(key_v);
3576
+ api.decref(py_prefix);
3577
+ api.decref(py_val);
3578
+
3579
+ let coroutine = api
3580
+ .run_string("fmt(prefix, val)", PY_EVAL_INPUT, globals, globals)
3581
+ .expect("should create coroutine");
3582
+
3583
+ let tstate = api.save_thread();
3584
+ let future = crate::future::RubyxFuture::from_coroutine(coroutine, api);
3585
+ let result = future.value().expect("future should resolve");
3586
+ drop(future);
3587
+ api.restore_thread(tstate);
3588
+
3589
+ assert_eq!(String::try_convert(result).unwrap(), "result: 99");
3590
+
3591
+ api.decref(coroutine);
3592
+ api.decref(globals);
3593
+ });
3594
+ }
3595
+
3596
+ #[test]
3597
+ #[serial]
3598
+ fn test_async_await_with_globals_error() {
3599
+ with_ruby_python(|ruby, api| {
3600
+ let globals = make_globals(api);
3601
+
3602
+ api.run_string(
3603
+ "import asyncio\nasync def div(a, b): return a / b\n",
3604
+ PY_FILE_INPUT,
3605
+ globals,
3606
+ globals,
3607
+ )
3608
+ .expect("should define async function");
3609
+
3610
+ let py_a = crate::rubyx_object::ruby_to_python(10_i64.into_value_with(ruby), api)
3611
+ .expect("should convert a");
3612
+ let py_b = crate::rubyx_object::ruby_to_python(0_i64.into_value_with(ruby), api)
3613
+ .expect("should convert b");
3614
+ let key_a = api.string_from_str("a");
3615
+ let key_b = api.string_from_str("b");
3616
+ api.dict_set_item(globals, key_a, py_a);
3617
+ api.dict_set_item(globals, key_b, py_b);
3618
+ api.decref(key_a);
3619
+ api.decref(key_b);
3620
+ api.decref(py_a);
3621
+ api.decref(py_b);
3622
+
3623
+ let coroutine = api
3624
+ .run_string("div(a, b)", PY_EVAL_INPUT, globals, globals)
3625
+ .expect("should create coroutine");
3626
+
3627
+ let tstate = api.save_thread();
3628
+ let future = crate::future::RubyxFuture::from_coroutine(coroutine, api);
3629
+ let result = future.value();
3630
+ drop(future);
3631
+ api.restore_thread(tstate);
3632
+
3633
+ assert!(result.is_err(), "division by zero should propagate");
3634
+
3635
+ api.decref(coroutine);
3636
+ api.decref(globals);
3637
+ });
3638
+ }
3639
+
3640
+ // ========== GIL safety regression tests ==========
3641
+ // These tests ensure GIL is always released on error paths.
3642
+ // A leaked GIL would deadlock subsequent tests (serial execution).
3643
+
3644
+ #[test]
3645
+ #[serial]
3646
+ fn test_eval_with_globals_releases_gil_on_python_error() {
3647
+ with_ruby_python(|ruby, api| {
3648
+ let hash = magnus::RHash::new();
3649
+ hash.aset(ruby.sym_new("x"), 1_i64.into_value_with(ruby))
3650
+ .unwrap();
3651
+
3652
+ // This should fail (NameError: 'undefined_var') but NOT leak the GIL
3653
+ let result =
3654
+ crate::eval::rubyx_eval_with_globals("x + undefined_var".to_string(), hash);
3655
+ assert!(result.is_err(), "should fail for undefined variable");
3656
+
3657
+ // Prove GIL is released: we can acquire it again without deadlocking
3658
+ let gil = api.ensure_gil();
3659
+ let check = api.run_string(
3660
+ "1 + 1",
3661
+ EVAL_INPUT,
3662
+ test_make_globals(api),
3663
+ test_make_globals(api),
3664
+ );
3665
+ assert!(check.is_ok());
3666
+ api.decref(check.unwrap());
3667
+ api.release_gil(gil);
3668
+ });
3669
+ }
3670
+
3671
+ #[test]
3672
+ #[serial]
3673
+ fn test_eval_with_globals_releases_gil_on_syntax_error() {
3674
+ with_ruby_python(|ruby, api| {
3675
+ let hash = magnus::RHash::new();
3676
+ hash.aset(ruby.sym_new("x"), 1_i64.into_value_with(ruby))
3677
+ .unwrap();
3678
+
3679
+ let result = crate::eval::rubyx_eval_with_globals("def".to_string(), hash);
3680
+ assert!(result.is_err(), "should fail on syntax error");
3681
+
3682
+ // GIL should be released — acquire again to verify
3683
+ let gil = api.ensure_gil();
3684
+ let check = api.run_string(
3685
+ "2 + 2",
3686
+ EVAL_INPUT,
3687
+ test_make_globals(api),
3688
+ test_make_globals(api),
3689
+ );
3690
+ assert!(check.is_ok());
3691
+ api.decref(check.unwrap());
3692
+ api.release_gil(gil);
3693
+ });
3694
+ }
3695
+
3696
+ #[test]
3697
+ #[serial]
3698
+ fn test_await_with_globals_releases_gil_on_error() {
3699
+ with_ruby_python(|ruby, api| {
3700
+ let hash = magnus::RHash::new();
3701
+ hash.aset(ruby.sym_new("x"), 1_i64.into_value_with(ruby))
3702
+ .unwrap();
3703
+
3704
+ // Invalid code — should error but release GIL
3705
+ let result =
3706
+ crate::eval::rubyx_await_with_globals("undefined_coroutine()".to_string(), hash);
3707
+ assert!(result.is_err());
3708
+
3709
+ // Verify GIL is free
3710
+ let gil = api.ensure_gil();
3711
+ let check = api.run_string(
3712
+ "3 + 3",
3713
+ EVAL_INPUT,
3714
+ test_make_globals(api),
3715
+ test_make_globals(api),
3716
+ );
3717
+ assert!(check.is_ok());
3718
+ api.decref(check.unwrap());
3719
+ api.release_gil(gil);
3720
+ });
3721
+ }
3722
+
3723
+ #[test]
3724
+ #[serial]
3725
+ fn test_async_await_with_globals_releases_gil_on_error() {
3726
+ with_ruby_python(|ruby, api| {
3727
+ let hash = magnus::RHash::new();
3728
+ hash.aset(ruby.sym_new("x"), 1_i64.into_value_with(ruby))
3729
+ .unwrap();
3730
+
3731
+ // Invalid code — should error but release GIL
3732
+ let result =
3733
+ crate::eval::rubyx_async_await_with_globals("undefined_async()".to_string(), hash);
3734
+ assert!(result.is_err());
3735
+
3736
+ // Verify GIL is free
3737
+ let gil = api.ensure_gil();
3738
+ let check = api.run_string(
3739
+ "4 + 4",
3740
+ EVAL_INPUT,
3741
+ test_make_globals(api),
3742
+ test_make_globals(api),
3743
+ );
3744
+ assert!(check.is_ok());
3745
+ api.decref(check.unwrap());
3746
+ api.release_gil(gil);
3747
+ });
3748
+ }
3749
+
3750
+ #[test]
3751
+ #[serial]
3752
+ fn test_eval_with_globals_pyguard_drops_before_gil_release() {
3753
+ // Regression: PyGuard must drop (decref) BEFORE release_gil.
3754
+ // If this deadlocks or segfaults, the ordering is wrong.
3755
+ with_ruby_python(|ruby, _api| {
3756
+ for _ in 0..50 {
3757
+ let hash = magnus::RHash::new();
3758
+ hash.aset(ruby.sym_new("val"), 42_i64.into_value_with(ruby))
3759
+ .unwrap();
3760
+ let result = crate::eval::rubyx_eval_with_globals("val * 2".to_string(), hash);
3761
+ assert!(result.is_ok());
3762
+ }
3763
+ });
3764
+ }
3765
+
3766
+ #[test]
3767
+ #[serial]
3768
+ fn test_context_eval_with_globals_releases_gil_on_error() {
3769
+ with_ruby_python(|ruby, api| {
3770
+ let ctx = crate::context::RubyxContext::new().expect("context should create");
3771
+
3772
+ let hash = magnus::RHash::new();
3773
+ hash.aset(ruby.sym_new("x"), 1_i64.into_value_with(ruby))
3774
+ .unwrap();
3775
+
3776
+ let result = ctx.eval_with_globals("x + missing".to_string(), hash);
3777
+ assert!(result.is_err());
3778
+
3779
+ // Verify GIL is free
3780
+ let gil = api.ensure_gil();
3781
+ let check = api.run_string(
3782
+ "5 + 5",
3783
+ EVAL_INPUT,
3784
+ test_make_globals(api),
3785
+ test_make_globals(api),
3786
+ );
3787
+ assert!(check.is_ok());
3788
+ api.decref(check.unwrap());
3789
+ api.release_gil(gil);
3790
+ });
3791
+ }
3792
+
3793
+ #[test]
3794
+ #[serial]
3795
+ fn test_context_await_with_globals_releases_gil_on_error() {
3796
+ with_ruby_python(|ruby, api| {
3797
+ let ctx = crate::context::RubyxContext::new().expect("context should create");
3798
+
3799
+ let hash = magnus::RHash::new();
3800
+ hash.aset(ruby.sym_new("n"), 1_i64.into_value_with(ruby))
3801
+ .unwrap();
3802
+
3803
+ let result = ctx.await_eval_with_globals("no_such_coro()".to_string(), hash);
3804
+ assert!(result.is_err());
3805
+
3806
+ // Verify GIL is free
3807
+ let gil = api.ensure_gil();
3808
+ let check = api.run_string(
3809
+ "6 + 6",
3810
+ EVAL_INPUT,
3811
+ test_make_globals(api),
3812
+ test_make_globals(api),
3813
+ );
3814
+ assert!(check.is_ok());
3815
+ api.decref(check.unwrap());
3816
+ api.release_gil(gil);
3817
+ });
3818
+ }
3819
+
3820
+ #[test]
3821
+ #[serial]
3822
+ fn test_context_async_await_with_globals_releases_gil_on_error() {
3823
+ with_ruby_python(|ruby, api| {
3824
+ let ctx = crate::context::RubyxContext::new().expect("context should create");
3825
+
3826
+ let hash = magnus::RHash::new();
3827
+ hash.aset(ruby.sym_new("n"), 1_i64.into_value_with(ruby))
3828
+ .unwrap();
3829
+
3830
+ let result = ctx.async_await_eval_with_globals("no_such_coro()".to_string(), hash);
3831
+ assert!(result.is_err());
3832
+
3833
+ // Verify GIL is free
3834
+ let gil = api.ensure_gil();
3835
+ let check = api.run_string(
3836
+ "7 + 7",
3837
+ EVAL_INPUT,
3838
+ test_make_globals(api),
3839
+ test_make_globals(api),
3840
+ );
3841
+ assert!(check.is_ok());
3842
+ api.decref(check.unwrap());
3843
+ api.release_gil(gil);
3844
+ });
3845
+ }
3846
+
3847
+ #[test]
3848
+ #[serial]
3849
+ fn test_eval_with_globals_error_maps_to_rubyx_class() {
3850
+ // Regression: extract_exception was consumed on syntax check,
3851
+ // then re-fetched (returning None) → fell back to RuntimeError.
3852
+ with_ruby_python(|ruby, _api| {
3853
+ let hash = magnus::RHash::new();
3854
+ hash.aset(
3855
+ ruby.sym_new("d"),
3856
+ magnus::RHash::new().into_value_with(ruby),
3857
+ )
3858
+ .unwrap();
3859
+
3860
+ // Python KeyError should map to Rubyx::KeyError, not RuntimeError
3861
+ let result = crate::eval::rubyx_eval_with_globals("d['missing']".to_string(), hash);
3862
+ assert!(result.is_err());
3863
+ let err_msg = format!("{}", result.unwrap_err());
3864
+ assert!(
3865
+ err_msg.contains("KeyError"),
3866
+ "Expected KeyError in message, got: {}",
3867
+ err_msg
3868
+ );
3869
+ });
3870
+ }
3871
+
3872
+ // ========== error class mapping tests ==========
3873
+
3874
+ #[test]
3875
+ #[serial]
3876
+ fn test_error_mapping_key_error() {
3877
+ let Some(guard) = skip_if_no_python() else {
3878
+ return;
3879
+ };
3880
+ let api = guard.api();
3881
+ let globals = test_make_globals(api);
3882
+
3883
+ let result = api.run_string("{}['missing']", EVAL_INPUT, globals, globals);
3884
+ let py_obj = result.unwrap();
3885
+ assert!(py_obj.is_null());
3886
+ let exc = PythonApi::extract_exception(api);
3887
+ assert!(exc.is_some());
3888
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
3889
+ assert_eq!(kind, "KeyError");
3890
+ } else {
3891
+ panic!("Expected KeyError, got: {:?}", exc);
3892
+ }
3893
+ api.decref(globals);
3894
+ }
3895
+
3896
+ #[test]
3897
+ #[serial]
3898
+ fn test_error_mapping_index_error() {
3899
+ let Some(guard) = skip_if_no_python() else {
3900
+ return;
3901
+ };
3902
+ let api = guard.api();
3903
+ let globals = test_make_globals(api);
3904
+
3905
+ let result = api.run_string("[][5]", EVAL_INPUT, globals, globals);
3906
+ let py_obj = result.unwrap();
3907
+ assert!(py_obj.is_null());
3908
+ let exc = PythonApi::extract_exception(api);
3909
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
3910
+ assert_eq!(kind, "IndexError");
3911
+ } else {
3912
+ panic!("Expected IndexError, got: {:?}", exc);
3913
+ }
3914
+ api.decref(globals);
3915
+ }
3916
+
3917
+ #[test]
3918
+ #[serial]
3919
+ fn test_error_mapping_value_error() {
3920
+ let Some(guard) = skip_if_no_python() else {
3921
+ return;
3922
+ };
3923
+ let api = guard.api();
3924
+ let globals = test_make_globals(api);
3925
+
3926
+ let result = api.run_string("int('bad')", EVAL_INPUT, globals, globals);
3927
+ let py_obj = result.unwrap();
3928
+ assert!(py_obj.is_null());
3929
+ let exc = PythonApi::extract_exception(api);
3930
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
3931
+ assert_eq!(kind, "ValueError");
3932
+ } else {
3933
+ panic!("Expected ValueError, got: {:?}", exc);
3934
+ }
3935
+ api.decref(globals);
3936
+ }
3937
+
3938
+ #[test]
3939
+ #[serial]
3940
+ fn test_error_mapping_type_error() {
3941
+ let Some(guard) = skip_if_no_python() else {
3942
+ return;
3943
+ };
3944
+ let api = guard.api();
3945
+ let globals = test_make_globals(api);
3946
+
3947
+ let result = api.run_string("1 + 'a'", EVAL_INPUT, globals, globals);
3948
+ let py_obj = result.unwrap();
3949
+ assert!(py_obj.is_null());
3950
+ let exc = PythonApi::extract_exception(api);
3951
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
3952
+ assert_eq!(kind, "TypeError");
3953
+ } else {
3954
+ panic!("Expected TypeError, got: {:?}", exc);
3955
+ }
3956
+ api.decref(globals);
3957
+ }
3958
+
3959
+ #[test]
3960
+ #[serial]
3961
+ fn test_error_mapping_attribute_error() {
3962
+ let Some(guard) = skip_if_no_python() else {
3963
+ return;
3964
+ };
3965
+ let api = guard.api();
3966
+ let globals = test_make_globals(api);
3967
+
3968
+ let result = api.run_string("(1).nonexistent", EVAL_INPUT, globals, globals);
3969
+ let py_obj = result.unwrap();
3970
+ assert!(py_obj.is_null());
3971
+ let exc = PythonApi::extract_exception(api);
3972
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
3973
+ assert_eq!(kind, "AttributeError");
3974
+ } else {
3975
+ panic!("Expected AttributeError, got: {:?}", exc);
3976
+ }
3977
+ api.decref(globals);
3978
+ }
3979
+
3980
+ #[test]
3981
+ #[serial]
3982
+ fn test_error_mapping_zero_division_falls_to_python_error() {
3983
+ let Some(guard) = skip_if_no_python() else {
3984
+ return;
3985
+ };
3986
+ let api = guard.api();
3987
+ let globals = test_make_globals(api);
3988
+
3989
+ let result = api.run_string("1/0", EVAL_INPUT, globals, globals);
3990
+ let py_obj = result.unwrap();
3991
+ assert!(py_obj.is_null());
3992
+ let exc = PythonApi::extract_exception(api);
3993
+ if let Some(crate::exception::PythonException::Exception { kind, .. }) = &exc {
3994
+ assert_eq!(kind, "ZeroDivisionError");
3995
+ } else {
3996
+ panic!("Expected ZeroDivisionError, got: {:?}", exc);
3997
+ }
3998
+ api.decref(globals);
3999
+ }
4000
+
4001
+ #[test]
4002
+ #[serial]
4003
+ fn test_rubyx_exception_class_maps_known_kinds() {
4004
+ with_ruby_python(|ruby, _api| {
4005
+ use crate::ruby_helpers::rubyx_exception_class;
4006
+ use magnus::{Class, Module};
4007
+
4008
+ // Define Rubyx error classes (normally done by error.rb at gem load time).
4009
+ // define_class needs RClass, so we eval to create exception subclasses.
4010
+ ruby.eval::<magnus::Value>(
4011
+ "
4012
+ module Rubyx
4013
+ class Error < StandardError; end
4014
+ class PythonError < Error; end
4015
+ class KeyError < Error; end
4016
+ class IndexError < Error; end
4017
+ class ValueError < Error; end
4018
+ class TypeError < Error; end
4019
+ class AttributeError < Error; end
4020
+ class ImportError < PythonError; end
4021
+ end
4022
+ ",
4023
+ )
4024
+ .expect("should define error classes");
4025
+
4026
+ let key_err = rubyx_exception_class("KeyError");
4027
+ let idx_err = rubyx_exception_class("IndexError");
4028
+ let val_err = rubyx_exception_class("ValueError");
4029
+ let typ_err = rubyx_exception_class("TypeError");
4030
+ let attr_err = rubyx_exception_class("AttributeError");
4031
+ let imp_err = rubyx_exception_class("ImportError");
4032
+ let mnf_err = rubyx_exception_class("ModuleNotFoundError");
4033
+ let unknown = rubyx_exception_class("ZeroDivisionError");
4034
+
4035
+ let class_name =
4036
+ |c: magnus::ExceptionClass| -> String { unsafe { c.name().to_string() } };
4037
+
4038
+ assert_eq!(class_name(key_err), "Rubyx::KeyError");
4039
+ assert_eq!(class_name(idx_err), "Rubyx::IndexError");
4040
+ assert_eq!(class_name(val_err), "Rubyx::ValueError");
4041
+ assert_eq!(class_name(typ_err), "Rubyx::TypeError");
4042
+ assert_eq!(class_name(attr_err), "Rubyx::AttributeError");
4043
+ assert_eq!(class_name(imp_err), "Rubyx::ImportError");
4044
+ assert_eq!(class_name(mnf_err), "Rubyx::ImportError");
4045
+ assert_eq!(class_name(unknown), "Rubyx::PythonError");
4046
+ });
4047
+ }
4048
+
4049
+ // ========== respond_to_missing? tests ==========
4050
+
4051
+ #[test]
4052
+ #[serial]
4053
+ fn test_respond_to_missing_via_ruby() {
4054
+ with_ruby_python(|ruby, api| {
4055
+ let os = api.import_module("os").expect("os should import");
4056
+ let wrapper = RubyxObject::new(os, api).unwrap();
4057
+
4058
+ // Test with symbol (Ruby convention)
4059
+ let args = vec!["path".into_value_with(ruby)];
4060
+ assert!(
4061
+ wrapper.respond_to_missing(&args).unwrap(),
4062
+ "os.path should exist"
4063
+ );
4064
+
4065
+ // Test nonexistent
4066
+ let args = vec!["xyz_not_real".into_value_with(ruby)];
4067
+ assert!(
4068
+ !wrapper.respond_to_missing(&args).unwrap(),
4069
+ "nonexistent should be false"
4070
+ );
4071
+
4072
+ drop(wrapper);
4073
+ api.decref(os);
4074
+ });
4075
+ }
4076
+
4077
+ #[test]
4078
+ #[serial]
4079
+ fn test_implicit_conversion_guards_dont_delegate() {
4080
+ with_ruby_python(|ruby, api| {
4081
+ let py_list = unsafe { (api.py_list_new)(0) };
4082
+ let wrapper = RubyxObject::new(py_list, api).unwrap();
4083
+
4084
+ // All of these should raise NoMethodError, not delegate to Python
4085
+ for method in &[
4086
+ "to_ary", "to_str", "to_hash", "to_int", "to_float", "to_io", "to_proc",
4087
+ ] {
4088
+ let args = vec![(*method).into_value_with(ruby)];
4089
+ let result = wrapper.method_missing(&args);
4090
+ assert!(
4091
+ result.is_err(),
4092
+ "{} should be guarded, not delegated to Python",
4093
+ method
4094
+ );
4095
+ }
4096
+
4097
+ drop(wrapper);
4098
+ api.decref(py_list);
4099
+ });
4100
+ }
4101
+
4102
+ #[test]
4103
+ #[serial]
4104
+ fn test_respond_to_missing_on_module() {
4105
+ with_ruby_python(|ruby, api| {
4106
+ let json = api.import_module("json").expect("json should import");
4107
+ let wrapper = RubyxObject::new(json, api).unwrap();
4108
+
4109
+ // json.loads and json.dumps should exist
4110
+ assert!(wrapper
4111
+ .respond_to_missing(&["loads".into_value_with(ruby)])
4112
+ .unwrap());
4113
+ assert!(wrapper
4114
+ .respond_to_missing(&["dumps".into_value_with(ruby)])
4115
+ .unwrap());
4116
+
4117
+ // json.nonexistent should not
4118
+ assert!(!wrapper
4119
+ .respond_to_missing(&["nonexistent".into_value_with(ruby)])
4120
+ .unwrap());
4121
+
4122
+ drop(wrapper);
4123
+ api.decref(json);
4124
+ });
4125
+ }
4126
+
4127
+ // ========== getitem / setitem / delitem integration ==========
4128
+
4129
+ #[test]
4130
+ #[serial]
4131
+ fn test_getitem_setitem_roundtrip() {
4132
+ with_ruby_python(|ruby, api| {
4133
+ let globals = make_globals(api);
4134
+ let py_dict = api
4135
+ .run_string("{'x': 10}", 258, globals, globals)
4136
+ .expect("should create dict");
4137
+ let wrapper = RubyxObject::new(py_dict, api).unwrap();
4138
+
4139
+ // Read existing
4140
+ let key: magnus::Value = "x".into_value_with(ruby);
4141
+ let result = wrapper.getitem(key).expect("should read 'x'");
4142
+ let obj = magnus::typed_data::Obj::<RubyxObject>::try_convert(result).unwrap();
4143
+ assert_eq!(api.long_to_i64(obj.as_ptr()), 10);
4144
+
4145
+ // Write new
4146
+ let new_key: magnus::Value = "y".into_value_with(ruby);
4147
+ let new_val: magnus::Value = 20_i64.into_value_with(ruby);
4148
+ wrapper.setitem(new_key, new_val).expect("should set 'y'");
4149
+
4150
+ // Read back
4151
+ let check: magnus::Value = "y".into_value_with(ruby);
4152
+ let result2 = wrapper.getitem(check).expect("should read 'y'");
4153
+ let obj2 = magnus::typed_data::Obj::<RubyxObject>::try_convert(result2).unwrap();
4154
+ assert_eq!(api.long_to_i64(obj2.as_ptr()), 20);
4155
+
4156
+ drop(wrapper);
4157
+ api.decref(py_dict);
4158
+ api.decref(globals);
4159
+ });
4160
+ }
4161
+
4162
+ #[test]
4163
+ #[serial]
4164
+ fn test_delitem_then_getitem_fails() {
4165
+ with_ruby_python(|ruby, api| {
4166
+ let globals = make_globals(api);
4167
+ let py_dict = api
4168
+ .run_string("{'remove_me': 999}", 258, globals, globals)
4169
+ .expect("should create dict");
4170
+ let wrapper = RubyxObject::new(py_dict, api).unwrap();
4171
+
4172
+ let key: magnus::Value = "remove_me".into_value_with(ruby);
4173
+ wrapper.delitem(key).expect("should delete key");
4174
+
4175
+ let check: magnus::Value = "remove_me".into_value_with(ruby);
4176
+ assert!(
4177
+ wrapper.getitem(check).is_err(),
4178
+ "deleted key should not be found"
4179
+ );
4180
+
4181
+ drop(wrapper);
4182
+ api.decref(py_dict);
4183
+ api.decref(globals);
4184
+ });
4185
+ }
4186
+
4187
+ #[test]
4188
+ #[serial]
4189
+ fn test_getitem_list_integration() {
4190
+ with_ruby_python(|ruby, api| {
4191
+ let globals = make_globals(api);
4192
+ let py_list = api
4193
+ .run_string("['a', 'b', 'c']", 258, globals, globals)
4194
+ .expect("should create list");
4195
+ let wrapper = RubyxObject::new(py_list, api).unwrap();
4196
+
4197
+ for (i, expected) in ["a", "b", "c"].iter().enumerate() {
4198
+ let key: magnus::Value = (i as i64).into_value_with(ruby);
4199
+ let result = wrapper.getitem(key).expect("should read index");
4200
+ let obj = magnus::typed_data::Obj::<RubyxObject>::try_convert(result).unwrap();
4201
+ assert_eq!(
4202
+ api.string_to_string(obj.as_ptr()),
4203
+ Some(expected.to_string())
4204
+ );
4205
+ }
4206
+
4207
+ drop(wrapper);
4208
+ api.decref(py_list);
4209
+ api.decref(globals);
4210
+ });
4211
+ }
4212
+ }