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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9296be89cdf78ab0e25656b6643c5a7c407157d7e2767c8707fdf79a5369bdc8
4
+ data.tar.gz: 1f60ada7b7c16f048b5f8cd259526d7e5534b70fd7d53a6efa33d8ac0cb7c6c4
5
+ SHA512:
6
+ metadata.gz: 60ffe57e3abbb175e595c9c26bf0a8128fa872d358dfa9a121d267b6e54dcd00ad4ac413071540ed0c050b6d322eeac93cdbe191a94d81f7049e315398e910df
7
+ data.tar.gz: 8095f4af8147e579aadc3bd928886379070292ab01fbee68c69259caeefeaeaefb87970f501fba47a51ebe2192f5ed685c1455c88ee5f761a7cc02493b36acc6
data/Cargo.toml ADDED
@@ -0,0 +1,19 @@
1
+ [workspace.package]
2
+ version = "0.1.0"
3
+ edition = "2021"
4
+ description = "Ruby-Python bridge powered by Rust"
5
+ license = "MIT"
6
+
7
+ [workspace]
8
+ members = ["ext/rubyx"]
9
+ resolver = "2"
10
+
11
+
12
+ [workspace.dependencies]
13
+ magnus = { version = "0.8", features = ["embed"] }
14
+ libloading = "0.9"
15
+ libc = "1.0.0-alpha.3"
16
+ thiserror = "2.0"
17
+ rb-sys = { version = "0.9", features = ["link-ruby"] }
18
+ crossbeam-channel = "0.5"
19
+ serial_test = "3.4.0"
data/README.md ADDED
@@ -0,0 +1,469 @@
1
+ <div align="center">
2
+
3
+ # Rubyx-py
4
+
5
+ **Call Python from Ruby. No microservices, no REST APIs, no serialization overhead.**
6
+
7
+ Powered by Rust for safety and performance. Built for Rails.
8
+
9
+ [![Gem Version](https://badge.fury.io/rb/rubyx.svg)](https://badge.fury.io/rb/rubyx)
10
+ [![CI](https://github.com/yinho999/rubyx/actions/workflows/ci.yml/badge.svg)](https://github.com/yinho999/rubyx/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
12
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.0-red.svg)](https://www.ruby-lang.org)
13
+ [![Rust](https://img.shields.io/badge/Rust-powered-orange.svg)](https://www.rust-lang.org)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ```ruby
20
+ np = Rubyx.import('numpy')
21
+ np.array([1, 2, 3]).mean().to_ruby # => 2.0
22
+ ```
23
+
24
+ ```ruby
25
+ Rubyx.eval("sum(items)", items: [1, 2, 3, 4]) # => 10
26
+ ```
27
+
28
+ ```ruby
29
+ # Stream LLM tokens in real-time
30
+ Rubyx.stream(llm.generate("Tell me about Ruby")).each { |token| print token }
31
+ ```
32
+
33
+ ```ruby
34
+ # Non-blocking — Ruby stays free while Python works
35
+ future = Rubyx.async_await("model.predict(data)", data: [1, 2, 3])
36
+ do_other_work()
37
+ result = future.value # get result when ready
38
+ ```
39
+
40
+ ### Built with non-blocking in mind
41
+
42
+ - **`Rubyx.stream`** / **`Rubyx.nb_stream`** — release Ruby's GVL during iteration, other threads and Fibers keep
43
+ running
44
+ - **`Rubyx.async_await`** — spawns Python on background threads, returns a `Future` immediately
45
+ - **`Rubyx.await`** — blocks only when you choose to
46
+
47
+ Ideal for LLM streaming, ML inference, data pipelines, and high-concurrency Rails apps.
48
+
49
+ ## Install
50
+
51
+ ```ruby
52
+ # Gemfile
53
+ gem 'rubyx-py'
54
+ ```
55
+
56
+ Python is auto-managed by [uv](https://github.com/astral-sh/uv). No manual install needed.
57
+
58
+ ## Rails Setup Simple Example
59
+
60
+ ```bash
61
+ rails generate rubyx:install
62
+ ```
63
+
64
+ Creates `config/initializers/rubyx.rb`, `pyproject.toml`, and `app/python/`.
65
+
66
+ ### Configuration
67
+
68
+ ```ruby
69
+ # config/initializers/rubyx.rb
70
+ Rubyx::Rails.configure do |config|
71
+ config.pyproject_path = Rails.root.join('pyproject.toml')
72
+ config.auto_init = true
73
+ config.python_paths = [Rails.root.join('app/python').to_s]
74
+ end
75
+ ```
76
+
77
+ ### Python dependencies
78
+
79
+ ```toml
80
+ # pyproject.toml
81
+ [project]
82
+ name = "my-app"
83
+ version = "0.1.0"
84
+ requires-python = ">=3.12"
85
+ dependencies = []
86
+ ```
87
+
88
+ ### 1. Sync — call a Python function
89
+
90
+ ```python
91
+ # app/python/hello.py
92
+ def greet(name):
93
+ return f"Hello, {name}!"
94
+ ```
95
+
96
+ ```ruby
97
+ class GreetingsController < ApplicationController
98
+ def show
99
+ hello = Rubyx.import('hello')
100
+ render json: { message: hello.greet(params[:name]).to_ruby }
101
+ end
102
+ end
103
+ ```
104
+
105
+ ### 2. Streaming — iterate a Python generator
106
+
107
+ ```python
108
+ # app/python/count.py
109
+ def count_up(n):
110
+ for i in range(n):
111
+ yield f"Step {i + 1}"
112
+ ```
113
+
114
+ ```ruby
115
+ class CountController < ApplicationController
116
+ include ActionController::Live
117
+
118
+ def stream
119
+ response.headers['Content-Type'] = 'text/event-stream'
120
+
121
+ counter = Rubyx.import('count')
122
+ Rubyx.stream(counter.count_up(5)).each do |msg|
123
+ response.stream.write("data: #{msg}\n\n")
124
+ end
125
+ ensure
126
+ response.stream.close
127
+ end
128
+ end
129
+ ```
130
+
131
+ ### 3. Async — non-blocking Python calls
132
+
133
+ ```python
134
+ # app/python/tasks.py
135
+ import asyncio
136
+
137
+
138
+ async def delayed_greet(name, seconds=1):
139
+ await asyncio.sleep(seconds)
140
+ return f"Hello, {name}! (after {seconds}s)"
141
+ ```
142
+
143
+ ```ruby
144
+ class TasksController < ApplicationController
145
+ def show
146
+ tasks = Rubyx.import('tasks')
147
+
148
+ # Non-blocking — returns a Future immediately
149
+ future = Rubyx.async_await(tasks.delayed_greet(params[:name], seconds: 2))
150
+ do_other_work()
151
+ render json: { message: future.value.to_ruby }
152
+ end
153
+ end
154
+ ```
155
+
156
+ ## Rails Setup Advanced LLM Example
157
+
158
+ ```bash
159
+ rails generate rubyx:install
160
+ ```
161
+
162
+ Creates `config/initializers/rubyx.rb`, `pyproject.toml`, and `app/python/`.
163
+
164
+ ### Configuration
165
+
166
+ ```ruby
167
+ # config/initializers/rubyx.rb
168
+ Rubyx::Rails.configure do |config|
169
+ config.pyproject_path = Rails.root.join('pyproject.toml')
170
+ config.auto_init = true
171
+ config.python_paths = [Rails.root.join('app/python').to_s]
172
+ end
173
+ ```
174
+
175
+ ### Python dependencies
176
+
177
+ ```toml
178
+ # pyproject.toml
179
+ [project]
180
+ name = "my-app"
181
+ version = "0.1.0"
182
+ requires-python = ">=3.12"
183
+ dependencies = ["numpy", "pandas", "transformers"]
184
+ ```
185
+
186
+ ### Write Python code
187
+
188
+ ```python
189
+ # app/python/services/text_processing.py
190
+ class TextAnalyzer:
191
+ def __init__(self, text):
192
+ self.text = text
193
+ self._words = text.split()
194
+
195
+ def summary(self):
196
+ return {
197
+ "word_count": len(self._words),
198
+ "unique_words": len(set(self._words)),
199
+ "avg_word_length": round(
200
+ sum(len(w) for w in self._words) / max(len(self._words), 1), 2
201
+ ),
202
+ }
203
+ ```
204
+
205
+ ### Call it from Rails
206
+
207
+ ```ruby
208
+
209
+ class AnalysisController < ApplicationController
210
+ def analyze
211
+ tp = Rubyx.import('services.text_processing')
212
+ analyzer = tp.TextAnalyzer(params[:text])
213
+ render json: analyzer.summary.to_ruby
214
+ end
215
+ end
216
+ ```
217
+
218
+ ### SSE streaming (LLM-style)
219
+
220
+ ```python
221
+ # app/python/services/llm.py
222
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
223
+ import threading
224
+
225
+ _model, _tokenizer = None, None
226
+
227
+
228
+ def load_model(name="Qwen/Qwen2.5-0.5B-Instruct"):
229
+ global _model, _tokenizer
230
+ _tokenizer = AutoTokenizer.from_pretrained(name)
231
+ _model = AutoModelForCausalLM.from_pretrained(name, torch_dtype="auto")
232
+
233
+
234
+ def stream_generate(prompt, max_tokens=256):
235
+ messages = [{"role": "user", "content": prompt}]
236
+ text = _tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
237
+ inputs = _tokenizer([text], return_tensors="pt").to(_model.device)
238
+ streamer = TextIteratorStreamer(_tokenizer, skip_prompt=True, skip_special_tokens=True)
239
+
240
+ thread = threading.Thread(target=_model.generate,
241
+ kwargs={**inputs, "max_new_tokens": max_tokens, "streamer": streamer})
242
+ thread.start()
243
+ for token in streamer:
244
+ if token:
245
+ yield token
246
+ thread.join()
247
+ ```
248
+
249
+ ```ruby
250
+ # config/initializers/rubyx.rb
251
+ Rubyx::Rails.configure do |config|
252
+ config.pyproject_path = Rails.root.join('pyproject.toml')
253
+ config.auto_init = true
254
+ config.python_paths = [Rails.root.join('app/python').to_s]
255
+ end
256
+
257
+ # Load models once at boot — must be inside after_initialize
258
+ # so Python is already initialized by the Railtie
259
+ Rails.application.config.after_initialize do
260
+ llm = Rubyx.import('services.llm')
261
+ llm.load_model("Qwen/Qwen2.5-0.5B-Instruct")
262
+ end
263
+ ```
264
+
265
+ ```ruby
266
+ class ChatController < ApplicationController
267
+ include ActionController::Live
268
+
269
+ def stream
270
+ llm = Rubyx.import('services.llm')
271
+ response.headers['Content-Type'] = 'text/event-stream'
272
+
273
+ Rubyx.stream(llm.stream_generate(params[:prompt])).each do |token|
274
+ token_str = token.to_s.gsub("\n", "\\n")
275
+ response.stream.write("data: #{token_str}\n\n")
276
+ end
277
+ response.stream.write("data: [DONE]\n\n")
278
+ ensure
279
+ response.stream.close
280
+ end
281
+ end
282
+ ```
283
+
284
+ ### Rake tasks
285
+
286
+ ```bash
287
+ rake rubyx:init # Initialize Python environment
288
+ rake rubyx:status # Check environment status
289
+ rake rubyx:packages # List installed Python packages
290
+ rake rubyx:clear_cache # Clear cached environments
291
+ ```
292
+
293
+ ## Standalone Setup
294
+
295
+ ```ruby
296
+ require 'rubyx'
297
+
298
+ Rubyx.uv_init <<~TOML
299
+ [project]
300
+ name = "my-script"
301
+ version = "0.1.0"
302
+ requires-python = ">=3.12"
303
+ dependencies = ["numpy"]
304
+ TOML
305
+
306
+ np = Rubyx.import('numpy')
307
+ np.array([1, 2, 3, 4, 5]).std().to_ruby # => 1.4142135623730951
308
+ ```
309
+
310
+ ## Eval with Globals
311
+
312
+ Pass Ruby data directly into Python:
313
+
314
+ ```ruby
315
+ Rubyx.eval("x ** 2 + y ** 2", x: 3, y: 4).to_ruby # => 25
316
+ Rubyx.eval("f'Hello, {name}!'", name: "World").to_ruby # => "Hello, World!"
317
+ Rubyx.eval("max(items)", items: [3, 1, 4, 1, 5]).to_ruby # => 5
318
+ ```
319
+
320
+ Supports: Integer, Float, String, Symbol, Bool, nil, Array, Hash, and RubyxObject.
321
+
322
+ ## Python Objects
323
+
324
+ Python objects are wrapped as `RubyxObject`:
325
+
326
+ ```ruby
327
+ os = Rubyx.import('os')
328
+ os.getcwd().to_ruby # => "/home/user"
329
+ os.path.exists("/tmp").to_ruby # => true
330
+
331
+ # Subscript access
332
+ d = Rubyx.eval("{'a': 1, 'b': 2}")
333
+ d['a'].to_ruby # => 1
334
+ d['c'] = 3
335
+
336
+ # Enumerable
337
+ py_list = Rubyx.eval("[1, 2, 3, 4, 5]")
338
+ py_list.map { |x| x.to_ruby * 2 } # => [2, 4, 6, 8, 10]
339
+ py_list.select { |x| x.to_ruby > 3 } # filtered RubyxObjects
340
+
341
+ # Introspection
342
+ py_list.truthy? # => true
343
+ py_list.callable? # => false
344
+ py_list.py_type # => "list"
345
+ ```
346
+
347
+ ## Context
348
+
349
+ Persistent state across multiple eval calls:
350
+
351
+ ```ruby
352
+ ctx = Rubyx.context
353
+
354
+ ctx.eval("import math")
355
+ ctx.eval("data = [1, 2, 3, 4, 5]")
356
+ ctx.eval("avg = sum(data) / len(data)")
357
+ ctx.eval("avg").to_ruby # => 3.0
358
+
359
+ # Inject Ruby data into context
360
+ ctx.eval("total = base + offset", base: 100, offset: 42)
361
+ ctx.eval("total").to_ruby # => 142
362
+ ```
363
+
364
+ ## Streaming
365
+
366
+ ```ruby
367
+ gen = Rubyx.eval("(x ** 2 for x in range(5))")
368
+ Rubyx.stream(gen).each { |val| puts val } # 0, 1, 4, 9, 16
369
+
370
+ # Non-blocking (releases GVL for other Ruby threads)
371
+ Rubyx.nb_stream(gen).each { |val| process(val) }
372
+ ```
373
+
374
+ ## Async / Await
375
+
376
+ ```ruby
377
+ ctx = Rubyx.context
378
+ ctx.eval("import asyncio")
379
+ ctx.eval("async def fetch(url): ...")
380
+
381
+ # Blocking
382
+ result = ctx.await("fetch(url)", url: "https://example.com")
383
+
384
+ # Non-blocking (returns Future)
385
+ future = ctx.async_await("fetch(url)", url: "https://example.com")
386
+ do_other_stuff()
387
+ result = future.value # blocks only when needed
388
+ future.ready? # check without blocking
389
+ ```
390
+
391
+ ## Error Handling
392
+
393
+ Python exceptions map to Ruby classes:
394
+
395
+ ```ruby
396
+
397
+ begin
398
+ Rubyx.eval('{}["missing"]')
399
+ rescue Rubyx::KeyError => e
400
+ puts e.message # includes Python traceback
401
+ end
402
+ ```
403
+
404
+ | Python | Ruby |
405
+ |---------------------------------------|-------------------------------|
406
+ | `KeyError` | `Rubyx::KeyError` |
407
+ | `IndexError` | `Rubyx::IndexError` |
408
+ | `ValueError` | `Rubyx::ValueError` |
409
+ | `TypeError` | `Rubyx::TypeError` |
410
+ | `AttributeError` | `Rubyx::AttributeError` |
411
+ | `ImportError` / `ModuleNotFoundError` | `Rubyx::ImportError` |
412
+ | `SyntaxError` | `SyntaxError` (Ruby built-in) |
413
+ | Everything else | `Rubyx::PythonError` |
414
+
415
+ All inherit from `Rubyx::Error` (`StandardError`).
416
+
417
+ ## Local Python Files
418
+
419
+ ```python
420
+ # app/python/services/analyzer.py
421
+ class Analyzer:
422
+ def __init__(self, data):
423
+ self.data = data
424
+
425
+ def summary(self):
426
+ return {"count": len(self.data), "sum": sum(self.data)}
427
+ ```
428
+
429
+ ```ruby
430
+ svc = Rubyx.import('services.analyzer')
431
+ svc.Analyzer([1, 2, 3]).summary.to_ruby # => {"count" => 3, "sum" => 6}
432
+ ```
433
+
434
+ ## API Reference
435
+
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 |
447
+
448
+ | RubyxObject | |
449
+ |--------------------------|-------------------------------|
450
+ | `.to_ruby` | Convert to native Ruby type |
451
+ | `.to_s` / `.inspect` | String / repr |
452
+ | `.method_missing` | Delegates to Python |
453
+ | `[]` / `[]=` / `.delete` | Subscript access |
454
+ | `.each` | Iterate (includes Enumerable) |
455
+ | `.truthy?` / `.falsy?` | Python truthiness |
456
+ | `.callable?` | Check if callable |
457
+ | `.py_type` | Python type name |
458
+
459
+ ## Requirements
460
+
461
+ - Ruby >= 3.0
462
+ - Rust (for building from source)
463
+ - Python >= 3.12 (auto-managed by uv)
464
+
465
+ Precompiled gems available for Linux and macOS (x86_64 and ARM64).
466
+
467
+ ## License
468
+
469
+ MIT
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "rubyx"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ publish = false
6
+
7
+ [lib]
8
+ crate-type = ["cdylib"]
9
+
10
+ [dependencies]
11
+ magnus = { workspace = true }
12
+ libloading = { workspace = true }
13
+ libc = { workspace = true }
14
+ thiserror = { workspace = true }
15
+ rb-sys = { workspace = true }
16
+ crossbeam-channel = { workspace = true }
17
+
18
+ [dev-dependencies]
19
+ serial_test = { workspace = true }
@@ -0,0 +1,22 @@
1
+ require 'mkmf'
2
+
3
+ unless respond_to?(:dummy_makefile)
4
+ def dummy_makefile(dir)
5
+ ["SHELL = /bin/sh",
6
+ "ECHO = @echo",
7
+ "Q = @",
8
+ "TOUCH = touch",
9
+ "COPY = cp",
10
+ "RM_RF = rm -rf",
11
+ "MAKEDIRS = mkdir -p",
12
+ "INSTALL_PROG = install -m 0755",
13
+ "all install static install-so install-rb:",
14
+ "pre-install-rb: install-rb",
15
+ "clean distclean realclean:",
16
+ "\t@-$(RM_RF) mkmf.log"]
17
+ end
18
+ end
19
+
20
+ require 'rb_sys/mkmf'
21
+
22
+ create_rust_makefile('rubyx/rubyx')