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,377 @@
1
+ use crate::python_api::PythonApi;
2
+ use crate::python_ffi::PyObject;
3
+ use crate::python_guard::PyGuard;
4
+ use crate::ruby_helpers::runtime_error;
5
+ use crate::rubyx_object::{ruby_to_python, RubyxObject};
6
+ use magnus::r_hash::ForEach;
7
+ use magnus::typed_data::Obj;
8
+ use magnus::{Error, IntoValue, RHash, Ruby, TryConvert, Value};
9
+
10
+ /// Py_eval_input = 258 (for expressions)
11
+ const PY_EVAL_INPUT: i64 = 258;
12
+ /// Py_file_input = 257 (for statements)
13
+ const PY_FILE_INPUT: i64 = 257;
14
+
15
+ pub(crate) fn make_globals(api: &PythonApi) -> PyGuard<'_> {
16
+ let globals = api.dict_new();
17
+ // Import __builtins__ so expressions like len([1,2]) work
18
+ let builtins_key = PyGuard::new(api.string_from_str("__builtins__"), api)
19
+ .expect("string_from_str should not return null");
20
+ let builtins = PyGuard::new(
21
+ api.import_module("builtins")
22
+ .expect("builtins should exist"),
23
+ api,
24
+ )
25
+ .expect("builtins module should not be null");
26
+ api.dict_set_item(globals, builtins_key.ptr(), builtins.ptr());
27
+ // builtins_key and builtins are automatically decref'd here on drop
28
+ PyGuard::new(globals, api).expect("dict_new should not return null")
29
+ }
30
+
31
+ /// Use Python's `ast` module to determine if the last statement in `code` is an
32
+ /// expression (`ast.Expr` node). If so, returns `Ok((body, last_expr))` where
33
+ /// `body` contains all preceding lines and `last_expr` is the expression source.
34
+ /// Returns `Err` if the last statement is not an expression or if parsing fails.
35
+ fn split_last_expr(
36
+ api: &PythonApi,
37
+ code: &str,
38
+ globals: *mut crate::python_ffi::PyObject,
39
+ ) -> Result<(String, String), String> {
40
+ let key = PyGuard::new(api.string_from_str("__rubyx_src__"), api)
41
+ .ok_or_else(|| "failed to create __rubyx_src__ key".to_string())?;
42
+ let val = PyGuard::new(api.string_from_str(code), api)
43
+ .ok_or_else(|| "failed to create code string".to_string())?;
44
+ api.dict_set_item(globals, key.ptr(), val.ptr());
45
+
46
+ // Parse with ast.parse() and check if body[-1] is an ast.Expr node.
47
+ // Returns the 1-based line number of the last expression, or -1.
48
+ let ast_expr = "(lambda a: (lambda t: t.body[-1].lineno \
49
+ if t.body and isinstance(t.body[-1], a.Expr) \
50
+ else -1)(a.parse(__rubyx_src__)))(__import__('ast'))";
51
+
52
+ let result = api
53
+ .run_string(ast_expr, PY_EVAL_INPUT, globals, globals)
54
+ .map_err(|e| format!("AST parse failed: {e}"))?;
55
+
56
+ if result.is_null() {
57
+ if api.has_error() {
58
+ PythonApi::extract_exception(api);
59
+ }
60
+ return Err("AST parse returned null".to_string());
61
+ }
62
+
63
+ let result_guard =
64
+ PyGuard::new(result, api).ok_or_else(|| "AST result was null".to_string())?;
65
+ let lineno = api.long_to_i64(result_guard.ptr());
66
+
67
+ if lineno < 1 {
68
+ return Err("last statement is not an expression".to_string());
69
+ }
70
+
71
+ let lines: Vec<&str> = code.lines().collect();
72
+ let split_idx = (lineno as usize) - 1; // Convert to 0-based
73
+
74
+ if split_idx == 0 {
75
+ return Err("expression is the entire code".to_string());
76
+ }
77
+
78
+ let body = lines[..split_idx].join("\n");
79
+ let last_expr = lines[split_idx..].join("\n");
80
+
81
+ Ok((body, last_expr))
82
+ }
83
+
84
+ pub(crate) fn eval_with_globals(
85
+ code: &str,
86
+ globals: *mut PyObject,
87
+ api: &'static PythonApi,
88
+ ) -> Result<Value, magnus::Error> {
89
+ let ruby = Ruby::get()
90
+ .map_err(|e| Error::new(runtime_error(), format!("Ruby VM unavailable: {e}")))?;
91
+ // Try as expression first (Py_eval_input)
92
+ let py_result = match api.run_string(code, PY_EVAL_INPUT, globals, globals) {
93
+ Ok(output) if !output.is_null() => output,
94
+ Ok(_) => {
95
+ // Expression eval failed — extract the exception to check type.
96
+ // extract_exception consumes the error, so we must save it.
97
+ let exc = if api.has_error() {
98
+ PythonApi::extract_exception(api)
99
+ } else {
100
+ None
101
+ };
102
+
103
+ let is_syntax = matches!(
104
+ exc,
105
+ Some(crate::exception::PythonException::SyntaxError { .. })
106
+ );
107
+
108
+ if !is_syntax {
109
+ // Real error (NameError, KeyError, etc.) — not a syntax issue
110
+ let err = exc
111
+ .map(Error::from)
112
+ .unwrap_or_else(|| Error::new(runtime_error(), "Python execution failed"));
113
+ return Err(err);
114
+ }
115
+
116
+ // SyntaxError — code contains statements. Use AST to split.
117
+ let trimmed = code.trim_end();
118
+
119
+ match split_last_expr(api, trimmed, globals) {
120
+ Ok((body, last_expr)) => {
121
+ // Run body (statements) with Py_file_input
122
+ match api.run_string(&body, PY_FILE_INPUT, globals, globals) {
123
+ Ok(out) if out.is_null() => {
124
+ let err = PythonApi::extract_exception(api)
125
+ .map(Error::from)
126
+ .unwrap_or_else(|| {
127
+ Error::new(runtime_error(), "Python execution failed")
128
+ });
129
+ return Err(err);
130
+ }
131
+ Ok(_) => { /* Py_file_input returns Py_None — ignore */ }
132
+ Err(e) => {
133
+ return Err(Error::new(runtime_error(), e));
134
+ }
135
+ }
136
+
137
+ // Eval the last expression to get its value
138
+ match api.run_string(&last_expr, PY_EVAL_INPUT, globals, globals) {
139
+ Ok(out) if !out.is_null() => out,
140
+ Ok(_) => {
141
+ // AST said it's an expression but eval failed — shouldn't happen
142
+ if api.has_error() {
143
+ let err = PythonApi::extract_exception(api)
144
+ .map(Error::from)
145
+ .unwrap_or_else(|| {
146
+ Error::new(runtime_error(), "Python execution failed")
147
+ });
148
+ return Err(err);
149
+ }
150
+ return Err(Error::new(runtime_error(), "Python execution failed"));
151
+ }
152
+ Err(e) => {
153
+ return Err(Error::new(runtime_error(), e));
154
+ }
155
+ }
156
+ }
157
+ Err(_) => {
158
+ // Last statement is not an expression (or AST parse failed)
159
+ // — run entire code as statements
160
+ match api.run_string(trimmed, PY_FILE_INPUT, globals, globals) {
161
+ Ok(out) if out.is_null() => {
162
+ let err = PythonApi::extract_exception(api)
163
+ .map(Error::from)
164
+ .unwrap_or_else(|| {
165
+ Error::new(runtime_error(), "Python execution failed")
166
+ });
167
+ return Err(err);
168
+ }
169
+ Ok(out) => out, // Py_None — no expression value to return
170
+ Err(e) => {
171
+ return Err(Error::new(runtime_error(), e));
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ Err(e) => {
178
+ return Err(Error::new(runtime_error(), e));
179
+ }
180
+ };
181
+
182
+ let py_result_guard = PyGuard::new(py_result, api)
183
+ .ok_or_else(|| Error::new(runtime_error(), "Python returned null result"))?;
184
+
185
+ // Wrap result — RubyxObject::new increfs, so we decref our reference after
186
+ let wrapper = RubyxObject::new(py_result_guard.ptr(), api)
187
+ .ok_or_else(|| Error::new(runtime_error(), "Failed to create RubyxObject"))?;
188
+ Ok(wrapper.into_value_with(&ruby))
189
+ }
190
+
191
+ pub(crate) fn rubyx_eval(code: String) -> Result<Value, magnus::Error> {
192
+ let api = crate::api();
193
+ let gil = api.ensure_gil();
194
+
195
+ let result = {
196
+ let globals = make_globals(api);
197
+ eval_with_globals(&code, globals.ptr(), api)
198
+ };
199
+
200
+ api.release_gil(gil);
201
+ result
202
+ }
203
+ pub(crate) fn rubyx_eval_with_globals(
204
+ code: String,
205
+ globals_hash: RHash,
206
+ ) -> Result<Value, magnus::Error> {
207
+ let api = crate::api();
208
+ let gil = api.ensure_gil();
209
+
210
+ let globals = make_globals(api);
211
+ let result = match inject_globals(&globals, globals_hash, api) {
212
+ Ok(()) => eval_with_globals(&code, globals.ptr(), api),
213
+ Err(e) => Err(e),
214
+ };
215
+ drop(globals); // decref while GIL is held
216
+
217
+ api.release_gil(gil);
218
+ result
219
+ }
220
+
221
+ fn inject_globals(
222
+ globals: &PyGuard<'_>,
223
+ globals_hash: RHash,
224
+ api: &'static PythonApi,
225
+ ) -> Result<(), magnus::Error> {
226
+ globals_hash.foreach(|key: Value, val: Value| {
227
+ let py_key = ruby_to_python(key, api)?;
228
+ let py_val = ruby_to_python(val, api)?;
229
+ api.dict_set_item(globals.ptr(), py_key, py_val);
230
+ api.decref(py_key);
231
+ api.decref(py_val);
232
+ Ok(ForEach::Continue)
233
+ })?;
234
+ Ok(())
235
+ }
236
+
237
+ /// Run a Python coroutine with asyncio.run() and return the result.
238
+ /// The coroutine must already be a PyObject (not code string).
239
+ /// Caller must hold the GIL.
240
+ fn run_asyncio(coroutine: *mut PyObject, api: &'static PythonApi) -> Result<Value, magnus::Error> {
241
+ let ruby = Ruby::get().map_err(|e| Error::new(runtime_error(), e.to_string()))?;
242
+
243
+ let asyncio = api
244
+ .import_module("asyncio")
245
+ .map_err(|e| Error::new(runtime_error(), e.to_string()))?;
246
+ let run_fn = api.object_get_attr_string(asyncio, "run");
247
+
248
+ if run_fn.is_null() {
249
+ api.clear_error();
250
+ api.decref(asyncio);
251
+ return Err(Error::new(runtime_error(), "asyncio.run not found"));
252
+ }
253
+
254
+ let args = unsafe { (api.py_tuple_new)(1) };
255
+ if args.is_null() {
256
+ api.decref(run_fn);
257
+ api.decref(asyncio);
258
+ return Err(Error::new(
259
+ runtime_error(),
260
+ "Failed to allocate argument tuple",
261
+ ));
262
+ }
263
+ api.incref(coroutine);
264
+ unsafe { (api.py_tuple_set_item)(args, 0, coroutine) };
265
+
266
+ let result = api.object_call(run_fn, args, std::ptr::null_mut());
267
+ api.decref(args);
268
+ api.decref(run_fn);
269
+ api.decref(asyncio);
270
+
271
+ if result.is_null() {
272
+ let err = if let Some(exc) = PythonApi::extract_exception(api) {
273
+ exc.to_string()
274
+ } else {
275
+ "Python async call failed".to_string()
276
+ };
277
+ return Err(Error::new(runtime_error(), err));
278
+ }
279
+
280
+ let wrapper = RubyxObject::new(result, api)
281
+ .ok_or_else(|| Error::new(runtime_error(), "Failed to wrap async result"))?;
282
+
283
+ Ok(wrapper.into_value_with(&ruby))
284
+ }
285
+
286
+ /// Rubyx.await(coroutine) — takes a RubyxObject wrapping a Python coroutine,
287
+ /// runs it with asyncio.run(), and returns the result.
288
+ pub(crate) fn rubyx_await(coroutine: Value) -> Result<Value, magnus::Error> {
289
+ let obj = Obj::<RubyxObject>::try_convert(coroutine)?;
290
+ let api = crate::api();
291
+ let gil = api.ensure_gil();
292
+
293
+ let result = run_asyncio(obj.as_ptr(), api);
294
+
295
+ api.release_gil(gil);
296
+ result
297
+ }
298
+
299
+ /// Eval code in context globals to get a coroutine, then run it with asyncio.run().
300
+ /// Used by RubyxContext#await.
301
+ pub(crate) fn await_eval_with_globals(
302
+ code: &str,
303
+ globals: *mut PyObject,
304
+ api: &'static PythonApi,
305
+ ) -> Result<Value, magnus::Error> {
306
+ let py_coroutine = match api.run_string(code, PY_EVAL_INPUT, globals, globals) {
307
+ Ok(obj) if !obj.is_null() => obj,
308
+ Ok(_) => {
309
+ let err = if api.has_error() {
310
+ PythonApi::extract_exception(api)
311
+ .map(Error::from)
312
+ .unwrap_or_else(|| Error::new(runtime_error(), "Python eval failed"))
313
+ } else {
314
+ Error::new(runtime_error(), "Python eval returned null")
315
+ };
316
+ return Err(err);
317
+ }
318
+ Err(e) => return Err(Error::new(runtime_error(), e)),
319
+ };
320
+
321
+ let result = run_asyncio(py_coroutine, api);
322
+ api.decref(py_coroutine);
323
+ result
324
+ }
325
+
326
+ pub(crate) fn rubyx_await_with_globals(
327
+ code: String,
328
+ globals_hash: RHash,
329
+ ) -> Result<Value, magnus::Error> {
330
+ let api = crate::api();
331
+ let gil = api.ensure_gil();
332
+
333
+ let globals = make_globals(api);
334
+ let result = match inject_globals(&globals, globals_hash, api) {
335
+ Ok(()) => await_eval_with_globals(&code, globals.ptr(), api),
336
+ Err(e) => Err(e),
337
+ };
338
+ drop(globals);
339
+
340
+ api.release_gil(gil);
341
+ result
342
+ }
343
+
344
+ pub(crate) fn rubyx_async_await_with_globals(
345
+ code: String,
346
+ globals_hash: RHash,
347
+ ) -> Result<crate::future::RubyxFuture, magnus::Error> {
348
+ let api = crate::api();
349
+ let gil = api.ensure_gil();
350
+
351
+ let globals = make_globals(api);
352
+ let result = match inject_globals(&globals, globals_hash, api) {
353
+ Err(e) => Err(e),
354
+ Ok(()) => match api.run_string(&code, PY_EVAL_INPUT, globals.ptr(), globals.ptr()) {
355
+ Ok(obj) if !obj.is_null() => {
356
+ let future = crate::future::RubyxFuture::from_coroutine(obj, api);
357
+ api.decref(obj);
358
+ Ok(future)
359
+ }
360
+ Ok(_) => {
361
+ let err = if api.has_error() {
362
+ PythonApi::extract_exception(api)
363
+ .map(Error::from)
364
+ .unwrap_or_else(|| Error::new(runtime_error(), "Python eval failed"))
365
+ } else {
366
+ Error::new(runtime_error(), "Python eval returned null")
367
+ };
368
+ Err(err)
369
+ }
370
+ Err(e) => Err(Error::new(runtime_error(), e)),
371
+ },
372
+ };
373
+ drop(globals);
374
+
375
+ api.release_gil(gil);
376
+ result
377
+ }
@@ -0,0 +1,184 @@
1
+ use thiserror::Error;
2
+
3
+ #[derive(Debug, Error, Clone)]
4
+ pub enum PythonException {
5
+ #[error("{kind}: {message}")]
6
+ Exception {
7
+ kind: String, // e.g., "TypeError"
8
+ message: String, // e.g., "expected int, got str"
9
+ traceback: Option<String>,
10
+ },
11
+
12
+ #[error("SyntaxError at line {line}: {message}")]
13
+ SyntaxError {
14
+ message: String,
15
+ filename: String,
16
+ line: usize,
17
+ offset: usize,
18
+ },
19
+ }
20
+ impl PythonException {
21
+ /// Format the exception as a message for magnus::Error.
22
+ ///
23
+ /// Separate from Display (thiserror) because magnus needs a different format:
24
+ /// Exception includes traceback, SyntaxError includes filename and offset.
25
+ pub(crate) fn to_magnus_message(&self) -> String {
26
+ match self {
27
+ PythonException::Exception {
28
+ kind,
29
+ message,
30
+ traceback,
31
+ } => {
32
+ if let Some(tb) = traceback {
33
+ format!("{}: {}\n{}", kind, message, tb)
34
+ } else {
35
+ format!("{}: {}", kind, message)
36
+ }
37
+ }
38
+ PythonException::SyntaxError {
39
+ message,
40
+ filename,
41
+ line,
42
+ offset,
43
+ } => {
44
+ format!(
45
+ "SyntaxError: {} ({}:{}:{})",
46
+ message, filename, line, offset
47
+ )
48
+ }
49
+ }
50
+ }
51
+ }
52
+ impl From<PythonException> for magnus::Error {
53
+ fn from(e: PythonException) -> Self {
54
+ let (class, msg) = match &e {
55
+ PythonException::Exception { kind, .. } => (
56
+ crate::ruby_helpers::rubyx_exception_class(kind),
57
+ e.to_magnus_message(),
58
+ ),
59
+ PythonException::SyntaxError { .. } => {
60
+ (crate::ruby_helpers::syntax_error(), e.to_magnus_message())
61
+ }
62
+ };
63
+ magnus::Error::new(class, msg)
64
+ }
65
+ }
66
+ #[cfg(test)]
67
+ mod tests {
68
+ use super::*;
69
+ // ===== Display (thiserror) tests =====
70
+ #[test]
71
+ fn test_exception_display_format() {
72
+ let exc = PythonException::Exception {
73
+ kind: "TypeError".into(),
74
+ message: "expected int".into(),
75
+ traceback: None,
76
+ };
77
+ assert_eq!(exc.to_string(), "TypeError: expected int");
78
+ }
79
+ #[test]
80
+ fn test_exception_display_ignores_traceback() {
81
+ // Display (thiserror) format does NOT include traceback
82
+ let exc = PythonException::Exception {
83
+ kind: "ValueError".into(),
84
+ message: "bad value".into(),
85
+ traceback: Some(" File \"test.py\", line 1".into()),
86
+ };
87
+ assert_eq!(exc.to_string(), "ValueError: bad value");
88
+ }
89
+ #[test]
90
+ fn test_syntax_error_display_format() {
91
+ let exc = PythonException::SyntaxError {
92
+ message: "invalid syntax".into(),
93
+ filename: "test.py".into(),
94
+ line: 42,
95
+ offset: 5,
96
+ };
97
+ assert_eq!(exc.to_string(), "SyntaxError at line 42: invalid syntax");
98
+ }
99
+ // ===== Magnus message formatting tests =====
100
+ // We test to_magnus_message() directly because magnus::Error::new()
101
+ // requires a Ruby VM thread, which cargo test doesn't guarantee.
102
+ #[test]
103
+ fn test_magnus_message_exception_no_traceback() {
104
+ let exc = PythonException::Exception {
105
+ kind: "TypeError".into(),
106
+ message: "expected int".into(),
107
+ traceback: None,
108
+ };
109
+ assert_eq!(exc.to_magnus_message(), "TypeError: expected int");
110
+ }
111
+ #[test]
112
+ fn test_magnus_message_exception_with_traceback() {
113
+ let exc = PythonException::Exception {
114
+ kind: "ValueError".into(),
115
+ message: "invalid value".into(),
116
+ traceback: Some(" File \"test.py\", line 1\n x = bad".into()),
117
+ };
118
+ let msg = exc.to_magnus_message();
119
+ assert_eq!(
120
+ msg,
121
+ "ValueError: invalid value\n File \"test.py\", line 1\n x = bad"
122
+ );
123
+ }
124
+ #[test]
125
+ fn test_magnus_message_syntax_error() {
126
+ let exc = PythonException::SyntaxError {
127
+ message: "invalid syntax".into(),
128
+ filename: "test.py".into(),
129
+ line: 42,
130
+ offset: 5,
131
+ };
132
+ assert_eq!(
133
+ exc.to_magnus_message(),
134
+ "SyntaxError: invalid syntax (test.py:42:5)"
135
+ );
136
+ }
137
+ #[test]
138
+ fn test_magnus_message_syntax_error_string_source() {
139
+ let exc = PythonException::SyntaxError {
140
+ message: "unexpected EOF".into(),
141
+ filename: "<string>".into(),
142
+ line: 1,
143
+ offset: 0,
144
+ };
145
+ assert_eq!(
146
+ exc.to_magnus_message(),
147
+ "SyntaxError: unexpected EOF (<string>:1:0)"
148
+ );
149
+ }
150
+ // ===== Enum trait tests =====
151
+ #[test]
152
+ fn test_exception_clone_preserves_fields() {
153
+ let exc = PythonException::Exception {
154
+ kind: "TypeError".into(),
155
+ message: "test".into(),
156
+ traceback: Some("tb".into()),
157
+ };
158
+ let cloned = exc.clone();
159
+ assert_eq!(exc.to_string(), cloned.to_string());
160
+ assert_eq!(exc.to_magnus_message(), cloned.to_magnus_message());
161
+ }
162
+ #[test]
163
+ fn test_exception_debug_contains_fields() {
164
+ let exc = PythonException::Exception {
165
+ kind: "TypeError".into(),
166
+ message: "test".into(),
167
+ traceback: None,
168
+ };
169
+ let debug = format!("{:?}", exc);
170
+ assert!(debug.contains("TypeError"));
171
+ assert!(debug.contains("test"));
172
+ }
173
+ #[test]
174
+ fn test_exception_implements_std_error() {
175
+ let exc = PythonException::Exception {
176
+ kind: "TypeError".into(),
177
+ message: "test".into(),
178
+ traceback: None,
179
+ };
180
+ // PythonException implements std::error::Error via thiserror
181
+ let err: &dyn std::error::Error = &exc;
182
+ assert!(err.to_string().contains("TypeError"));
183
+ }
184
+ }
@@ -0,0 +1,126 @@
1
+ use crate::python_api::PythonApi;
2
+ use crate::python_ffi::PyObject;
3
+ use crate::ruby_helpers::runtime_error;
4
+ use crate::rubyx_object::python_to_sendable;
5
+ use crate::stream::SendableValue;
6
+ use crossbeam_channel::{bounded, Receiver};
7
+ use magnus::{Error, Value};
8
+ use std::cell::RefCell;
9
+ use std::thread::{self, JoinHandle};
10
+
11
+ /// A future representing an async Python operation running on a background thread.
12
+ ///
13
+ /// The Python coroutine is executed via `asyncio.run()` on a dedicated thread.
14
+ /// The Ruby thread is free to do other work. Call `value` to block until the
15
+ /// result is ready, or `ready?` to check without blocking.
16
+ #[magnus::wrap(class = "Rubyx::Future", free_immediately)]
17
+ pub(crate) struct RubyxFuture {
18
+ receiver: Receiver<Result<SendableValue, String>>,
19
+ handle: RefCell<Option<JoinHandle<()>>>,
20
+ }
21
+
22
+ unsafe impl Send for RubyxFuture {}
23
+ unsafe impl Sync for RubyxFuture {}
24
+
25
+ impl RubyxFuture {
26
+ /// Spawn a background thread that runs asyncio.run(coroutine).
27
+ pub fn from_coroutine(py_coroutine: *mut PyObject, api: &'static PythonApi) -> Self {
28
+ let (tx, rx) = bounded(1);
29
+ let coroutine_addr = py_coroutine as usize;
30
+
31
+ api.incref(py_coroutine);
32
+
33
+ let handle = thread::spawn(move || {
34
+ let coroutine = coroutine_addr as *mut PyObject;
35
+ let api = crate::api();
36
+ let gil = api.ensure_gil();
37
+
38
+ let result = run_asyncio_sendable(coroutine, api);
39
+
40
+ api.decref(coroutine);
41
+ api.release_gil(gil);
42
+
43
+ let _ = tx.send(result);
44
+ });
45
+
46
+ Self {
47
+ receiver: rx,
48
+ handle: RefCell::new(Some(handle)),
49
+ }
50
+ }
51
+
52
+ /// Block until the result is ready and return it as a Ruby value.
53
+ /// Can only be called once — subsequent calls return an error.
54
+ pub fn value(&self) -> Result<Value, Error> {
55
+ // Join the worker thread first
56
+ if let Some(handle) = self.handle.borrow_mut().take() {
57
+ let _ = handle.join();
58
+ }
59
+
60
+ match self.receiver.try_recv() {
61
+ Ok(Ok(sendable)) => sendable.try_into(),
62
+ Ok(Err(err)) => Err(Error::new(runtime_error(), err)),
63
+ Err(_) => Err(Error::new(
64
+ runtime_error(),
65
+ "Future already consumed or worker failed",
66
+ )),
67
+ }
68
+ }
69
+
70
+ pub fn is_ready(&self) -> bool {
71
+ !self.receiver.is_empty()
72
+ }
73
+ }
74
+
75
+ impl Drop for RubyxFuture {
76
+ fn drop(&mut self) {
77
+ if let Some(handle) = self.handle.borrow_mut().take() {
78
+ let _ = handle.join();
79
+ }
80
+ }
81
+ }
82
+
83
+ /// Run asyncio.run(coroutine) and convert the result to a SendableValue
84
+ /// (thread-safe). Runs on the worker thread with the GIL held.
85
+ fn run_asyncio_sendable(
86
+ coroutine: *mut PyObject,
87
+ api: &PythonApi,
88
+ ) -> Result<SendableValue, String> {
89
+ let asyncio = api
90
+ .import_module("asyncio")
91
+ .map_err(|e| format!("Failed to import asyncio: {e}"))?;
92
+ let run_fn = api.object_get_attr_string(asyncio, "run");
93
+
94
+ if run_fn.is_null() {
95
+ api.clear_error();
96
+ api.decref(asyncio);
97
+ return Err("asyncio.run not found".to_string());
98
+ }
99
+
100
+ let args = unsafe { (api.py_tuple_new)(1) };
101
+ if args.is_null() {
102
+ api.decref(run_fn);
103
+ api.decref(asyncio);
104
+ return Err("Failed to allocate argument tuple".to_string());
105
+ }
106
+ api.incref(coroutine);
107
+ unsafe { (api.py_tuple_set_item)(args, 0, coroutine) };
108
+
109
+ let result = api.object_call(run_fn, args, std::ptr::null_mut());
110
+ api.decref(args);
111
+ api.decref(run_fn);
112
+ api.decref(asyncio);
113
+
114
+ if result.is_null() {
115
+ let err = if let Some(exc) = PythonApi::extract_exception(api) {
116
+ exc.to_string()
117
+ } else {
118
+ "Python async call failed".to_string()
119
+ };
120
+ return Err(err);
121
+ }
122
+
123
+ let sendable = python_to_sendable(result, api);
124
+ api.decref(result);
125
+ sendable
126
+ }