rubyx-py 0.1.0-aarch64-linux

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: 310b31e267a6ba428456f3f2c27b7ab6efdb62b85643cb59e5fadbdd402faf58
4
+ data.tar.gz: 920b88249171d979b02461f6571b759c6b2048ba7a6e31805ffd69d10d7d973a
5
+ SHA512:
6
+ metadata.gz: 9b4d8d5f9bb99d73908341a377d28c9cbf31be4d944f3bb93aa40f70aec525e720f3b85e1fc0d81b72321d846a0ee1ede27e603d6d54dfac74fe8447da9925e9
7
+ data.tar.gz: c8b593f701c843a543e87c447e8595f72f38f334e986b3be4845c0f05bba248b9cbecb337e38a39bafa74aef797c91f1563e9f3d4798a14fa1c28d21f96b0048
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,31 @@
1
+ import asyncio
2
+ from asyncio import AbstractEventLoop
3
+ from typing import AsyncGenerator
4
+
5
+
6
+ class AsyncToSync:
7
+ def __init__(self, async_gen: AsyncGenerator):
8
+ self._agen = async_gen
9
+ self._loop: AbstractEventLoop = asyncio.new_event_loop()
10
+
11
+
12
+ def __iter__(self):
13
+ return self
14
+
15
+ def __next__(self):
16
+ try:
17
+ coro = self._agen.__anext__()
18
+ return self._loop.run_until_complete(coro)
19
+ except StopAsyncIteration:
20
+ self._loop.close()
21
+ raise StopIteration
22
+ except BaseException:
23
+ self._loop.close()
24
+ raise
25
+
26
+ def close(self):
27
+ try:
28
+ self._loop.run_until_complete(self._agen.aclose())
29
+ except Exception:
30
+ pass
31
+ self._loop.close()
@@ -0,0 +1,24 @@
1
+ module Rubyx
2
+ module Generators
3
+ class InstallGenerator < ::Rails::Generators::Base
4
+ source_root File.expand_path('templates', __dir__)
5
+
6
+ def create_pyproject
7
+ copy_file 'pyproject.toml', 'pyproject.toml'
8
+ end
9
+
10
+ def create_initializer
11
+ copy_file 'rubyx_initializer.rb', 'config/initializers/rubyx.rb'
12
+ end
13
+
14
+ def create_python_directory
15
+ empty_directory 'app/python'
16
+ copy_file 'example.py', 'app/python/example.py'
17
+ end
18
+
19
+ def add_gitignore
20
+ append_to_file '.gitignore', "\n# Python (managed by rubyx-py)\n.venv/\n" if File.exist?('.gitignore')
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ Rubyx::Rails.configure do |config|
2
+ # Path to your Python project's pyproject.toml
3
+ config.pyproject_path = Rails.root.join('pyproject.toml')
4
+
5
+ # Auto-initialize Python when Rails boots
6
+ # Set to false for forking servers (Puma workers) — use on_worker_boot instead
7
+ config.auto_init = true
8
+
9
+ # Directories to add to Python's sys.path (makes .py files importable)
10
+ config.python_paths = [Rails.root.join('app/python').to_s]
11
+
12
+ # Use system uv instead of auto-downloading (optional)
13
+ # config.uv_path = `which uv`.strip
14
+
15
+ # Extra arguments for uv sync (optional)
16
+ # config.uv_args = ['--extra', 'ml']
17
+ end
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,27 @@
1
+ module Rubyx
2
+ class Context
3
+ def eval(code, **globals)
4
+ if globals.empty?
5
+ _eval(code.to_s)
6
+ else
7
+ _eval_with_globals(code.to_s, globals)
8
+ end
9
+ end
10
+
11
+ def await(code, **globals)
12
+ if globals.empty?
13
+ _await(code.to_s)
14
+ else
15
+ _await_with_globals(code.to_s, globals)
16
+ end
17
+ end
18
+
19
+ def async_await(code, **globals)
20
+ if globals.empty?
21
+ _async_await(code.to_s)
22
+ else
23
+ _async_await_with_globals(code.to_s, globals)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,30 @@
1
+ module Rubyx
2
+ VALID_MODULE_NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*\z/
3
+
4
+ class Error < StandardError; end
5
+
6
+ class PythonError < Error; end
7
+
8
+ class ImportError < PythonError; end
9
+
10
+ class InvalidModuleNameError < Error; end
11
+
12
+ class KeyError < Error; end
13
+
14
+ class IndexError < Error; end
15
+
16
+ class ValueError < Error; end
17
+
18
+ class AttributeError < Error; end
19
+
20
+ class TypeError < Error; end
21
+
22
+ module Uv
23
+ class Error < Rubyx::Error; end
24
+
25
+ class SetupError < Error; end
26
+
27
+ class InitError < Error; end
28
+ end
29
+
30
+ end
@@ -0,0 +1,105 @@
1
+ module Rubyx
2
+ module Rails
3
+ class Error < Rubyx::Error; end
4
+
5
+ class Configuration
6
+ attr_accessor :pyproject_path, :pyproject_content, :auto_init,
7
+ :force_reinit, :uv_version, :debug, :python_paths,
8
+ :uv_path, :uv_args
9
+
10
+ def initialize
11
+ @pyproject_path = nil
12
+ @pyproject_content = nil
13
+ @auto_init = false
14
+ @force_reinit = false
15
+ @uv_version = Rubyx::Uv::DEFAULT_UV_VERSION
16
+ @debug = false
17
+ @python_paths = []
18
+ @uv_path = nil
19
+ @uv_args = []
20
+ end
21
+ end
22
+
23
+ class << self
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ def configure
29
+ yield configuration
30
+ end
31
+
32
+ def init!
33
+ return if initialized?
34
+
35
+ config = configuration
36
+
37
+ pyproject_toml = resolve_pyproject(config)
38
+ project_dir = resolve_project_dir(config)
39
+
40
+ options = {
41
+ force: config.force_reinit,
42
+ uv_version: config.uv_version,
43
+ project_dir: project_dir,
44
+ uv_args: config.uv_args,
45
+ }
46
+ options[:uv_path] = config.uv_path if config.uv_path
47
+
48
+ Rubyx.uv_init(pyproject_toml, **options)
49
+
50
+ inject_python_paths(config.python_paths)
51
+
52
+ @initialized = true
53
+
54
+ if config.debug
55
+ ::Rails.logger.info "[Rubyx] Python initialized (project_dir: #{project_dir})"
56
+ end
57
+ rescue => e
58
+ @initialized = false
59
+ ::Rails.logger.error "[Rubyx] Failed to initialize Python: #{e.message}" if defined?(::Rails.logger)
60
+ raise
61
+ end
62
+
63
+ def ensure_initialized!
64
+ return if initialized?
65
+
66
+ init!
67
+ end
68
+
69
+ def initialized?
70
+ @initialized == true
71
+ end
72
+
73
+ private
74
+
75
+ def resolve_pyproject(config)
76
+ if config.pyproject_path && File.exist?(config.pyproject_path.to_s)
77
+ File.read(config.pyproject_path.to_s)
78
+ elsif config.pyproject_content
79
+ config.pyproject_content
80
+ else
81
+ raise Error, "No pyproject.toml configured. Set pyproject_path or pyproject_content in config/initializers/rubyx.rb"
82
+ end
83
+ end
84
+
85
+ def resolve_project_dir(config)
86
+ if config.pyproject_path
87
+ File.dirname(config.pyproject_path.to_s)
88
+ elsif defined?(::Rails) && ::Rails.respond_to?(:root)
89
+ ::Rails.root.to_s
90
+ else
91
+ Dir.pwd
92
+ end
93
+ end
94
+
95
+ def inject_python_paths(paths)
96
+ return if paths.nil? || paths.empty?
97
+
98
+ paths.each do |path|
99
+ expanded = File.expand_path(path)
100
+ Rubyx.eval("import sys; sys.path.insert(0, '#{expanded}')") if Dir.exist?(expanded)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,20 @@
1
+ require_relative 'rails'
2
+
3
+ module Rubyx
4
+ class Railtie < ::Rails::Railtie
5
+ config.rubyx = ActiveSupport::OrderedOptions.new
6
+
7
+ # Auto-initialize Python after all config initializers have run
8
+ config.after_initialize do
9
+ if Rubyx::Rails.configuration.auto_init
10
+ Rubyx::Rails.init!
11
+ ::Rails.logger.info '[Rubyx] Python environment initialized successfully'
12
+ end
13
+ end
14
+
15
+ # Register rake tasks
16
+ rake_tasks do
17
+ load 'rubyx/tasks/rubyx.rake'
18
+ end
19
+ end
20
+ end
data/lib/rubyx/uv.rb ADDED
@@ -0,0 +1,261 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+ require 'open-uri'
4
+ require 'stringio'
5
+ require 'rubygems/package'
6
+ require 'zlib'
7
+
8
+ module Rubyx
9
+ module Uv
10
+ DEFAULT_UV_VERSION = '0.10.2'.freeze
11
+
12
+ class << self
13
+ # Download uv (if needed), write pyproject.toml, and run `uv sync`.
14
+ #
15
+ # @param pyproject_toml [String] Content of pyproject.toml
16
+ # @param force [Boolean] Force re-setup even if .venv exists
17
+ # @param uv_version [String] Version of uv to download
18
+ # @param project_dir [String, Symbol, nil] Where to create the project
19
+ # - nil: use Dir.pwd
20
+ # - :cache: use a hash-based cache directory
21
+ # - String: use the specified path
22
+ # @param uv_args [Array<String>] Extra arguments to pass to `uv sync`
23
+ # @param uv_path [String, nil] Path to an existing uv binary. When set,
24
+ # auto-download is skipped entirely.
25
+ # @return [String] The resolved project directory path
26
+ def setup(pyproject_toml, force: false, uv_version: DEFAULT_UV_VERSION,
27
+ project_dir: nil, uv_args: [], uv_path: nil)
28
+ proj_dir = resolve_project_dir(pyproject_toml, uv_version, project_dir)
29
+
30
+ venv_dir = File.join(proj_dir, '.venv')
31
+ pyproject_path = File.join(proj_dir, 'pyproject.toml')
32
+
33
+ needs_setup = force || !Dir.exist?(venv_dir)
34
+
35
+ if !needs_setup && File.exist?(pyproject_path)
36
+ needs_setup = File.read(pyproject_path).strip != pyproject_toml.strip
37
+ end
38
+
39
+ if needs_setup
40
+ FileUtils.rm_rf(venv_dir) if force
41
+ FileUtils.mkdir_p(proj_dir)
42
+ File.write(pyproject_path, pyproject_toml)
43
+
44
+ success = run_uv!(
45
+ ['sync', '--managed-python', '--no-config', '--project', proj_dir, *uv_args],
46
+ chdir: proj_dir,
47
+ env: { 'UV_PYTHON_INSTALL_DIR' => python_install_dir(uv_version) },
48
+ uv_version: uv_version,
49
+ uv_path: uv_path
50
+ )
51
+
52
+ unless success
53
+ FileUtils.rm_rf(venv_dir)
54
+ raise SetupError, 'uv sync failed to setup Python environment'
55
+ end
56
+ end
57
+
58
+ proj_dir
59
+ end
60
+
61
+ # Parse pyvenv.cfg, resolve platform paths, and call Rubyx.init.
62
+ #
63
+ # @param pyproject_toml [String] Content of pyproject.toml (used to resolve project_dir)
64
+ # @param uv_version [String] Version of uv (used to resolve project_dir)
65
+ # @param project_dir [String, Symbol, nil] Same as setup
66
+ # @return [Hash] Resolved paths (:root_dir, :project_dir, :python_dl, etc.)
67
+ def init(pyproject_toml, uv_version: DEFAULT_UV_VERSION, project_dir: nil)
68
+ proj_dir = resolve_project_dir(pyproject_toml, uv_version, project_dir)
69
+
70
+ venv_dir = File.join(proj_dir, '.venv')
71
+ raise InitError, "Not set up. Call Rubyx::Uv.setup first." unless Dir.exist?(venv_dir)
72
+
73
+ cfg_path = File.join(venv_dir, 'pyvenv.cfg')
74
+ raise InitError, "pyvenv.cfg not found at #{cfg_path}" unless File.exist?(cfg_path)
75
+
76
+ pyvenv_cfg = File.read(cfg_path)
77
+ home_line = pyvenv_cfg.lines.find { |l| l.start_with?('home = ') }
78
+ raise InitError, "Could not find 'home' in pyvenv.cfg" unless home_line
79
+
80
+ home_path = home_line.sub('home = ', '').strip
81
+ root_dir = File.dirname(home_path) # Parent of bin/
82
+
83
+ paths = platform_paths(root_dir, proj_dir)
84
+ validate_paths!(paths)
85
+
86
+ sys_paths = []
87
+ sys_paths << paths[:venv_packages] if paths[:venv_packages]
88
+ sys_paths << proj_dir if project_dir && project_dir != :cache
89
+
90
+ # Call the Rust init
91
+ Rubyx.init(
92
+ paths[:python_dl],
93
+ paths[:python_home],
94
+ paths[:python_exe],
95
+ sys_paths
96
+ )
97
+
98
+ { root_dir: root_dir, project_dir: proj_dir, **paths }
99
+ end
100
+
101
+ private
102
+
103
+ # Download the uv binary from GitHub releases.
104
+ def download_uv!(uv_version)
105
+ archive_type, archive_name = archive_name_for_platform
106
+ url = "https://github.com/astral-sh/uv/releases/download/#{uv_version}/#{archive_name}"
107
+
108
+ warn "Downloading uv #{uv_version}..."
109
+
110
+ archive_data = URI.open(url, 'rb', &:read)
111
+ uv_binary = extract_uv(archive_type, archive_data)
112
+
113
+ path = default_uv_path(uv_version)
114
+ FileUtils.mkdir_p(File.dirname(path))
115
+ File.binwrite(path, uv_binary)
116
+ File.chmod(0o755, path)
117
+
118
+ path
119
+ end
120
+
121
+ def archive_name_for_platform
122
+ case RUBY_PLATFORM
123
+ when /arm64.*darwin/, /aarch64.*darwin/
124
+ [:tar_gz, 'uv-aarch64-apple-darwin.tar.gz']
125
+ when /x86_64.*darwin/, /darwin/
126
+ [:tar_gz, 'uv-x86_64-apple-darwin.tar.gz']
127
+ when /aarch64.*linux/
128
+ [:tar_gz, 'uv-aarch64-unknown-linux-gnu.tar.gz']
129
+ when /x86_64.*linux/, /linux/
130
+ [:tar_gz, 'uv-x86_64-unknown-linux-gnu.tar.gz']
131
+ when /mingw/, /mswin/, /cygwin/
132
+ [:zip, 'uv-x86_64-pc-windows-msvc.zip']
133
+ else
134
+ raise SetupError, "Unsupported platform: #{RUBY_PLATFORM}"
135
+ end
136
+ end
137
+
138
+ def extract_uv(type, data)
139
+ case type
140
+ when :tar_gz
141
+ io = StringIO.new(data)
142
+ gzip = Zlib::GzipReader.new(io)
143
+ Gem::Package::TarReader.new(gzip) do |tar|
144
+ tar.each do |entry|
145
+ return entry.read if File.basename(entry.full_name) == 'uv'
146
+ end
147
+ end
148
+ raise SetupError, 'uv binary not found in archive'
149
+ when :zip
150
+ require 'zip'
151
+ Zip::File.open_buffer(data) do |zip|
152
+ zip.each do |entry|
153
+ return entry.get_input_stream.read if File.basename(entry.name, '.*') == 'uv'
154
+ end
155
+ end
156
+ raise SetupError, 'uv binary not found in archive'
157
+ end
158
+ end
159
+
160
+ # Run a uv command.
161
+ #
162
+ # @param uv_path [String, nil] Custom uv binary path. When nil, uses
163
+ # the auto-downloaded binary (downloading if needed).
164
+ def run_uv!(args, chdir:, env:, uv_version:, uv_path: nil)
165
+ path = if uv_path
166
+ raise SetupError, "uv not found at #{uv_path}" unless File.exist?(uv_path)
167
+ uv_path
168
+ else
169
+ default = default_uv_path(uv_version)
170
+ download_uv!(uv_version) unless File.exist?(default)
171
+ default
172
+ end
173
+
174
+ require 'open3'
175
+ full_env = env.transform_keys(&:to_s)
176
+ success = nil
177
+ Dir.chdir(chdir) do
178
+ Open3.popen2e(full_env, path, *args) do |stdin, stdout_err, wait_thr|
179
+ stdin.close
180
+ stdout_err.each_line { |line| $stderr.print line }
181
+ success = wait_thr.value.success?
182
+ end
183
+ end
184
+
185
+ success
186
+ end
187
+
188
+ # Resolve platform-specific paths for libpython, home, exe, and site-packages.
189
+ def platform_paths(root_dir, project_dir)
190
+ case RUBY_PLATFORM
191
+ when /darwin/
192
+ {
193
+ python_dl: find_lib(root_dir, 'lib/libpython3.*.dylib'),
194
+ python_home: root_dir,
195
+ python_exe: File.join(project_dir, '.venv/bin/python'),
196
+ venv_packages: find_lib(project_dir, '.venv/lib/python3.*/site-packages'),
197
+ }
198
+ when /linux/
199
+ {
200
+ python_dl: find_lib(root_dir, 'lib/libpython3.*.so'),
201
+ python_home: root_dir,
202
+ python_exe: File.join(project_dir, '.venv/bin/python'),
203
+ venv_packages: find_lib(project_dir, '.venv/lib/python3.*/site-packages'),
204
+ }
205
+ when /mingw/, /mswin/, /cygwin/
206
+ {
207
+ python_dl: find_lib(root_dir, 'python3*.dll'),
208
+ python_home: root_dir,
209
+ python_exe: File.join(project_dir, '.venv/Scripts/python.exe'),
210
+ venv_packages: File.join(project_dir, '.venv/Lib/site-packages'),
211
+ }
212
+ else
213
+ raise InitError, "Unsupported platform: #{RUBY_PLATFORM}"
214
+ end
215
+ end
216
+
217
+ def find_lib(base_dir, pattern)
218
+ matches = Dir.glob(File.join(base_dir, pattern))
219
+ matches.min_by(&:length)
220
+ end
221
+
222
+ def validate_paths!(paths)
223
+ paths.each do |key, path|
224
+ next if path.nil? && key == :venv_packages
225
+ raise InitError, "Path not found: #{key} (#{path})" unless path && File.exist?(path)
226
+ end
227
+ end
228
+
229
+ # Determine where the project directory should be.
230
+ def resolve_project_dir(pyproject_toml, uv_version, project_dir)
231
+ case project_dir
232
+ when nil
233
+ Dir.pwd
234
+ when :cache
235
+ cache_id = Digest::MD5.hexdigest(pyproject_toml)
236
+ File.join(cache_dir(uv_version), 'projects', cache_id)
237
+ else
238
+ File.expand_path(project_dir)
239
+ end
240
+ end
241
+
242
+ # Path to the auto-downloaded uv binary.
243
+ def default_uv_path(uv_version)
244
+ File.join(cache_dir(uv_version), 'bin', 'uv')
245
+ end
246
+
247
+ # Root cache directory for this rubyx + uv version combination.
248
+ def cache_dir(uv_version)
249
+ File.join(
250
+ ENV.fetch('XDG_CACHE_HOME', File.join(Dir.home, '.cache')),
251
+ 'rubyx', Rubyx::VERSION, 'uv', uv_version
252
+ )
253
+ end
254
+
255
+ # Directory where uv installs managed Python distributions.
256
+ def python_install_dir(uv_version)
257
+ File.join(cache_dir(uv_version), 'python')
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Rubyx
3
+ VERSION = "0.1.0".freeze
4
+ end
data/lib/rubyx-py.rb ADDED
@@ -0,0 +1 @@
1
+ require_relative 'rubyx'
data/lib/rubyx.rb ADDED
@@ -0,0 +1,136 @@
1
+ require 'rbconfig'
2
+ require_relative 'rubyx/version'
3
+ require_relative 'rubyx/error'
4
+
5
+ # Load the native extension
6
+ begin
7
+ ruby_version = RUBY_VERSION.match(/\d+\.\d+/)[0]
8
+ require "rubyx/#{ruby_version}/rubyx"
9
+ rescue LoadError
10
+ begin
11
+ require 'rubyx/rubyx'
12
+ rescue LoadError
13
+ # dev
14
+ dev_root = File.expand_path('..', __dir__)
15
+ unless File.exist?(File.join(dev_root, 'Cargo.toml'))
16
+ raise LoadError,
17
+ "Could not load rubyx native extension. Install the rubyx-py gem."
18
+ end
19
+
20
+ lib_ext = case RbConfig::CONFIG['host_os']
21
+ when /darwin/ then 'dylib'
22
+ when /linux/ then 'so'
23
+ when /mingw|mswin/ then 'dll'
24
+ else 'so'
25
+ end
26
+ bundle_ext = RbConfig::CONFIG['host_os'] =~ /darwin/ ? 'bundle' : lib_ext
27
+
28
+ lib_path = File.join(dev_root, "target/release/librubyx.#{lib_ext}")
29
+ bundle_path = File.join(dev_root, "target/release/rubyx.#{bundle_ext}")
30
+
31
+ unless File.exist?(lib_path)
32
+ raise LoadError,
33
+ "Native extension not built. Run: cargo build --release"
34
+ end
35
+
36
+ if !File.exist?(bundle_path) || File.mtime(lib_path) > File.mtime(bundle_path)
37
+ require 'fileutils'
38
+ FileUtils.cp(lib_path, bundle_path)
39
+ end
40
+ require bundle_path
41
+ end
42
+ end
43
+
44
+ require_relative 'rubyx/context'
45
+ require_relative 'rubyx/uv'
46
+ require_relative 'rubyx/railtie' if defined?(::Rails::Railtie)
47
+
48
+ module Rubyx
49
+ # Import a Python module by name.
50
+ #
51
+ # @param module_name [String] Python module name (e.g., "os", "numpy", "my_module.sub")
52
+ # @return [RubyxObject] Wrapped Python module
53
+ # @raise [InvalidModuleNameError] if the name contains invalid characters
54
+ def self.import(module_name)
55
+ name = module_name.to_s
56
+ unless name.match?(VALID_MODULE_NAME_PATTERN)
57
+ raise InvalidModuleNameError,
58
+ "Invalid Python module name: '#{name}'. " \
59
+ "Module names must contain only alphanumeric characters, underscores, and dots."
60
+ end
61
+ _import(name)
62
+ end
63
+
64
+ # Evaluate Python code and return the result.
65
+ #
66
+ # @param code [String] Python code to evaluate
67
+ # @param globals [Hash] Ruby values to inject as Python globals
68
+ # @return [RubyxObject] The result as a wrapped Python object
69
+ # @example
70
+ # Rubyx.eval("x + y", x: 10, y: 20)
71
+ class << self
72
+ public define_method(:eval) { |code, **globals|
73
+ if globals.empty?
74
+ Rubyx._eval(code.to_s)
75
+ else
76
+ Rubyx._eval_with_globals(code.to_s, globals)
77
+ end
78
+ }
79
+ end
80
+
81
+ # Run a Python coroutine with asyncio.run() (blocking).
82
+ # Accepts either a RubyxObject (coroutine) or a code string with globals.
83
+ #
84
+ # @param code_or_coroutine [String, RubyxObject] Python code or coroutine object
85
+ # @param globals [Hash] Ruby values to inject as Python globals (only with code string)
86
+ # @return [RubyxObject] The awaited result
87
+ # @example
88
+ # Rubyx.await("fetch(url)", url: "https://example.com")
89
+ def self.await(code_or_coroutine, **globals)
90
+ if code_or_coroutine.is_a?(String)
91
+ if globals.empty?
92
+ _await_with_globals(code_or_coroutine, {})
93
+ else
94
+ _await_with_globals(code_or_coroutine, globals)
95
+ end
96
+ else
97
+ raise ArgumentError, "cannot pass globals with a coroutine object" unless globals.empty?
98
+ _await(code_or_coroutine)
99
+ end
100
+ end
101
+
102
+ # Run a Python coroutine on a background thread (non-blocking).
103
+ # Accepts either a RubyxObject (coroutine) or a code string with globals.
104
+ #
105
+ # @param code_or_coroutine [String, RubyxObject] Python code or coroutine object
106
+ # @param globals [Hash] Ruby values to inject as Python globals (only with code string)
107
+ # @return [Rubyx::Future] A future that resolves to the result
108
+ # @example
109
+ # future = Rubyx.async_await("fetch(url)", url: "https://example.com")
110
+ # future.value
111
+ def self.async_await(code_or_coroutine, **globals)
112
+ if code_or_coroutine.is_a?(String)
113
+ if globals.empty?
114
+ _async_await_with_globals(code_or_coroutine, {})
115
+ else
116
+ _async_await_with_globals(code_or_coroutine, globals)
117
+ end
118
+ else
119
+ raise ArgumentError, "cannot pass globals with a coroutine object" unless globals.empty?
120
+ _async_await(code_or_coroutine)
121
+ end
122
+ end
123
+
124
+ # Convenience method: setup Python environment via uv and initialize.
125
+ #
126
+ # @param pyproject_toml [String] Content of pyproject.toml
127
+ # @param options [Hash] Options passed to Uv.setup and Uv.init
128
+ # @return [Hash] Resolved paths from Uv.init
129
+ def self.uv_init(pyproject_toml, **options)
130
+ setup_keys = %i[force uv_version project_dir uv_args uv_path]
131
+ init_keys = %i[uv_version project_dir]
132
+
133
+ Uv.setup(pyproject_toml, **options.slice(*setup_keys))
134
+ Uv.init(pyproject_toml, **options.slice(*init_keys))
135
+ end
136
+ end
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubyx-py
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: aarch64-linux
6
+ authors:
7
+ - Naiker
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake-compiler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.2'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ description: Call Python libraries directly from Ruby and Rails. No microservices,
42
+ no REST APIs — just seamless interop. Powered by Rust for safety and performance.
43
+ email:
44
+ - yinho999@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - README.md
50
+ - ext/rubyx/src/python/sync_adapter.py
51
+ - lib/generators/rubyx/install_generator.rb
52
+ - lib/generators/rubyx/templates/rubyx_initializer.rb
53
+ - lib/rubyx-py.rb
54
+ - lib/rubyx.rb
55
+ - lib/rubyx/3.1/rubyx.so
56
+ - lib/rubyx/3.2/rubyx.so
57
+ - lib/rubyx/3.3/rubyx.so
58
+ - lib/rubyx/3.4/rubyx.so
59
+ - lib/rubyx/context.rb
60
+ - lib/rubyx/error.rb
61
+ - lib/rubyx/rails.rb
62
+ - lib/rubyx/railtie.rb
63
+ - lib/rubyx/uv.rb
64
+ - lib/rubyx/version.rb
65
+ homepage: https://github.com/yinho999/rubyx
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ homepage_uri: https://github.com/yinho999/rubyx
70
+ source_code_uri: https://github.com/yinho999/rubyx
71
+ changelog_uri: https://github.com/yinho999/rubyx/blob/main/CHANGELOG.md
72
+ bug_tracker_uri: https://github.com/yinho999/rubyx/issues
73
+ rubygems_mfa_required: 'true'
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '3.1'
83
+ - - "<"
84
+ - !ruby/object:Gem::Version
85
+ version: 3.5.dev
86
+ required_rubygems_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ requirements: []
92
+ rubygems_version: 3.5.23
93
+ signing_key:
94
+ specification_version: 4
95
+ summary: Ruby-Python bridge powered by Rust
96
+ test_files: []