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,950 @@
1
+ use crate::async_gen::AsyncGeneratorStream;
2
+ use crate::ruby_helpers::runtime_error;
3
+ use magnus::value::ReprValue;
4
+ use magnus::{Error, Ruby, Value};
5
+
6
+ #[magnus::wrap(class = "Rubyx::Stream", free_immediately)]
7
+ pub(crate) struct RubyxStream {
8
+ inner: std::cell::RefCell<Option<AsyncGeneratorStream>>,
9
+ }
10
+
11
+ impl RubyxStream {
12
+ pub fn each(&self) -> Result<Value, magnus::Error> {
13
+ let ruby = Ruby::get()
14
+ .map_err(|e| magnus::Error::new(runtime_error(), format!("Error getting Ruby: {e}")))?;
15
+
16
+ // Enumerator
17
+ if !ruby.block_given() {
18
+ let receiver: Value = ruby.current_receiver()?;
19
+ return Ok(receiver.enumeratorize("each", ()).as_value());
20
+ }
21
+
22
+ // Stream
23
+ // Take ownership of the stream so it gets dropped when `each` returns.
24
+ // This is critical: when Ruby's `first`/`take` break out of `each` early,
25
+ // the stream must be cleaned up immediately (cancel + join worker thread)
26
+ // rather than waiting for Ruby's GC. Otherwise the worker thread holds
27
+ // the Python GIL and subsequent Python calls deadlock.
28
+ let mut stream = self
29
+ .inner
30
+ .borrow_mut()
31
+ .take()
32
+ .ok_or_else(|| magnus::Error::new(runtime_error(), "Stream already consumed"))?;
33
+ for result in &mut stream {
34
+ match result {
35
+ Ok(val) => {
36
+ let _: Value = ruby.yield_value(val)?;
37
+ }
38
+ Err(err) => return Err(err),
39
+ }
40
+ }
41
+ Ok(ruby.qnil().as_value())
42
+ }
43
+
44
+ pub fn next_item(&self) -> Result<Value, Error> {
45
+ let ruby = Ruby::get()
46
+ .map_err(|e| magnus::Error::new(runtime_error(), format!("Error getting Ruby: {e}")))?;
47
+ let mut inner = self.inner.borrow_mut();
48
+ let stream = inner
49
+ .as_mut()
50
+ .ok_or_else(|| magnus::Error::new(runtime_error(), "Stream already consumed"))?;
51
+ match stream.next() {
52
+ Some(Ok(val)) => Ok(val),
53
+ Some(Err(err)) => Err(err),
54
+ None => {
55
+ // Stream exhausted — take it out so it gets dropped and cleaned up
56
+ inner.take();
57
+ Err(magnus::Error::new(
58
+ ruby.exception_stop_iteration(),
59
+ "iteration reached an end",
60
+ ))
61
+ }
62
+ }
63
+ }
64
+ pub fn from_stream(stream: AsyncGeneratorStream) -> Self {
65
+ Self {
66
+ inner: std::cell::RefCell::new(Some(stream)),
67
+ }
68
+ }
69
+ }
70
+
71
+ #[cfg(test)]
72
+ mod tests {
73
+ use crate::stream::SendableValue;
74
+ use crate::test_helpers::{skip_if_no_python, with_ruby_python};
75
+ use crossbeam_channel::{bounded, unbounded};
76
+ use magnus::value::ReprValue;
77
+ use magnus::TryConvert;
78
+ use serial_test::serial;
79
+ use std::thread;
80
+
81
+ // ========== SendableValue → magnus::Value conversion ==========
82
+
83
+ #[test]
84
+ #[serial]
85
+ fn test_sendable_nil_converts_to_ruby_nil() {
86
+ with_ruby_python(|_ruby, _api| {
87
+ let val: magnus::Value = SendableValue::Nil
88
+ .try_into()
89
+ .expect("Nil conversion should succeed");
90
+ assert!(
91
+ val.is_nil(),
92
+ "SendableValue::Nil should convert to Ruby nil"
93
+ );
94
+ });
95
+ }
96
+
97
+ #[test]
98
+ #[serial]
99
+ fn test_sendable_integer_converts_to_ruby_integer() {
100
+ with_ruby_python(|_ruby, _api| {
101
+ let val: magnus::Value = SendableValue::Integer(42)
102
+ .try_into()
103
+ .expect("Integer conversion should succeed");
104
+ let n = i64::try_convert(val).expect("should be convertible to i64");
105
+ assert_eq!(n, 42);
106
+ });
107
+ }
108
+
109
+ #[test]
110
+ #[serial]
111
+ fn test_sendable_integer_negative() {
112
+ with_ruby_python(|_ruby, _api| {
113
+ let val: magnus::Value = SendableValue::Integer(-99)
114
+ .try_into()
115
+ .expect("negative integer conversion should succeed");
116
+ let n = i64::try_convert(val).expect("should be convertible to i64");
117
+ assert_eq!(n, -99);
118
+ });
119
+ }
120
+
121
+ #[test]
122
+ #[serial]
123
+ fn test_sendable_integer_zero() {
124
+ with_ruby_python(|_ruby, _api| {
125
+ let val: magnus::Value = SendableValue::Integer(0)
126
+ .try_into()
127
+ .expect("zero conversion should succeed");
128
+ let n = i64::try_convert(val).expect("should be convertible to i64");
129
+ assert_eq!(n, 0);
130
+ });
131
+ }
132
+
133
+ #[test]
134
+ #[serial]
135
+ fn test_sendable_float_converts_to_ruby_float() {
136
+ with_ruby_python(|_ruby, _api| {
137
+ let val: magnus::Value = SendableValue::Float(std::f64::consts::PI)
138
+ .try_into()
139
+ .expect("Float conversion should succeed");
140
+ let f = f64::try_convert(val).expect("should be convertible to f64");
141
+ assert!((f - std::f64::consts::PI).abs() < 1e-9);
142
+ });
143
+ }
144
+
145
+ #[test]
146
+ #[serial]
147
+ fn test_sendable_float_negative() {
148
+ with_ruby_python(|_ruby, _api| {
149
+ let val: magnus::Value = SendableValue::Float(-0.5)
150
+ .try_into()
151
+ .expect("negative float conversion should succeed");
152
+ let f = f64::try_convert(val).expect("should be convertible to f64");
153
+ assert!((f - (-0.5)).abs() < 1e-9);
154
+ });
155
+ }
156
+
157
+ #[test]
158
+ #[serial]
159
+ fn test_sendable_string_converts_to_ruby_string() {
160
+ with_ruby_python(|_ruby, _api| {
161
+ let val: magnus::Value = SendableValue::Str("hello world".to_string())
162
+ .try_into()
163
+ .expect("String conversion should succeed");
164
+ let s = String::try_convert(val).expect("should be convertible to String");
165
+ assert_eq!(s, "hello world");
166
+ });
167
+ }
168
+
169
+ #[test]
170
+ #[serial]
171
+ fn test_sendable_string_empty() {
172
+ with_ruby_python(|_ruby, _api| {
173
+ let val: magnus::Value = SendableValue::Str(String::new())
174
+ .try_into()
175
+ .expect("empty string conversion should succeed");
176
+ let s = String::try_convert(val).expect("should be convertible to String");
177
+ assert_eq!(s, "");
178
+ });
179
+ }
180
+
181
+ #[test]
182
+ #[serial]
183
+ fn test_sendable_string_unicode() {
184
+ with_ruby_python(|_ruby, _api| {
185
+ let val: magnus::Value = SendableValue::Str("こんにちは🌍".to_string())
186
+ .try_into()
187
+ .expect("unicode string conversion should succeed");
188
+ let s = String::try_convert(val).expect("should be convertible to String");
189
+ assert_eq!(s, "こんにちは🌍");
190
+ });
191
+ }
192
+
193
+ #[test]
194
+ #[serial]
195
+ fn test_sendable_bool_true() {
196
+ with_ruby_python(|_ruby, _api| {
197
+ let val: magnus::Value = SendableValue::Bool(true)
198
+ .try_into()
199
+ .expect("Bool(true) conversion should succeed");
200
+ let b = bool::try_convert(val).expect("should be convertible to bool");
201
+ assert!(b);
202
+ });
203
+ }
204
+
205
+ #[test]
206
+ #[serial]
207
+ fn test_sendable_bool_false() {
208
+ with_ruby_python(|_ruby, _api| {
209
+ let val: magnus::Value = SendableValue::Bool(false)
210
+ .try_into()
211
+ .expect("Bool(false) conversion should succeed");
212
+ let b = bool::try_convert(val).expect("should be convertible to bool");
213
+ assert!(!b);
214
+ });
215
+ }
216
+
217
+ // ========== SendableValue::List → Ruby Array ==========
218
+
219
+ #[test]
220
+ #[serial]
221
+ fn test_sendable_list_converts_to_ruby_array() {
222
+ with_ruby_python(|_ruby, _api| {
223
+ let list = SendableValue::List(vec![
224
+ SendableValue::Integer(1),
225
+ SendableValue::Integer(2),
226
+ SendableValue::Integer(3),
227
+ ]);
228
+ let val: magnus::Value = list.try_into().expect("List conversion should succeed");
229
+
230
+ assert!(val.is_kind_of(_ruby.class_array()));
231
+ let arr = magnus::RArray::try_convert(val).expect("should be an Array");
232
+ assert_eq!(arr.len(), 3);
233
+
234
+ let items: Vec<i64> = (0..3)
235
+ .map(|i| i64::try_convert(arr.entry::<magnus::Value>(i).unwrap()).unwrap())
236
+ .collect();
237
+ assert_eq!(items, vec![1, 2, 3]);
238
+ });
239
+ }
240
+
241
+ #[test]
242
+ #[serial]
243
+ fn test_sendable_list_empty() {
244
+ with_ruby_python(|_ruby, _api| {
245
+ let list = SendableValue::List(vec![]);
246
+ let val: magnus::Value = list
247
+ .try_into()
248
+ .expect("empty list conversion should succeed");
249
+ let arr = magnus::RArray::try_convert(val).expect("should be an Array");
250
+ assert_eq!(arr.len(), 0);
251
+ });
252
+ }
253
+
254
+ #[test]
255
+ #[serial]
256
+ fn test_sendable_list_mixed_types() {
257
+ with_ruby_python(|_ruby, _api| {
258
+ let list = SendableValue::List(vec![
259
+ SendableValue::Integer(42),
260
+ SendableValue::Str("hello".to_string()),
261
+ SendableValue::Float(2.5),
262
+ SendableValue::Bool(true),
263
+ SendableValue::Nil,
264
+ ]);
265
+ let val: magnus::Value = list
266
+ .try_into()
267
+ .expect("mixed list conversion should succeed");
268
+
269
+ let arr = magnus::RArray::try_convert(val).expect("should be an Array");
270
+ assert_eq!(arr.len(), 5);
271
+
272
+ let v0 = arr.entry::<magnus::Value>(0).unwrap();
273
+ let v1 = arr.entry::<magnus::Value>(1).unwrap();
274
+ let v2 = arr.entry::<magnus::Value>(2).unwrap();
275
+ let v3 = arr.entry::<magnus::Value>(3).unwrap();
276
+ let v4 = arr.entry::<magnus::Value>(4).unwrap();
277
+
278
+ assert_eq!(i64::try_convert(v0).unwrap(), 42);
279
+ assert_eq!(String::try_convert(v1).unwrap(), "hello");
280
+ assert!((f64::try_convert(v2).unwrap() - 2.5).abs() < 1e-9);
281
+ assert!(bool::try_convert(v3).unwrap());
282
+ assert!(v4.is_nil());
283
+ });
284
+ }
285
+
286
+ #[test]
287
+ #[serial]
288
+ fn test_sendable_list_nested() {
289
+ with_ruby_python(|_ruby, _api| {
290
+ let nested = SendableValue::List(vec![
291
+ SendableValue::List(vec![SendableValue::Integer(1), SendableValue::Integer(2)]),
292
+ SendableValue::List(vec![SendableValue::Integer(3), SendableValue::Integer(4)]),
293
+ ]);
294
+ let val: magnus::Value = nested
295
+ .try_into()
296
+ .expect("nested list conversion should succeed");
297
+
298
+ let outer = magnus::RArray::try_convert(val).expect("should be an Array");
299
+ assert_eq!(outer.len(), 2);
300
+
301
+ let inner0 = magnus::RArray::try_convert(outer.entry::<magnus::Value>(0).unwrap())
302
+ .expect("inner should be an Array");
303
+ assert_eq!(inner0.len(), 2);
304
+ assert_eq!(
305
+ i64::try_convert(inner0.entry::<magnus::Value>(0).unwrap()).unwrap(),
306
+ 1
307
+ );
308
+ assert_eq!(
309
+ i64::try_convert(inner0.entry::<magnus::Value>(1).unwrap()).unwrap(),
310
+ 2
311
+ );
312
+
313
+ let inner1 = magnus::RArray::try_convert(outer.entry::<magnus::Value>(1).unwrap())
314
+ .expect("inner should be an Array");
315
+ assert_eq!(
316
+ i64::try_convert(inner1.entry::<magnus::Value>(0).unwrap()).unwrap(),
317
+ 3
318
+ );
319
+ assert_eq!(
320
+ i64::try_convert(inner1.entry::<magnus::Value>(1).unwrap()).unwrap(),
321
+ 4
322
+ );
323
+ });
324
+ }
325
+
326
+ // ========== SendableValue::Dict → Ruby Hash ==========
327
+
328
+ #[test]
329
+ #[serial]
330
+ fn test_sendable_dict_converts_to_ruby_hash() {
331
+ with_ruby_python(|_ruby, _api| {
332
+ let dict = SendableValue::Dict(vec![
333
+ (
334
+ SendableValue::Str("name".to_string()),
335
+ SendableValue::Str("Alice".to_string()),
336
+ ),
337
+ (
338
+ SendableValue::Str("age".to_string()),
339
+ SendableValue::Integer(30),
340
+ ),
341
+ ]);
342
+ let val: magnus::Value = dict.try_into().expect("Dict conversion should succeed");
343
+
344
+ assert!(val.is_kind_of(_ruby.class_hash()));
345
+ let hash = magnus::RHash::try_convert(val).expect("should be a Hash");
346
+
347
+ let name: String = hash.fetch("name").expect("should have 'name' key");
348
+ assert_eq!(name, "Alice");
349
+ let age: i64 = hash.fetch("age").expect("should have 'age' key");
350
+ assert_eq!(age, 30);
351
+ });
352
+ }
353
+
354
+ #[test]
355
+ #[serial]
356
+ fn test_sendable_dict_empty() {
357
+ with_ruby_python(|_ruby, _api| {
358
+ let dict = SendableValue::Dict(vec![]);
359
+ let val: magnus::Value = dict
360
+ .try_into()
361
+ .expect("empty dict conversion should succeed");
362
+ let hash = magnus::RHash::try_convert(val).expect("should be a Hash");
363
+ assert_eq!(hash.len(), 0);
364
+ });
365
+ }
366
+
367
+ #[test]
368
+ #[serial]
369
+ fn test_sendable_dict_with_integer_keys() {
370
+ with_ruby_python(|_ruby, _api| {
371
+ let dict = SendableValue::Dict(vec![
372
+ (
373
+ SendableValue::Integer(1),
374
+ SendableValue::Str("one".to_string()),
375
+ ),
376
+ (
377
+ SendableValue::Integer(2),
378
+ SendableValue::Str("two".to_string()),
379
+ ),
380
+ ]);
381
+ let val: magnus::Value = dict
382
+ .try_into()
383
+ .expect("Dict with int keys conversion should succeed");
384
+
385
+ let hash = magnus::RHash::try_convert(val).expect("should be a Hash");
386
+ let v1: String = hash.fetch(1_i64).expect("should have key 1");
387
+ assert_eq!(v1, "one");
388
+ let v2: String = hash.fetch(2_i64).expect("should have key 2");
389
+ assert_eq!(v2, "two");
390
+ });
391
+ }
392
+
393
+ #[test]
394
+ #[serial]
395
+ fn test_sendable_dict_nested_list_value() {
396
+ with_ruby_python(|_ruby, _api| {
397
+ let dict = SendableValue::Dict(vec![(
398
+ SendableValue::Str("data".to_string()),
399
+ SendableValue::List(vec![SendableValue::Integer(10), SendableValue::Integer(20)]),
400
+ )]);
401
+ let val: magnus::Value = dict
402
+ .try_into()
403
+ .expect("Dict with list value conversion should succeed");
404
+
405
+ let hash = magnus::RHash::try_convert(val).expect("should be a Hash");
406
+ let data: magnus::Value = hash.fetch("data").expect("should have 'data' key");
407
+ let arr = magnus::RArray::try_convert(data).expect("value should be an Array");
408
+ assert_eq!(arr.len(), 2);
409
+ assert_eq!(
410
+ i64::try_convert(arr.entry::<magnus::Value>(0).unwrap()).unwrap(),
411
+ 10
412
+ );
413
+ assert_eq!(
414
+ i64::try_convert(arr.entry::<magnus::Value>(1).unwrap()).unwrap(),
415
+ 20
416
+ );
417
+ });
418
+ }
419
+
420
+ // ========== AsyncStream as Iterator (via channel) ==========
421
+
422
+ #[test]
423
+ #[serial]
424
+ fn test_async_stream_iterates_values() {
425
+ with_ruby_python(|_ruby, _api| {
426
+ let (value_tx, value_rx) = unbounded();
427
+ let (cancel_tx, _cancel_rx) = bounded(1);
428
+
429
+ thread::spawn(move || {
430
+ value_tx.send(Some(SendableValue::Integer(10))).ok();
431
+ value_tx.send(Some(SendableValue::Integer(20))).ok();
432
+ value_tx.send(Some(SendableValue::Integer(30))).ok();
433
+ value_tx.send(None).ok(); // End signal
434
+ });
435
+
436
+ let mut stream = crate::stream::AsyncStream::from_channel(value_rx, cancel_tx);
437
+
438
+ let v1 = stream.next().unwrap().unwrap();
439
+ let v2 = stream.next().unwrap().unwrap();
440
+ let v3 = stream.next().unwrap().unwrap();
441
+ assert!(stream.next().is_none());
442
+
443
+ assert_eq!(i64::try_convert(v1).unwrap(), 10);
444
+ assert_eq!(i64::try_convert(v2).unwrap(), 20);
445
+ assert_eq!(i64::try_convert(v3).unwrap(), 30);
446
+ });
447
+ }
448
+
449
+ #[test]
450
+ #[serial]
451
+ fn test_async_stream_empty() {
452
+ with_ruby_python(|_ruby, _api| {
453
+ let (value_tx, value_rx) = unbounded();
454
+ let (cancel_tx, _cancel_rx) = bounded(1);
455
+
456
+ thread::spawn(move || {
457
+ value_tx.send(None).ok(); // Immediate end
458
+ });
459
+
460
+ let mut stream = crate::stream::AsyncStream::from_channel(value_rx, cancel_tx);
461
+ assert!(
462
+ stream.next().is_none(),
463
+ "Empty stream should return None immediately"
464
+ );
465
+ });
466
+ }
467
+
468
+ #[test]
469
+ #[serial]
470
+ fn test_async_stream_mixed_types() {
471
+ with_ruby_python(|_ruby, _api| {
472
+ let (value_tx, value_rx) = unbounded();
473
+ let (cancel_tx, _cancel_rx) = bounded(1);
474
+
475
+ thread::spawn(move || {
476
+ value_tx.send(Some(SendableValue::Integer(1))).ok();
477
+ value_tx
478
+ .send(Some(SendableValue::Str("hello".to_string())))
479
+ .ok();
480
+ value_tx.send(Some(SendableValue::Float(2.5))).ok();
481
+ value_tx.send(Some(SendableValue::Bool(true))).ok();
482
+ value_tx.send(Some(SendableValue::Nil)).ok();
483
+ value_tx.send(None).ok();
484
+ });
485
+
486
+ let mut stream = crate::stream::AsyncStream::from_channel(value_rx, cancel_tx);
487
+ let mut results = Vec::new();
488
+ while let Some(Ok(val)) = stream.next() {
489
+ results.push(val);
490
+ }
491
+ assert_eq!(results.len(), 5);
492
+
493
+ assert_eq!(i64::try_convert(results[0]).unwrap(), 1);
494
+ assert_eq!(String::try_convert(results[1]).unwrap(), "hello");
495
+ assert!((f64::try_convert(results[2]).unwrap() - 2.5).abs() < 1e-9);
496
+ assert!(bool::try_convert(results[3]).unwrap());
497
+ assert!(results[4].is_nil());
498
+ });
499
+ }
500
+
501
+ #[test]
502
+ #[serial]
503
+ fn test_async_stream_with_collections() {
504
+ with_ruby_python(|_ruby, _api| {
505
+ let (value_tx, value_rx) = unbounded();
506
+ let (cancel_tx, _cancel_rx) = bounded(1);
507
+
508
+ thread::spawn(move || {
509
+ value_tx
510
+ .send(Some(SendableValue::List(vec![
511
+ SendableValue::Integer(1),
512
+ SendableValue::Integer(2),
513
+ ])))
514
+ .ok();
515
+ value_tx
516
+ .send(Some(SendableValue::Dict(vec![(
517
+ SendableValue::Str("key".to_string()),
518
+ SendableValue::Str("val".to_string()),
519
+ )])))
520
+ .ok();
521
+ value_tx.send(None).ok();
522
+ });
523
+
524
+ let mut stream = crate::stream::AsyncStream::from_channel(value_rx, cancel_tx);
525
+
526
+ let arr_val = stream.next().unwrap().unwrap();
527
+ let arr = magnus::RArray::try_convert(arr_val).expect("should be Array");
528
+ assert_eq!(arr.len(), 2);
529
+
530
+ let hash_val = stream.next().unwrap().unwrap();
531
+ let hash = magnus::RHash::try_convert(hash_val).expect("should be Hash");
532
+ let v: String = hash.fetch("key").unwrap();
533
+ assert_eq!(v, "val");
534
+
535
+ assert!(stream.next().is_none());
536
+ });
537
+ }
538
+
539
+ #[test]
540
+ #[serial]
541
+ fn test_async_stream_cancellation_via_drop() {
542
+ // Verify that dropping the stream doesn't hang even with a blocked producer
543
+ let Some(_guard) = skip_if_no_python() else {
544
+ return;
545
+ };
546
+
547
+ let (value_tx, value_rx) = bounded(1); // small buffer
548
+ let (cancel_tx, cancel_rx) = bounded(1);
549
+
550
+ let producer = thread::spawn(move || {
551
+ // Fill the buffer
552
+ let _ = value_tx.send(Some(SendableValue::Integer(1)));
553
+ // This send will block because buffer is full and consumer won't read
554
+ // The cancel signal or channel close should unblock us
555
+ loop {
556
+ crossbeam_channel::select! {
557
+ send(value_tx, Some(SendableValue::Integer(2))) -> res => {
558
+ if res.is_err() { break; } // channel closed
559
+ }
560
+ recv(cancel_rx) -> _ => {
561
+ break; // cancelled
562
+ }
563
+ }
564
+ }
565
+ });
566
+
567
+ // Drop the stream (sends cancel, drops receiver)
568
+ let stream = crate::stream::AsyncStream::from_channel(value_rx, cancel_tx);
569
+ drop(stream);
570
+
571
+ // Producer thread should finish without hanging
572
+ producer
573
+ .join()
574
+ .expect("producer should not hang after cancellation");
575
+ }
576
+
577
+ #[test]
578
+ #[serial]
579
+ fn test_async_stream_channel_closed_returns_none() {
580
+ with_ruby_python(|_ruby, _api| {
581
+ let (value_tx, value_rx) = unbounded();
582
+ let (cancel_tx, _cancel_rx) = bounded(1);
583
+
584
+ // Drop sender immediately — simulates producer crash
585
+ drop(value_tx);
586
+
587
+ let mut stream = crate::stream::AsyncStream::from_channel(value_rx, cancel_tx);
588
+ assert!(stream.next().is_none(), "Closed channel should return None");
589
+ });
590
+ }
591
+
592
+ // ========== python_to_sendable via Python objects ==========
593
+
594
+ #[test]
595
+ #[serial]
596
+ fn test_python_to_sendable_primitives() {
597
+ let Some(guard) = skip_if_no_python() else {
598
+ return;
599
+ };
600
+ let api = guard.api();
601
+
602
+ // None
603
+ let result = crate::rubyx_object::python_to_sendable(api.py_none, api);
604
+ assert!(matches!(result, Ok(SendableValue::Nil)));
605
+
606
+ // Integer
607
+ let py_int = api.long_from_i64(42);
608
+ let result = crate::rubyx_object::python_to_sendable(py_int, api);
609
+ assert!(matches!(result, Ok(SendableValue::Integer(42))));
610
+ api.decref(py_int);
611
+
612
+ // Float
613
+ let py_float = api.float_from_f64(std::f64::consts::PI);
614
+ let result = crate::rubyx_object::python_to_sendable(py_float, api);
615
+ match result {
616
+ Ok(SendableValue::Float(f)) => assert!((f - std::f64::consts::PI).abs() < 1e-9),
617
+ other => panic!("Expected Float, got {:?}", other),
618
+ }
619
+ api.decref(py_float);
620
+
621
+ // String
622
+ let py_str = api.string_from_str("test");
623
+ let result = crate::rubyx_object::python_to_sendable(py_str, api);
624
+ assert!(matches!(result, Ok(SendableValue::Str(s)) if s == "test"));
625
+ api.decref(py_str);
626
+
627
+ // Bool true
628
+ let result = crate::rubyx_object::python_to_sendable(api.py_true, api);
629
+ assert!(matches!(result, Ok(SendableValue::Bool(true))));
630
+
631
+ // Bool false
632
+ let result = crate::rubyx_object::python_to_sendable(api.py_false, api);
633
+ assert!(matches!(result, Ok(SendableValue::Bool(false))));
634
+ }
635
+
636
+ #[test]
637
+ #[serial]
638
+ fn test_python_to_sendable_list() {
639
+ let Some(guard) = skip_if_no_python() else {
640
+ return;
641
+ };
642
+ let api = guard.api();
643
+
644
+ let list = api.list_new(3);
645
+ api.list_set_item(list, 0, api.long_from_i64(10));
646
+ api.list_set_item(list, 1, api.long_from_i64(20));
647
+ api.list_set_item(list, 2, api.long_from_i64(30));
648
+
649
+ let result = crate::rubyx_object::python_to_sendable(list, api);
650
+ match result {
651
+ Ok(SendableValue::List(items)) => {
652
+ assert_eq!(items.len(), 3);
653
+ assert!(matches!(items[0], SendableValue::Integer(10)));
654
+ assert!(matches!(items[1], SendableValue::Integer(20)));
655
+ assert!(matches!(items[2], SendableValue::Integer(30)));
656
+ }
657
+ other => panic!("Expected List, got {:?}", other),
658
+ }
659
+ api.decref(list);
660
+ }
661
+
662
+ #[test]
663
+ #[serial]
664
+ fn test_python_to_sendable_tuple() {
665
+ let Some(guard) = skip_if_no_python() else {
666
+ return;
667
+ };
668
+ let api = guard.api();
669
+
670
+ let tuple = api.tuple_new(2);
671
+ api.tuple_set_item(tuple, 0, api.string_from_str("a"));
672
+ api.tuple_set_item(tuple, 1, api.long_from_i64(1));
673
+
674
+ let result = crate::rubyx_object::python_to_sendable(tuple, api);
675
+ match result {
676
+ Ok(SendableValue::List(items)) => {
677
+ assert_eq!(items.len(), 2);
678
+ assert!(matches!(&items[0], SendableValue::Str(s) if s == "a"));
679
+ assert!(matches!(items[1], SendableValue::Integer(1)));
680
+ }
681
+ other => panic!("Expected List (from tuple), got {:?}", other),
682
+ }
683
+ api.decref(tuple);
684
+ }
685
+
686
+ #[test]
687
+ #[serial]
688
+ fn test_python_to_sendable_dict() {
689
+ let Some(guard) = skip_if_no_python() else {
690
+ return;
691
+ };
692
+ let api = guard.api();
693
+
694
+ let dict = api.dict_new();
695
+ let key = api.string_from_str("x");
696
+ let val = api.long_from_i64(99);
697
+ api.dict_set_item(dict, key, val);
698
+ api.decref(key);
699
+ api.decref(val);
700
+
701
+ let result = crate::rubyx_object::python_to_sendable(dict, api);
702
+ match result {
703
+ Ok(SendableValue::Dict(entries)) => {
704
+ assert_eq!(entries.len(), 1);
705
+ assert!(matches!(&entries[0].0, SendableValue::Str(s) if s == "x"));
706
+ assert!(matches!(entries[0].1, SendableValue::Integer(99)));
707
+ }
708
+ other => panic!("Expected Dict, got {:?}", other),
709
+ }
710
+ api.decref(dict);
711
+ }
712
+
713
+ #[test]
714
+ #[serial]
715
+ fn test_python_to_sendable_nested_structure() {
716
+ let Some(guard) = skip_if_no_python() else {
717
+ return;
718
+ };
719
+ let api = guard.api();
720
+
721
+ // Build: {"items": [1, 2], "flag": True}
722
+ let inner_list = api.list_new(2);
723
+ api.list_set_item(inner_list, 0, api.long_from_i64(1));
724
+ api.list_set_item(inner_list, 1, api.long_from_i64(2));
725
+
726
+ let dict = api.dict_new();
727
+ let key1 = api.string_from_str("items");
728
+ api.dict_set_item(dict, key1, inner_list);
729
+ api.decref(key1);
730
+ api.decref(inner_list);
731
+
732
+ let key2 = api.string_from_str("flag");
733
+ api.incref(api.py_true);
734
+ api.dict_set_item(dict, key2, api.py_true);
735
+ api.decref(key2);
736
+
737
+ let result = crate::rubyx_object::python_to_sendable(dict, api);
738
+ match result {
739
+ Ok(SendableValue::Dict(entries)) => {
740
+ assert_eq!(entries.len(), 2);
741
+ // Dict order is not guaranteed; find each key
742
+ let items_entry = entries
743
+ .iter()
744
+ .find(|(k, _)| matches!(k, SendableValue::Str(s) if s == "items"))
745
+ .expect("should have 'items' key");
746
+ assert!(matches!(&items_entry.1, SendableValue::List(l) if l.len() == 2));
747
+
748
+ let flag_entry = entries
749
+ .iter()
750
+ .find(|(k, _)| matches!(k, SendableValue::Str(s) if s == "flag"))
751
+ .expect("should have 'flag' key");
752
+ assert!(matches!(flag_entry.1, SendableValue::Bool(true)));
753
+ }
754
+ other => panic!("Expected Dict, got {:?}", other),
755
+ }
756
+ api.decref(dict);
757
+ }
758
+
759
+ // ========== RubyxStream::next_item ==========
760
+
761
+ #[test]
762
+ #[serial]
763
+ fn test_next_item_returns_values() {
764
+ with_ruby_python(|_ruby, _api| {
765
+ let (tx, rx) = unbounded();
766
+ let (cancel_tx, _cancel_rx) = bounded(1);
767
+
768
+ thread::spawn(move || {
769
+ tx.send(Some(SendableValue::Integer(1))).ok();
770
+ tx.send(Some(SendableValue::Integer(2))).ok();
771
+ tx.send(Some(SendableValue::Integer(3))).ok();
772
+ tx.send(None).ok();
773
+ });
774
+
775
+ let stream = crate::async_gen::AsyncGeneratorStream::from_channel(rx, cancel_tx);
776
+ let rubyx_stream = crate::rubyx_stream::RubyxStream::from_stream(stream);
777
+
778
+ let v1 = rubyx_stream
779
+ .next_item()
780
+ .expect("first next_item should succeed");
781
+ let v2 = rubyx_stream
782
+ .next_item()
783
+ .expect("second next_item should succeed");
784
+ let v3 = rubyx_stream
785
+ .next_item()
786
+ .expect("third next_item should succeed");
787
+
788
+ assert_eq!(i64::try_convert(v1).unwrap(), 1);
789
+ assert_eq!(i64::try_convert(v2).unwrap(), 2);
790
+ assert_eq!(i64::try_convert(v3).unwrap(), 3);
791
+ });
792
+ }
793
+
794
+ #[test]
795
+ #[serial]
796
+ fn test_next_item_raises_stop_iteration_at_end() {
797
+ with_ruby_python(|ruby, _api| {
798
+ let (tx, rx) = unbounded();
799
+ let (cancel_tx, _cancel_rx) = bounded(1);
800
+
801
+ thread::spawn(move || {
802
+ tx.send(Some(SendableValue::Integer(42))).ok();
803
+ tx.send(None).ok();
804
+ });
805
+
806
+ let stream = crate::async_gen::AsyncGeneratorStream::from_channel(rx, cancel_tx);
807
+ let rubyx_stream = crate::rubyx_stream::RubyxStream::from_stream(stream);
808
+
809
+ // First call succeeds
810
+ let val = rubyx_stream.next_item().expect("should return value");
811
+ assert_eq!(i64::try_convert(val).unwrap(), 42);
812
+
813
+ // Second call should raise StopIteration
814
+ let err = rubyx_stream
815
+ .next_item()
816
+ .expect_err("should raise StopIteration");
817
+ assert!(
818
+ err.is_kind_of(ruby.exception_stop_iteration()),
819
+ "error should be StopIteration, got: {}",
820
+ err
821
+ );
822
+ });
823
+ }
824
+
825
+ #[test]
826
+ #[serial]
827
+ fn test_next_item_empty_stream_raises_stop_iteration() {
828
+ with_ruby_python(|ruby, _api| {
829
+ let (tx, rx) = unbounded();
830
+ let (cancel_tx, _cancel_rx) = bounded(1);
831
+
832
+ thread::spawn(move || {
833
+ tx.send(None).ok(); // immediate end
834
+ });
835
+
836
+ let stream = crate::async_gen::AsyncGeneratorStream::from_channel(rx, cancel_tx);
837
+ let rubyx_stream = crate::rubyx_stream::RubyxStream::from_stream(stream);
838
+
839
+ let err = rubyx_stream
840
+ .next_item()
841
+ .expect_err("should raise StopIteration");
842
+ assert!(
843
+ err.is_kind_of(ruby.exception_stop_iteration()),
844
+ "empty stream should raise StopIteration"
845
+ );
846
+ });
847
+ }
848
+
849
+ #[test]
850
+ #[serial]
851
+ fn test_next_item_repeated_after_exhaustion() {
852
+ with_ruby_python(|ruby, _api| {
853
+ let (tx, rx) = unbounded();
854
+ let (cancel_tx, _cancel_rx) = bounded(1);
855
+
856
+ thread::spawn(move || {
857
+ tx.send(None).ok();
858
+ });
859
+
860
+ let stream = crate::async_gen::AsyncGeneratorStream::from_channel(rx, cancel_tx);
861
+ let rubyx_stream = crate::rubyx_stream::RubyxStream::from_stream(stream);
862
+
863
+ // First call raises StopIteration
864
+ let err1 = rubyx_stream
865
+ .next_item()
866
+ .expect_err("should raise StopIteration");
867
+ assert!(err1.is_kind_of(ruby.exception_stop_iteration()));
868
+
869
+ // Second call should also raise (StopIteration or stream consumed error)
870
+ let err2 = rubyx_stream
871
+ .next_item()
872
+ .expect_err("should still raise after exhaustion");
873
+ // Could be StopIteration again or RuntimeError("Stream already consumed")
874
+ // Both are acceptable — the key is it doesn't return nil or panic
875
+ assert!(
876
+ err2.is_kind_of(ruby.exception_stop_iteration())
877
+ || err2.is_kind_of(ruby.exception_runtime_error()),
878
+ "should raise StopIteration or RuntimeError"
879
+ );
880
+ });
881
+ }
882
+
883
+ #[test]
884
+ #[serial]
885
+ fn test_next_item_with_mixed_types() {
886
+ with_ruby_python(|_ruby, _api| {
887
+ let (tx, rx) = unbounded();
888
+ let (cancel_tx, _cancel_rx) = bounded(1);
889
+
890
+ thread::spawn(move || {
891
+ tx.send(Some(SendableValue::Str("hello".to_string()))).ok();
892
+ tx.send(Some(SendableValue::Float(std::f64::consts::PI)))
893
+ .ok();
894
+ tx.send(Some(SendableValue::Bool(true))).ok();
895
+ tx.send(Some(SendableValue::Nil)).ok();
896
+ tx.send(None).ok();
897
+ });
898
+
899
+ let stream = crate::async_gen::AsyncGeneratorStream::from_channel(rx, cancel_tx);
900
+ let rubyx_stream = crate::rubyx_stream::RubyxStream::from_stream(stream);
901
+
902
+ let v1 = rubyx_stream.next_item().unwrap();
903
+ assert_eq!(String::try_convert(v1).unwrap(), "hello");
904
+
905
+ let v2 = rubyx_stream.next_item().unwrap();
906
+ assert!((f64::try_convert(v2).unwrap() - std::f64::consts::PI).abs() < 1e-9);
907
+
908
+ let v3 = rubyx_stream.next_item().unwrap();
909
+ assert!(bool::try_convert(v3).unwrap());
910
+
911
+ let v4 = rubyx_stream.next_item().unwrap();
912
+ assert!(v4.is_nil());
913
+ });
914
+ }
915
+
916
+ #[test]
917
+ #[serial]
918
+ fn test_next_item_with_collections() {
919
+ with_ruby_python(|_ruby, _api| {
920
+ let (tx, rx) = unbounded();
921
+ let (cancel_tx, _cancel_rx) = bounded(1);
922
+
923
+ thread::spawn(move || {
924
+ tx.send(Some(SendableValue::List(vec![
925
+ SendableValue::Integer(1),
926
+ SendableValue::Integer(2),
927
+ ])))
928
+ .ok();
929
+ tx.send(Some(SendableValue::Dict(vec![(
930
+ SendableValue::Str("k".to_string()),
931
+ SendableValue::Integer(99),
932
+ )])))
933
+ .ok();
934
+ tx.send(None).ok();
935
+ });
936
+
937
+ let stream = crate::async_gen::AsyncGeneratorStream::from_channel(rx, cancel_tx);
938
+ let rubyx_stream = crate::rubyx_stream::RubyxStream::from_stream(stream);
939
+
940
+ let arr_val = rubyx_stream.next_item().unwrap();
941
+ let arr = magnus::RArray::try_convert(arr_val).expect("should be Array");
942
+ assert_eq!(arr.len(), 2);
943
+
944
+ let hash_val = rubyx_stream.next_item().unwrap();
945
+ let hash = magnus::RHash::try_convert(hash_val).expect("should be Hash");
946
+ let v: i64 = hash.fetch("k").unwrap();
947
+ assert_eq!(v, 99);
948
+ });
949
+ }
950
+ }