rubyx-py 0.1.1 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 779abf6ff7235f29633d44399308ee23f3a6244b4f23e3f0fb0a056914602ea7
4
- data.tar.gz: a08a4226433cecb9533b09e23e5867f248b5fcec4b994dc3623a7f5698c33475
3
+ metadata.gz: cf3d6decfdb8c4bf6dd12f891389511ce96deeaf3efffb1664bb35d6b9b6066f
4
+ data.tar.gz: 7dcbdcceac38a4a8dc0908e0f11fc19237080025fbb36e8d7cb8d38d0c8ec58f
5
5
  SHA512:
6
- metadata.gz: 85de3fdcbe4f96fc99db882a0e06c25347f31cb5e1d474e0d330698be781ee215163590ff68ed368aa9ecff3dd605dfd509349458307e33eedfff6c9137c4473
7
- data.tar.gz: 2ed6beee3da490dda53cb6d49897d3c092870404e1f93c53d2e0fdeb22a1bf59159c14a67ab60411601f493fa06df4e19aae09ab4914bbe9c5413899f616f7ad
6
+ metadata.gz: 63cd8eba2c550fd0f4728d6a836ae3e2c80c0b81421830e07938a4d6acba4823e9cc11fb73e767384e33993c01fe4e4138097d9b26e53b361d87d46281ac8de4
7
+ data.tar.gz: 27c3168265e2e40186c2e0cb554192b03fd484400e34b788d125f9d4347abfecd9d3be260bf093deec48fc38f350c4655ce1e8632b3e7729f41cf967fce6d7ca
data/README.md CHANGED
@@ -1,17 +1,21 @@
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
- Powered by Rust for safety and performance. Built for Rails.
9
+ Powered by Rust for safety and performance. Built for Rails. Inspired by [Pythonx](https://github.com/livebook-dev/pythonx)
8
10
 
9
- [![Gem Version](https://badge.fury.io/rb/rubyx.svg)](https://badge.fury.io/rb/rubyx)
11
+ [![Gem Version](https://badge.fury.io/rb/rubyx-py.svg)](https://badge.fury.io/rb/rubyx-py)
10
12
  [![CI](https://github.com/yinho999/rubyx/actions/workflows/ci.yml/badge.svg)](https://github.com/yinho999/rubyx/actions/workflows/ci.yml)
11
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
12
14
  [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.0-red.svg)](https://www.ruby-lang.org)
13
15
  [![Rust](https://img.shields.io/badge/Rust-powered-orange.svg)](https://www.rust-lang.org)
14
-
16
+ > *"Rubyx showed that the proxy-object pattern works beautifully for cross-language bridges in Rust, and its magnus + rb-sys architecture is exactly what [Boax](https://intertwingly.net/blog/2026/03/25/Calling-JavaScript-from-Ruby.html) uses."*
17
+ >
18
+ > — [Sam Ruby](https://en.wikipedia.org/wiki/Sam_Ruby), author of *Agile Web Development with Rails*
15
19
  </div>
16
20
 
17
21
  ---
@@ -34,15 +38,17 @@ Rubyx.stream(llm.generate("Tell me about Ruby")).each { |token| print token }
34
38
  # Non-blocking — Ruby stays free while Python works
35
39
  future = Rubyx.async_await("model.predict(data)", data: [1, 2, 3])
36
40
  do_other_work()
37
- result = future.value # get result when ready
41
+ result = future.await # GVL released during wait, reacquired when ready
38
42
  ```
39
43
 
40
44
  ### Built with non-blocking in mind
41
45
 
42
46
  - **`Rubyx.stream`** / **`Rubyx.nb_stream`** — release Ruby's GVL during iteration, other threads and Fibers keep
43
47
  running
44
- - **`Rubyx.async_await`** — spawns Python on background threads, returns a `Future` immediately
45
- - **`Rubyx.await`** blocks only when you choose to
48
+ - **`Rubyx.async_await`** — spawns Python on background threads, returns a `Future` immediately; `future.await` releases
49
+ the GVL while waiting, reacquires when ready
50
+ - **`Rubyx.await`** — GVL released while waiting; returns native Ruby types for primitives, `RubyxObject` for complex
51
+ Python objects
46
52
 
47
53
  Ideal for LLM streaming, ML inference, data pipelines, and high-concurrency Rails apps.
48
54
 
@@ -88,20 +94,27 @@ dependencies = []
88
94
  ### 1. Sync — call a Python function
89
95
 
90
96
  ```python
91
- # app/python/hello.py
92
- def greet(name):
93
- return f"Hello, {name}!"
97
+ # app/python/example.py
98
+ def hello(name):
99
+ return f"Hello, {name}! From Python."
94
100
  ```
95
101
 
96
102
  ```ruby
103
+
97
104
  class GreetingsController < ApplicationController
98
- def show
99
- hello = Rubyx.import('hello')
100
- render json: { message: hello.greet(params[:name]).to_ruby }
105
+ def index
106
+ example = Rubyx.import('example')
107
+ render json: { message: example.hello(params[:name] || 'World').to_ruby }
101
108
  end
102
109
  end
103
110
  ```
104
111
 
112
+ ```ruby
113
+ Rails.application.routes.draw do
114
+ root "greetings#index"
115
+ end
116
+ ```
117
+
105
118
  ### 2. Streaming — iterate a Python generator
106
119
 
107
120
  ```python
@@ -112,6 +125,7 @@ def count_up(n):
112
125
  ```
113
126
 
114
127
  ```ruby
128
+
115
129
  class CountController < ApplicationController
116
130
  include ActionController::Live
117
131
 
@@ -141,14 +155,15 @@ async def delayed_greet(name, seconds=1):
141
155
  ```
142
156
 
143
157
  ```ruby
158
+
144
159
  class TasksController < ApplicationController
145
160
  def show
146
161
  tasks = Rubyx.import('tasks')
147
162
 
148
163
  # Non-blocking — returns a Future immediately
149
- future = Rubyx.async_await(tasks.delayed_greet(params[:name], seconds: 2))
164
+ future = Rubyx.async_await(tasks.delayed_greet(params[:name] || 'World', seconds: 2))
150
165
  do_other_work()
151
- render json: { message: future.value.to_ruby }
166
+ render json: { message: future.await.to_ruby }
152
167
  end
153
168
  end
154
169
  ```
@@ -263,6 +278,7 @@ end
263
278
  ```
264
279
 
265
280
  ```ruby
281
+
266
282
  class ChatController < ApplicationController
267
283
  include ActionController::Live
268
284
 
@@ -317,7 +333,29 @@ Rubyx.eval("f'Hello, {name}!'", name: "World").to_ruby # => "Hello, World!"
317
333
  Rubyx.eval("max(items)", items: [3, 1, 4, 1, 5]).to_ruby # => 5
318
334
  ```
319
335
 
320
- Supports: Integer, Float, String, Symbol, Bool, nil, Array, Hash, and RubyxObject.
336
+ Supports: Integer, Float, String, Symbol, Bool, nil, Array, Hash, binary String (ASCII-8BIT), and RubyxObject.
337
+
338
+ ## Bytes / Binary Data
339
+
340
+ Python `bytes` and `bytearray` convert to Ruby `String` with `ASCII-8BIT` encoding. Ruby binary strings (`.b`) convert to Python `bytes`:
341
+
342
+ ```ruby
343
+ # Python bytes → Ruby
344
+ Rubyx.eval("b'hello'").to_ruby # => "hello" (ASCII-8BIT)
345
+ Rubyx.eval("bytearray(b'hello')").to_ruby # => "hello" (ASCII-8BIT)
346
+
347
+ # Ruby → Python
348
+ ctx = Rubyx.context
349
+ ctx.eval("type(data).__name__", data: "hello".b) # => "bytes"
350
+ ctx.eval("type(data).__name__", data: "hello") # => "str"
351
+
352
+ # Roundtrip binary data
353
+ ctx.eval("data", data: "\xff\x00\xfe".b).to_ruby # => "\xFF\x00\xFE" (ASCII-8BIT)
354
+
355
+ # Works with Python stdlib
356
+ ctx.eval("import base64")
357
+ ctx.eval("base64.b64encode(raw)", raw: "Hello".b).to_ruby # => "SGVsbG8=" (ASCII-8BIT)
358
+ ```
321
359
 
322
360
  ## Python Objects
323
361
 
@@ -378,13 +416,13 @@ ctx = Rubyx.context
378
416
  ctx.eval("import asyncio")
379
417
  ctx.eval("async def fetch(url): ...")
380
418
 
381
- # Blocking
419
+ # GVL released while waiting, reacquired when ready
382
420
  result = ctx.await("fetch(url)", url: "https://example.com")
383
421
 
384
422
  # Non-blocking (returns Future)
385
423
  future = ctx.async_await("fetch(url)", url: "https://example.com")
386
424
  do_other_stuff()
387
- result = future.value # blocks only when needed
425
+ result = future.await # GVL released during wait, reacquired when ready
388
426
  future.ready? # check without blocking
389
427
  ```
390
428
 
@@ -433,17 +471,17 @@ svc.Analyzer([1, 2, 3]).summary.to_ruby # => {"count" => 3, "sum" => 6}
433
471
 
434
472
  ## API Reference
435
473
 
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 (blocking) |
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 |
474
+ | Method | Description |
475
+ |--------------------------------------|-----------------------------------------------|
476
+ | `Rubyx.uv_init(toml, **opts)` | Setup Python env and initialize |
477
+ | `Rubyx.import(name)` | Import a Python module |
478
+ | `Rubyx.eval(code, **globals)` | Evaluate Python code |
479
+ | `Rubyx.await(code, **globals)` | Run async code (GVL released while waiting) |
480
+ | `Rubyx.async_await(code, **globals)` | Run async code (non-blocking, returns Future) |
481
+ | `Rubyx.stream(iterable)` | Stream a Python generator |
482
+ | `Rubyx.nb_stream(iterable)` | Non-blocking stream (GVL-aware) |
483
+ | `Rubyx.context` | Create isolated Python context |
484
+ | `Rubyx.initialized?` | Check if Python is ready |
447
485
 
448
486
  | RubyxObject | |
449
487
  |--------------------------|-------------------------------|
@@ -456,6 +494,37 @@ svc.Analyzer([1, 2, 3]).summary.to_ruby # => {"count" => 3, "sum" => 6}
456
494
  | `.callable?` | Check if callable |
457
495
  | `.py_type` | Python type name |
458
496
 
497
+ ## Type Conversion
498
+
499
+ | Python | Ruby | Notes |
500
+ |-----------------------|------------------------------|------------------------|
501
+ | `int` | `Integer` | |
502
+ | `float` | `Float` | |
503
+ | `str` | `String` (UTF-8) | |
504
+ | `bytes` | `String` (ASCII-8BIT) | binary data |
505
+ | `bytearray` | `String` (ASCII-8BIT) | binary data |
506
+ | `bool` | `true` / `false` | |
507
+ | `None` | `nil` | |
508
+ | `list` / `tuple` | `Array` | |
509
+ | `dict` | `Hash` | |
510
+ | `set` / `frozenset` | `Array` | |
511
+ | everything else | `RubyxObject` | proxy to Python object |
512
+
513
+ **Ruby → Python** (via globals/kwargs):
514
+
515
+ | Ruby | Python |
516
+ |--------------------------------|-------------|
517
+ | `Integer` | `int` |
518
+ | `Float` | `float` |
519
+ | `String` (UTF-8) | `str` |
520
+ | `String` (ASCII-8BIT / `.b`) | `bytes` |
521
+ | `true` / `false` | `bool` |
522
+ | `nil` | `None` |
523
+ | `Array` | `list` |
524
+ | `Hash` | `dict` |
525
+ | `Symbol` | `str` |
526
+ | `RubyxObject` | original |
527
+
459
528
  ## Requirements
460
529
 
461
530
  - Ruby >= 3.0
Binary file
@@ -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 result = await_eval_with_globals(&code, self.globals, self.api);
53
+ let future = await_eval_with_globals(&code, self.globals, self.api);
54
54
  self.api.release_gil(gil);
55
- result
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 result = match self.inject_globals(globals_hash) {
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
- result
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
- let result = ctx
713
- .await_eval_with_globals("multiply(a, b)".to_string(), hash)
714
- .expect("await should succeed");
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 obj =
717
- magnus::typed_data::Obj::<crate::rubyx_object::RubyxObject>::try_convert(result)
718
- .expect("should be RubyxObject");
719
- assert_eq!(api.long_to_i64(obj.as_ptr()), 42);
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, _api| {
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 result = ctx.await_eval_with_globals("fail_if_neg(n)".to_string(), hash);
742
- assert!(result.is_err(), "should propagate ValueError");
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);
@@ -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 result = run_asyncio(obj.as_ptr(), api);
245
+ let future = RubyxFuture::from_coroutine(obj.as_ptr(), api);
294
246
 
295
247
  api.release_gil(gil);
296
- result
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<Value, magnus::Error> {
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) => return Err(Error::new(runtime_error(), e)),
270
+ Err(e) => {
271
+ return Err(magnus::Error::new(runtime_error(), e));
272
+ }
319
273
  };
320
-
321
- let result = run_asyncio(py_coroutine, api);
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 result = match inject_globals(&globals, globals_hash, api) {
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
- result
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(