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 +7 -0
- data/README.md +469 -0
- data/ext/rubyx/src/python/sync_adapter.py +31 -0
- data/lib/generators/rubyx/install_generator.rb +24 -0
- data/lib/generators/rubyx/templates/rubyx_initializer.rb +17 -0
- data/lib/rubyx/3.1/rubyx.so +0 -0
- data/lib/rubyx/3.2/rubyx.so +0 -0
- data/lib/rubyx/3.3/rubyx.so +0 -0
- data/lib/rubyx/3.4/rubyx.so +0 -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 +96 -0
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
|
+
[](https://badge.fury.io/rb/rubyx)
|
|
10
|
+
[](https://github.com/yinho999/rubyx/actions/workflows/ci.yml)
|
|
11
|
+
[](https://opensource.org/licenses/MIT)
|
|
12
|
+
[](https://www.ruby-lang.org)
|
|
13
|
+
[](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
|
data/lib/rubyx/error.rb
ADDED
|
@@ -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
|
data/lib/rubyx/rails.rb
ADDED
|
@@ -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
|
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: []
|