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.
- checksums.yaml +7 -0
- data/Cargo.toml +19 -0
- data/README.md +469 -0
- data/ext/rubyx/Cargo.toml +19 -0
- data/ext/rubyx/extconf.rb +22 -0
- data/ext/rubyx/src/async_gen.rs +1298 -0
- data/ext/rubyx/src/context.rs +812 -0
- data/ext/rubyx/src/convert.rs +1498 -0
- data/ext/rubyx/src/eval.rs +377 -0
- data/ext/rubyx/src/exception.rs +184 -0
- data/ext/rubyx/src/future.rs +126 -0
- data/ext/rubyx/src/import.rs +34 -0
- data/ext/rubyx/src/lib.rs +4212 -0
- data/ext/rubyx/src/nonblocking_stream.rs +1422 -0
- data/ext/rubyx/src/pipe_notify.rs +232 -0
- data/ext/rubyx/src/python/sync_adapter.py +31 -0
- data/ext/rubyx/src/python_api.rs +6029 -0
- data/ext/rubyx/src/python_ffi.rs +18 -0
- data/ext/rubyx/src/python_finder.rs +119 -0
- data/ext/rubyx/src/python_guard.rs +25 -0
- data/ext/rubyx/src/ruby_helpers.rs +74 -0
- data/ext/rubyx/src/rubyx_object.rs +1931 -0
- data/ext/rubyx/src/rubyx_stream.rs +950 -0
- data/ext/rubyx/src/stream.rs +713 -0
- data/ext/rubyx/src/test_helpers.rs +351 -0
- data/lib/generators/rubyx/install_generator.rb +24 -0
- data/lib/generators/rubyx/templates/rubyx_initializer.rb +17 -0
- data/lib/rubyx/context.rb +27 -0
- data/lib/rubyx/error.rb +30 -0
- data/lib/rubyx/rails.rb +105 -0
- data/lib/rubyx/railtie.rb +20 -0
- data/lib/rubyx/uv.rb +261 -0
- data/lib/rubyx/version.rb +4 -0
- data/lib/rubyx-py.rb +1 -0
- data/lib/rubyx.rb +136 -0
- metadata +123 -0
|
@@ -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
|
+
}
|