rubyx-py 0.1.1 → 0.2.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 +4 -4
- data/README.md +38 -24
- data/docs/assets/logo.png +0 -0
- data/ext/rubyx/src/context.rs +45 -14
- data/ext/rubyx/src/eval.rs +14 -60
- data/ext/rubyx/src/future.rs +534 -14
- data/ext/rubyx/src/gvl.rs +70 -0
- data/ext/rubyx/src/lib.rs +82 -32
- data/ext/rubyx/src/nonblocking_stream.rs +5 -78
- data/ext/rubyx/src/rubyx_object.rs +91 -3
- data/ext/rubyx/src/stream.rs +77 -1
- data/lib/rubyx/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2815c45d6a58b835cfc97484894c6aff43abf13316f18006f46a38c059988ec9
|
|
4
|
+
data.tar.gz: 2eb4aa61608a7553046b01143d7a704dd204a9eb0a13aa1c1a933350f092b660
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4bd4ce8b2bf2e169a8b32adc9f3fefaf8445fdcc72b1eb1ac821d6c8b8ae192979b0f00260585844e22f0aa3b76a70648801c11130e82df8cb6b6da66bdfec4d
|
|
7
|
+
data.tar.gz: 0ef30d48d7cbeb5b85e33cb51fa5abebc67808495ff2bcb840b78c1b7c962fa62cb484b7f88a3ee84cc0506e13c6a06c0d173aa0fb03f9f0074443e0aa3e2397
|
data/README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
+
<img src="docs/assets/logo.png" alt="rubyx-py" width="200">
|
|
4
|
+
|
|
3
5
|
# Rubyx-py
|
|
4
6
|
|
|
5
7
|
**Call Python from Ruby. No microservices, no REST APIs, no serialization overhead.**
|
|
6
8
|
|
|
7
9
|
Powered by Rust for safety and performance. Built for Rails.
|
|
8
10
|
|
|
9
|
-
[](https://badge.fury.io/rb/rubyx)
|
|
11
|
+
[](https://badge.fury.io/rb/rubyx-py)
|
|
10
12
|
[](https://github.com/yinho999/rubyx/actions/workflows/ci.yml)
|
|
11
13
|
[](https://opensource.org/licenses/MIT)
|
|
12
14
|
[](https://www.ruby-lang.org)
|
|
@@ -34,15 +36,17 @@ Rubyx.stream(llm.generate("Tell me about Ruby")).each { |token| print token }
|
|
|
34
36
|
# Non-blocking — Ruby stays free while Python works
|
|
35
37
|
future = Rubyx.async_await("model.predict(data)", data: [1, 2, 3])
|
|
36
38
|
do_other_work()
|
|
37
|
-
result = future.
|
|
39
|
+
result = future.await # GVL released during wait, reacquired when ready
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
### Built with non-blocking in mind
|
|
41
43
|
|
|
42
44
|
- **`Rubyx.stream`** / **`Rubyx.nb_stream`** — release Ruby's GVL during iteration, other threads and Fibers keep
|
|
43
45
|
running
|
|
44
|
-
- **`Rubyx.async_await`** — spawns Python on background threads, returns a `Future` immediately
|
|
45
|
-
|
|
46
|
+
- **`Rubyx.async_await`** — spawns Python on background threads, returns a `Future` immediately; `future.await` releases
|
|
47
|
+
the GVL while waiting, reacquires when ready
|
|
48
|
+
- **`Rubyx.await`** — GVL released while waiting; returns native Ruby types for primitives, `RubyxObject` for complex
|
|
49
|
+
Python objects
|
|
46
50
|
|
|
47
51
|
Ideal for LLM streaming, ML inference, data pipelines, and high-concurrency Rails apps.
|
|
48
52
|
|
|
@@ -88,20 +92,27 @@ dependencies = []
|
|
|
88
92
|
### 1. Sync — call a Python function
|
|
89
93
|
|
|
90
94
|
```python
|
|
91
|
-
# app/python/
|
|
92
|
-
def
|
|
93
|
-
return f"Hello, {name}!"
|
|
95
|
+
# app/python/example.py
|
|
96
|
+
def hello(name="World"):
|
|
97
|
+
return f"Hello, {name}! From Python."
|
|
94
98
|
```
|
|
95
99
|
|
|
96
100
|
```ruby
|
|
101
|
+
|
|
97
102
|
class GreetingsController < ApplicationController
|
|
98
|
-
def
|
|
99
|
-
|
|
100
|
-
render json: { message: hello
|
|
103
|
+
def index
|
|
104
|
+
example = Rubyx.import('example')
|
|
105
|
+
render json: { message: example.hello(params[:name]).to_ruby }
|
|
101
106
|
end
|
|
102
107
|
end
|
|
103
108
|
```
|
|
104
109
|
|
|
110
|
+
```ruby
|
|
111
|
+
Rails.application.routes.draw do
|
|
112
|
+
root "greetings#index"
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
105
116
|
### 2. Streaming — iterate a Python generator
|
|
106
117
|
|
|
107
118
|
```python
|
|
@@ -112,6 +123,7 @@ def count_up(n):
|
|
|
112
123
|
```
|
|
113
124
|
|
|
114
125
|
```ruby
|
|
126
|
+
|
|
115
127
|
class CountController < ApplicationController
|
|
116
128
|
include ActionController::Live
|
|
117
129
|
|
|
@@ -141,6 +153,7 @@ async def delayed_greet(name, seconds=1):
|
|
|
141
153
|
```
|
|
142
154
|
|
|
143
155
|
```ruby
|
|
156
|
+
|
|
144
157
|
class TasksController < ApplicationController
|
|
145
158
|
def show
|
|
146
159
|
tasks = Rubyx.import('tasks')
|
|
@@ -148,7 +161,7 @@ class TasksController < ApplicationController
|
|
|
148
161
|
# Non-blocking — returns a Future immediately
|
|
149
162
|
future = Rubyx.async_await(tasks.delayed_greet(params[:name], seconds: 2))
|
|
150
163
|
do_other_work()
|
|
151
|
-
render json: { message: future.
|
|
164
|
+
render json: { message: future.await.to_ruby }
|
|
152
165
|
end
|
|
153
166
|
end
|
|
154
167
|
```
|
|
@@ -263,6 +276,7 @@ end
|
|
|
263
276
|
```
|
|
264
277
|
|
|
265
278
|
```ruby
|
|
279
|
+
|
|
266
280
|
class ChatController < ApplicationController
|
|
267
281
|
include ActionController::Live
|
|
268
282
|
|
|
@@ -378,13 +392,13 @@ ctx = Rubyx.context
|
|
|
378
392
|
ctx.eval("import asyncio")
|
|
379
393
|
ctx.eval("async def fetch(url): ...")
|
|
380
394
|
|
|
381
|
-
#
|
|
395
|
+
# GVL released while waiting, reacquired when ready
|
|
382
396
|
result = ctx.await("fetch(url)", url: "https://example.com")
|
|
383
397
|
|
|
384
398
|
# Non-blocking (returns Future)
|
|
385
399
|
future = ctx.async_await("fetch(url)", url: "https://example.com")
|
|
386
400
|
do_other_stuff()
|
|
387
|
-
result = future.
|
|
401
|
+
result = future.await # GVL released during wait, reacquired when ready
|
|
388
402
|
future.ready? # check without blocking
|
|
389
403
|
```
|
|
390
404
|
|
|
@@ -433,17 +447,17 @@ svc.Analyzer([1, 2, 3]).summary.to_ruby # => {"count" => 3, "sum" => 6}
|
|
|
433
447
|
|
|
434
448
|
## API Reference
|
|
435
449
|
|
|
436
|
-
| Method | Description
|
|
437
|
-
|
|
438
|
-
| `Rubyx.uv_init(toml, **opts)` | Setup Python env and initialize
|
|
439
|
-
| `Rubyx.import(name)` | Import a Python module
|
|
440
|
-
| `Rubyx.eval(code, **globals)` | Evaluate Python code
|
|
441
|
-
| `Rubyx.await(code, **globals)` | Run async code (
|
|
442
|
-
| `Rubyx.async_await(code, **globals)` | Run async code (returns Future) |
|
|
443
|
-
| `Rubyx.stream(iterable)` | Stream a Python generator
|
|
444
|
-
| `Rubyx.nb_stream(iterable)` | Non-blocking stream (GVL-aware)
|
|
445
|
-
| `Rubyx.context` | Create isolated Python context
|
|
446
|
-
| `Rubyx.initialized?` | Check if Python is ready
|
|
450
|
+
| Method | Description |
|
|
451
|
+
|--------------------------------------|-----------------------------------------------|
|
|
452
|
+
| `Rubyx.uv_init(toml, **opts)` | Setup Python env and initialize |
|
|
453
|
+
| `Rubyx.import(name)` | Import a Python module |
|
|
454
|
+
| `Rubyx.eval(code, **globals)` | Evaluate Python code |
|
|
455
|
+
| `Rubyx.await(code, **globals)` | Run async code (GVL released while waiting) |
|
|
456
|
+
| `Rubyx.async_await(code, **globals)` | Run async code (non-blocking, returns Future) |
|
|
457
|
+
| `Rubyx.stream(iterable)` | Stream a Python generator |
|
|
458
|
+
| `Rubyx.nb_stream(iterable)` | Non-blocking stream (GVL-aware) |
|
|
459
|
+
| `Rubyx.context` | Create isolated Python context |
|
|
460
|
+
| `Rubyx.initialized?` | Check if Python is ready |
|
|
447
461
|
|
|
448
462
|
| RubyxObject | |
|
|
449
463
|
|--------------------------|-------------------------------|
|
|
Binary file
|
data/ext/rubyx/src/context.rs
CHANGED
|
@@ -50,9 +50,12 @@ impl RubyxContext {
|
|
|
50
50
|
|
|
51
51
|
pub(crate) fn await_eval(&self, code: String) -> Result<magnus::Value, magnus::Error> {
|
|
52
52
|
let gil = self.api.ensure_gil();
|
|
53
|
-
let
|
|
53
|
+
let future = await_eval_with_globals(&code, self.globals, self.api);
|
|
54
54
|
self.api.release_gil(gil);
|
|
55
|
-
|
|
55
|
+
match future {
|
|
56
|
+
Ok(future) => Ok(crate::future::value_nonblocking(&future)?),
|
|
57
|
+
Err(e) => Err(e),
|
|
58
|
+
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
pub(crate) fn await_eval_with_globals(
|
|
@@ -61,12 +64,15 @@ impl RubyxContext {
|
|
|
61
64
|
globals_hash: RHash,
|
|
62
65
|
) -> Result<magnus::Value, magnus::Error> {
|
|
63
66
|
let gil = self.api.ensure_gil();
|
|
64
|
-
let
|
|
67
|
+
let future = match self.inject_globals(globals_hash) {
|
|
65
68
|
Ok(()) => await_eval_with_globals(&code, self.globals, self.api),
|
|
66
69
|
Err(e) => Err(e),
|
|
67
70
|
};
|
|
68
71
|
self.api.release_gil(gil);
|
|
69
|
-
|
|
72
|
+
match future {
|
|
73
|
+
Ok(future) => Ok(crate::future::value_nonblocking(&future)?),
|
|
74
|
+
Err(e) => Err(e),
|
|
75
|
+
}
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
/// Eval code to get a coroutine, then run it on a background thread.
|
|
@@ -703,20 +709,28 @@ mod tests {
|
|
|
703
709
|
ctx.eval("import asyncio\nasync def multiply(a, b): return a * b".to_string())
|
|
704
710
|
.expect("should define function");
|
|
705
711
|
|
|
712
|
+
// Inject globals into context
|
|
706
713
|
let hash = magnus::RHash::new();
|
|
707
714
|
hash.aset(ruby.sym_new("a"), 6_i64.into_value_with(ruby))
|
|
708
715
|
.unwrap();
|
|
709
716
|
hash.aset(ruby.sym_new("b"), 7_i64.into_value_with(ruby))
|
|
710
717
|
.unwrap();
|
|
718
|
+
ctx.inject_globals(hash).expect("inject should succeed");
|
|
711
719
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
720
|
+
// Manually create future (avoid ctx.await_eval_with_globals which
|
|
721
|
+
// nests ensure_gil/release_gil incorrectly with with_ruby_python's GIL)
|
|
722
|
+
let future = crate::eval::await_eval_with_globals("multiply(a, b)", ctx.globals, api)
|
|
723
|
+
.expect("should create future");
|
|
715
724
|
|
|
716
|
-
let
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
725
|
+
let tstate = api.save_thread();
|
|
726
|
+
while !future.is_ready() {
|
|
727
|
+
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
728
|
+
}
|
|
729
|
+
let result = future.value().expect("await should succeed");
|
|
730
|
+
drop(future);
|
|
731
|
+
api.restore_thread(tstate);
|
|
732
|
+
|
|
733
|
+
assert_eq!(i64::try_convert(result).unwrap(), 42);
|
|
720
734
|
});
|
|
721
735
|
}
|
|
722
736
|
|
|
@@ -725,7 +739,7 @@ mod tests {
|
|
|
725
739
|
fn test_context_await_with_globals_error() {
|
|
726
740
|
use crate::test_helpers::with_ruby_python;
|
|
727
741
|
use magnus::IntoValue;
|
|
728
|
-
with_ruby_python(|ruby,
|
|
742
|
+
with_ruby_python(|ruby, api| {
|
|
729
743
|
let ctx = super::RubyxContext::new().expect("context should create");
|
|
730
744
|
|
|
731
745
|
ctx.eval(
|
|
@@ -737,9 +751,23 @@ mod tests {
|
|
|
737
751
|
let hash = magnus::RHash::new();
|
|
738
752
|
hash.aset(ruby.sym_new("n"), (-5_i64).into_value_with(ruby))
|
|
739
753
|
.unwrap();
|
|
754
|
+
ctx.inject_globals(hash).expect("inject should succeed");
|
|
740
755
|
|
|
741
|
-
let
|
|
742
|
-
|
|
756
|
+
let future_result =
|
|
757
|
+
crate::eval::await_eval_with_globals("fail_if_neg(n)", ctx.globals, api);
|
|
758
|
+
|
|
759
|
+
match future_result {
|
|
760
|
+
Err(_) => {} // eval itself failed
|
|
761
|
+
Ok(future) => {
|
|
762
|
+
let tstate = api.save_thread();
|
|
763
|
+
while !future.is_ready() {
|
|
764
|
+
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
765
|
+
}
|
|
766
|
+
let result = future.value();
|
|
767
|
+
api.restore_thread(tstate);
|
|
768
|
+
assert!(result.is_err(), "should propagate ValueError");
|
|
769
|
+
}
|
|
770
|
+
}
|
|
743
771
|
});
|
|
744
772
|
}
|
|
745
773
|
|
|
@@ -768,6 +796,9 @@ mod tests {
|
|
|
768
796
|
api.release_gil(gil);
|
|
769
797
|
|
|
770
798
|
let tstate = api.save_thread();
|
|
799
|
+
while !future.is_ready() {
|
|
800
|
+
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
801
|
+
}
|
|
771
802
|
let result = future.value().expect("future should resolve");
|
|
772
803
|
drop(future);
|
|
773
804
|
api.restore_thread(tstate);
|
data/ext/rubyx/src/eval.rs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
use crate::future::RubyxFuture;
|
|
1
2
|
use crate::python_api::PythonApi;
|
|
2
3
|
use crate::python_ffi::PyObject;
|
|
3
4
|
use crate::python_guard::PyGuard;
|
|
@@ -234,55 +235,6 @@ fn inject_globals(
|
|
|
234
235
|
Ok(())
|
|
235
236
|
}
|
|
236
237
|
|
|
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
238
|
/// Rubyx.await(coroutine) — takes a RubyxObject wrapping a Python coroutine,
|
|
287
239
|
/// runs it with asyncio.run(), and returns the result.
|
|
288
240
|
pub(crate) fn rubyx_await(coroutine: Value) -> Result<Value, magnus::Error> {
|
|
@@ -290,10 +242,10 @@ pub(crate) fn rubyx_await(coroutine: Value) -> Result<Value, magnus::Error> {
|
|
|
290
242
|
let api = crate::api();
|
|
291
243
|
let gil = api.ensure_gil();
|
|
292
244
|
|
|
293
|
-
let
|
|
245
|
+
let future = RubyxFuture::from_coroutine(obj.as_ptr(), api);
|
|
294
246
|
|
|
295
247
|
api.release_gil(gil);
|
|
296
|
-
|
|
248
|
+
crate::future::value_nonblocking(&future)
|
|
297
249
|
}
|
|
298
250
|
|
|
299
251
|
/// Eval code in context globals to get a coroutine, then run it with asyncio.run().
|
|
@@ -302,7 +254,7 @@ pub(crate) fn await_eval_with_globals(
|
|
|
302
254
|
code: &str,
|
|
303
255
|
globals: *mut PyObject,
|
|
304
256
|
api: &'static PythonApi,
|
|
305
|
-
) -> Result<
|
|
257
|
+
) -> Result<RubyxFuture, magnus::Error> {
|
|
306
258
|
let py_coroutine = match api.run_string(code, PY_EVAL_INPUT, globals, globals) {
|
|
307
259
|
Ok(obj) if !obj.is_null() => obj,
|
|
308
260
|
Ok(_) => {
|
|
@@ -315,12 +267,12 @@ pub(crate) fn await_eval_with_globals(
|
|
|
315
267
|
};
|
|
316
268
|
return Err(err);
|
|
317
269
|
}
|
|
318
|
-
Err(e) =>
|
|
270
|
+
Err(e) => {
|
|
271
|
+
return Err(magnus::Error::new(runtime_error(), e));
|
|
272
|
+
}
|
|
319
273
|
};
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
api.decref(py_coroutine);
|
|
323
|
-
result
|
|
274
|
+
let future = RubyxFuture::from_coroutine(py_coroutine, api);
|
|
275
|
+
Ok(future)
|
|
324
276
|
}
|
|
325
277
|
|
|
326
278
|
pub(crate) fn rubyx_await_with_globals(
|
|
@@ -331,14 +283,16 @@ pub(crate) fn rubyx_await_with_globals(
|
|
|
331
283
|
let gil = api.ensure_gil();
|
|
332
284
|
|
|
333
285
|
let globals = make_globals(api);
|
|
334
|
-
let
|
|
286
|
+
let future = match inject_globals(&globals, globals_hash, api) {
|
|
335
287
|
Ok(()) => await_eval_with_globals(&code, globals.ptr(), api),
|
|
336
288
|
Err(e) => Err(e),
|
|
337
289
|
};
|
|
338
290
|
drop(globals);
|
|
339
|
-
|
|
340
291
|
api.release_gil(gil);
|
|
341
|
-
|
|
292
|
+
match future {
|
|
293
|
+
Ok(future) => Ok(crate::future::value_nonblocking(&future)?),
|
|
294
|
+
Err(e) => Err(e),
|
|
295
|
+
}
|
|
342
296
|
}
|
|
343
297
|
|
|
344
298
|
pub(crate) fn rubyx_async_await_with_globals(
|