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 +7 -0
- data/Cargo.toml +19 -0
- data/README.md +469 -0
- data/ext/rubyx/Cargo.toml +19 -0
- data/ext/rubyx/extconf.rb +22 -0
- data/ext/rubyx/src/async_gen.rs +1298 -0
- data/ext/rubyx/src/context.rs +812 -0
- data/ext/rubyx/src/convert.rs +1498 -0
- data/ext/rubyx/src/eval.rs +377 -0
- data/ext/rubyx/src/exception.rs +184 -0
- data/ext/rubyx/src/future.rs +126 -0
- data/ext/rubyx/src/import.rs +34 -0
- data/ext/rubyx/src/lib.rs +4212 -0
- data/ext/rubyx/src/nonblocking_stream.rs +1422 -0
- data/ext/rubyx/src/pipe_notify.rs +232 -0
- data/ext/rubyx/src/python/sync_adapter.py +31 -0
- data/ext/rubyx/src/python_api.rs +6029 -0
- data/ext/rubyx/src/python_ffi.rs +18 -0
- data/ext/rubyx/src/python_finder.rs +119 -0
- data/ext/rubyx/src/python_guard.rs +25 -0
- data/ext/rubyx/src/ruby_helpers.rs +74 -0
- data/ext/rubyx/src/rubyx_object.rs +1931 -0
- data/ext/rubyx/src/rubyx_stream.rs +950 -0
- data/ext/rubyx/src/stream.rs +713 -0
- data/ext/rubyx/src/test_helpers.rs +351 -0
- data/lib/generators/rubyx/install_generator.rb +24 -0
- data/lib/generators/rubyx/templates/rubyx_initializer.rb +17 -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 +123 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
//! Shared test infrastructure for Python API initialization and GIL management.
|
|
2
|
+
//!
|
|
3
|
+
//! This module provides a single, centralized point for Python initialization across all test
|
|
4
|
+
//! modules. It prevents the SIGSEGV that occurs when multiple test modules each initialize
|
|
5
|
+
//! Python independently.
|
|
6
|
+
//!
|
|
7
|
+
//! # Pattern
|
|
8
|
+
//!
|
|
9
|
+
//! The key insight is that `PyGILState_Ensure` / `PyGILState_Release` (our `ensure_gil` /
|
|
10
|
+
//! `release_gil`) are **thread-safe**: they create per-thread state automatically. This
|
|
11
|
+
//! makes them safe to call from any OS thread in Cargo's test-runner pool.
|
|
12
|
+
//!
|
|
13
|
+
//! In contrast, `PyEval_SaveThread` / `PyEval_RestoreThread` bind to a specific OS thread
|
|
14
|
+
//! and deadlock when restored on a different thread — which happens with `#[serial]` tests
|
|
15
|
+
//! because Cargo's thread pool may schedule successive tests on different threads.
|
|
16
|
+
//!
|
|
17
|
+
//! We:
|
|
18
|
+
//! 1. Initialize Python once via `OnceLock`.
|
|
19
|
+
//! 2. Immediately call `save_thread()` to release the GIL (discard the returned state).
|
|
20
|
+
//! 3. Each test acquires the GIL with `ensure_gil()` (via `GilGuard`) and releases it on drop.
|
|
21
|
+
//!
|
|
22
|
+
//! # Ruby Threading
|
|
23
|
+
//!
|
|
24
|
+
//! Ruby's GVL (Global VM Lock) binds to the thread that called `embed::init()`, and Ruby
|
|
25
|
+
//! C API functions require proper thread-local state (`ruby_current_ec_ptr`). Rust's test
|
|
26
|
+
//! harness spawns a **new OS thread for every test**, so neither `Ruby::get()` nor direct
|
|
27
|
+
//! C API calls work from test threads.
|
|
28
|
+
//!
|
|
29
|
+
//! The solution uses an executor pattern:
|
|
30
|
+
//! 1. A dedicated long-lived thread calls `embed::init()` and then releases the GVL via
|
|
31
|
+
//! `rb_thread_call_without_gvl`, running an executor loop that waits for work items.
|
|
32
|
+
//! 2. Test threads send closures to the executor via a channel.
|
|
33
|
+
//! 3. The executor calls `rb_thread_call_with_gvl` for each work item — this works because
|
|
34
|
+
//! the executor thread is inside `rb_thread_call_without_gvl` and is still registered
|
|
35
|
+
//! with Ruby. Inside `rb_thread_call_with_gvl`, `Ruby::get()` succeeds normally.
|
|
36
|
+
//! 4. Results are sent back to the test thread via a one-shot channel.
|
|
37
|
+
|
|
38
|
+
use crate::python_api::PythonApi;
|
|
39
|
+
use crate::python_ffi::PyGILState;
|
|
40
|
+
use crate::python_finder::find_libpython;
|
|
41
|
+
use magnus::Ruby;
|
|
42
|
+
use std::any::Any;
|
|
43
|
+
use std::ffi::c_void;
|
|
44
|
+
use std::panic::{self, AssertUnwindSafe};
|
|
45
|
+
use std::sync::mpsc::{Receiver, Sender};
|
|
46
|
+
use std::sync::{Mutex, OnceLock};
|
|
47
|
+
|
|
48
|
+
extern "C" {
|
|
49
|
+
fn rb_thread_call_without_gvl(
|
|
50
|
+
func: unsafe extern "C" fn(*mut c_void) -> *mut c_void,
|
|
51
|
+
data1: *mut c_void,
|
|
52
|
+
ubf: Option<unsafe extern "C" fn(*mut c_void)>,
|
|
53
|
+
data2: *mut c_void,
|
|
54
|
+
) -> *mut c_void;
|
|
55
|
+
|
|
56
|
+
fn rb_thread_call_with_gvl(
|
|
57
|
+
func: unsafe extern "C" fn(*mut c_void) -> *mut c_void,
|
|
58
|
+
data1: *mut c_void,
|
|
59
|
+
) -> *mut c_void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Tracks whether Python initialization has been attempted.
|
|
63
|
+
static PYTHON_INIT: OnceLock<bool> = OnceLock::new();
|
|
64
|
+
|
|
65
|
+
/// Get the shared Python API instance, initializing if necessary.
|
|
66
|
+
///
|
|
67
|
+
/// Stores the `PythonApi` in `crate::API` so that both test helpers and
|
|
68
|
+
/// production code paths (e.g. `method_missing` → `crate::api()`) share
|
|
69
|
+
/// the same instance.
|
|
70
|
+
///
|
|
71
|
+
/// After this returns, the GIL is **not** held. Use `skip_if_no_python()` to
|
|
72
|
+
/// acquire the GIL via a `GilGuard`.
|
|
73
|
+
pub fn get_api() -> Option<&'static PythonApi> {
|
|
74
|
+
let success = PYTHON_INIT.get_or_init(|| {
|
|
75
|
+
let path = match find_libpython() {
|
|
76
|
+
Some(p) => p,
|
|
77
|
+
None => return false,
|
|
78
|
+
};
|
|
79
|
+
let mut api = match unsafe { PythonApi::load(&path) } {
|
|
80
|
+
Ok(a) => a,
|
|
81
|
+
Err(_) => return false,
|
|
82
|
+
};
|
|
83
|
+
api.initialize();
|
|
84
|
+
let _ = api.install_async_to_sync_class();
|
|
85
|
+
|
|
86
|
+
// Release the GIL that Py_Initialize left us holding.
|
|
87
|
+
// We intentionally discard the returned PyThreadState — from here on,
|
|
88
|
+
// all GIL acquisition goes through the thread-safe ensure_gil/release_gil.
|
|
89
|
+
let _ = api.save_thread();
|
|
90
|
+
|
|
91
|
+
// Store in the crate-level API so crate::api() works in production code
|
|
92
|
+
// paths called from tests (e.g. method_missing).
|
|
93
|
+
let _ = crate::API.set(api);
|
|
94
|
+
true
|
|
95
|
+
});
|
|
96
|
+
if *success {
|
|
97
|
+
crate::API.get()
|
|
98
|
+
} else {
|
|
99
|
+
None
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// RAII guard that manages GIL acquisition and release for tests.
|
|
104
|
+
///
|
|
105
|
+
/// Created by `skip_if_no_python()`. Holds the GIL for the duration of its lifetime.
|
|
106
|
+
/// Uses `PyGILState_Ensure` / `PyGILState_Release` which are thread-safe and work
|
|
107
|
+
/// correctly regardless of which OS thread the test is scheduled on.
|
|
108
|
+
pub struct GilGuard<'a> {
|
|
109
|
+
api: &'a PythonApi,
|
|
110
|
+
gil_state: PyGILState,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
impl<'a> GilGuard<'a> {
|
|
114
|
+
/// Access the Python API while holding the GIL.
|
|
115
|
+
pub fn api(&self) -> &'a PythonApi {
|
|
116
|
+
self.api
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
impl<'a> Drop for GilGuard<'a> {
|
|
121
|
+
fn drop(&mut self) {
|
|
122
|
+
self.api.release_gil(self.gil_state);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Skip the test if Python is not available, otherwise return a GIL guard.
|
|
127
|
+
///
|
|
128
|
+
/// This is the main entry point for tests. It:
|
|
129
|
+
/// 1. Initializes Python (if not already done)
|
|
130
|
+
/// 2. Acquires the GIL via `ensure_gil()` (thread-safe)
|
|
131
|
+
/// 3. Returns a guard that releases the GIL on drop
|
|
132
|
+
///
|
|
133
|
+
/// # Example
|
|
134
|
+
///
|
|
135
|
+
/// ```ignore
|
|
136
|
+
/// #[test]
|
|
137
|
+
/// fn test_something() {
|
|
138
|
+
/// let Some(guard) = skip_if_no_python() else { return; };
|
|
139
|
+
/// let api = guard.api();
|
|
140
|
+
/// api.run_simple_string("x = 42").unwrap();
|
|
141
|
+
/// }
|
|
142
|
+
/// ```
|
|
143
|
+
pub fn skip_if_no_python() -> Option<GilGuard<'static>> {
|
|
144
|
+
let api = get_api()?;
|
|
145
|
+
let gil_state = api.ensure_gil();
|
|
146
|
+
Some(GilGuard { api, gil_state })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Ruby executor pattern
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/// Type-erased work item sent from test threads to the Ruby executor.
|
|
154
|
+
type WorkFn = Box<dyn FnOnce() + Send>;
|
|
155
|
+
|
|
156
|
+
/// Carries a work item through the C callback interface, with space to
|
|
157
|
+
/// store a panic payload if the work item panics.
|
|
158
|
+
struct WorkSlot {
|
|
159
|
+
work: Option<WorkFn>,
|
|
160
|
+
panic: Option<Box<dyn Any + Send>>,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Holds the sender half of the executor channel, wrapped in a Mutex because
|
|
164
|
+
/// `std::sync::mpsc::Sender` is `!Sync` (required for statics via `OnceLock`).
|
|
165
|
+
/// The Mutex is effectively uncontended since `#[serial]` ensures only one test
|
|
166
|
+
/// runs at a time.
|
|
167
|
+
static RUBY_EXECUTOR: OnceLock<Mutex<Sender<WorkFn>>> = OnceLock::new();
|
|
168
|
+
|
|
169
|
+
/// Executor loop that runs inside `rb_thread_call_without_gvl` on the Ruby
|
|
170
|
+
/// init thread. Receives work items from test threads and dispatches them
|
|
171
|
+
/// via `rb_thread_call_with_gvl`.
|
|
172
|
+
///
|
|
173
|
+
/// This works because the executor thread is still registered with Ruby
|
|
174
|
+
/// (it called `embed::init()`), so `rb_thread_call_with_gvl` is valid here.
|
|
175
|
+
unsafe extern "C" fn executor_loop(data: *mut c_void) -> *mut c_void {
|
|
176
|
+
let rx = &*(data as *const Receiver<WorkFn>);
|
|
177
|
+
while let Ok(work) = rx.recv() {
|
|
178
|
+
let mut slot = WorkSlot {
|
|
179
|
+
work: Some(work),
|
|
180
|
+
panic: None,
|
|
181
|
+
};
|
|
182
|
+
rb_thread_call_with_gvl(run_work_with_gvl, &mut slot as *mut WorkSlot as *mut c_void);
|
|
183
|
+
// If the work item panicked, the panic payload is in slot.panic.
|
|
184
|
+
// The test thread will see the result channel drop (no send) and
|
|
185
|
+
// the with_ruby_python function handles this. But we can't
|
|
186
|
+
// resume_unwind here (we're in a C callback). The panic info
|
|
187
|
+
// was already sent via the result channel by the work item itself.
|
|
188
|
+
}
|
|
189
|
+
std::ptr::null_mut()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Callback for `rb_thread_call_with_gvl`. Runs the work item with
|
|
193
|
+
/// proper Ruby thread-local state and GVL held.
|
|
194
|
+
///
|
|
195
|
+
/// Panics are caught via `catch_unwind` to prevent unwinding across the
|
|
196
|
+
/// FFI boundary (which is UB). The panic payload is stored back into the
|
|
197
|
+
/// work slot so the caller can propagate it.
|
|
198
|
+
unsafe extern "C" fn run_work_with_gvl(data: *mut c_void) -> *mut c_void {
|
|
199
|
+
let slot = &mut *(data as *mut WorkSlot);
|
|
200
|
+
if let Some(f) = slot.work.take() {
|
|
201
|
+
if let Err(payload) = panic::catch_unwind(AssertUnwindSafe(f)) {
|
|
202
|
+
slot.panic = Some(payload);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
std::ptr::null_mut()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Initialize the Ruby VM once on a dedicated long-lived thread and start
|
|
209
|
+
/// the executor loop.
|
|
210
|
+
///
|
|
211
|
+
/// The dedicated thread:
|
|
212
|
+
/// 1. Calls `embed::init()` — becoming Ruby's "main" thread
|
|
213
|
+
/// 2. Defines the `RubyxObject` class
|
|
214
|
+
/// 3. Releases the GVL via `rb_thread_call_without_gvl` and enters the
|
|
215
|
+
/// executor loop, waiting for work items from test threads
|
|
216
|
+
fn ensure_ruby_vm() {
|
|
217
|
+
RUBY_EXECUTOR.get_or_init(|| {
|
|
218
|
+
let (tx, rx) = std::sync::mpsc::channel::<WorkFn>();
|
|
219
|
+
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
|
|
220
|
+
|
|
221
|
+
// Spawn a dedicated thread that will be Ruby's "main" thread for the
|
|
222
|
+
// entire test process lifetime.
|
|
223
|
+
std::thread::spawn(move || {
|
|
224
|
+
let cleanup = unsafe { magnus::embed::init() };
|
|
225
|
+
let ruby: &Ruby = &cleanup;
|
|
226
|
+
ruby.define_class("RubyxObject", ruby.class_object())
|
|
227
|
+
.expect("Failed to define RubyxObject class for tests");
|
|
228
|
+
|
|
229
|
+
// Signal that Ruby is ready before releasing the GVL.
|
|
230
|
+
ready_tx.send(()).expect("ready channel send failed");
|
|
231
|
+
|
|
232
|
+
// Leak the receiver so it lives forever (the executor loop
|
|
233
|
+
// borrows it via raw pointer through the C callback interface).
|
|
234
|
+
let rx_ptr = Box::into_raw(Box::new(rx));
|
|
235
|
+
|
|
236
|
+
// Release the GVL and enter the executor loop. The loop receives
|
|
237
|
+
// work items from test threads and dispatches them via
|
|
238
|
+
// rb_thread_call_with_gvl.
|
|
239
|
+
unsafe {
|
|
240
|
+
rb_thread_call_without_gvl(
|
|
241
|
+
executor_loop,
|
|
242
|
+
rx_ptr as *mut c_void,
|
|
243
|
+
None,
|
|
244
|
+
std::ptr::null_mut(),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Never reached, but prevent cleanup from running.
|
|
249
|
+
std::mem::forget(cleanup);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
ready_rx.recv().expect("Ruby init thread failed");
|
|
253
|
+
Mutex::new(tx)
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/// Run a closure with both Ruby GVL and Python GIL held.
|
|
258
|
+
///
|
|
259
|
+
/// Returns `None` if Python is not available (test should be skipped).
|
|
260
|
+
///
|
|
261
|
+
/// This is the main entry point for tests that need both Ruby and Python.
|
|
262
|
+
/// The closure is sent to the Ruby executor thread, which runs it inside
|
|
263
|
+
/// `rb_thread_call_with_gvl` with proper Ruby thread-local state.
|
|
264
|
+
/// The Python GIL is also acquired on the executor thread before the
|
|
265
|
+
/// closure runs.
|
|
266
|
+
///
|
|
267
|
+
/// # Example
|
|
268
|
+
///
|
|
269
|
+
/// ```ignore
|
|
270
|
+
/// #[test]
|
|
271
|
+
/// #[serial]
|
|
272
|
+
/// fn test_something() {
|
|
273
|
+
/// with_ruby_python(|ruby, api| {
|
|
274
|
+
/// let py_str = api.string_from_str("hello");
|
|
275
|
+
/// let rb_str = "hello".into_value_with(ruby);
|
|
276
|
+
/// // ...
|
|
277
|
+
/// });
|
|
278
|
+
/// }
|
|
279
|
+
/// ```
|
|
280
|
+
pub fn with_ruby_python<F, R>(f: F) -> Option<R>
|
|
281
|
+
where
|
|
282
|
+
F: FnOnce(&Ruby, &'static PythonApi) -> R + Send + 'static,
|
|
283
|
+
R: Send + 'static,
|
|
284
|
+
{
|
|
285
|
+
// Initialize Python first — must happen before Ruby VM init to avoid
|
|
286
|
+
// interference with Python C extension loading.
|
|
287
|
+
let api = get_api()?;
|
|
288
|
+
ensure_ruby_vm();
|
|
289
|
+
|
|
290
|
+
let (result_tx, result_rx) = std::sync::mpsc::channel::<Result<R, Box<dyn Any + Send>>>();
|
|
291
|
+
|
|
292
|
+
let work: WorkFn = Box::new(move || {
|
|
293
|
+
// Inside rb_thread_call_with_gvl: Ruby GVL is held, thread-local
|
|
294
|
+
// state is set up, Ruby::get_unchecked() is safe.
|
|
295
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
296
|
+
|
|
297
|
+
// Acquire the Python GIL on the executor thread.
|
|
298
|
+
let gil = api.ensure_gil();
|
|
299
|
+
let result = panic::catch_unwind(AssertUnwindSafe(|| f(&ruby, api)));
|
|
300
|
+
api.release_gil(gil);
|
|
301
|
+
|
|
302
|
+
let _ = result_tx.send(result);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Send the work item to the executor thread.
|
|
306
|
+
RUBY_EXECUTOR
|
|
307
|
+
.get()
|
|
308
|
+
.expect("executor not initialized")
|
|
309
|
+
.lock()
|
|
310
|
+
.expect("executor mutex poisoned")
|
|
311
|
+
.send(work)
|
|
312
|
+
.expect("executor channel closed");
|
|
313
|
+
|
|
314
|
+
// Block until the executor finishes running our closure.
|
|
315
|
+
// If the closure panicked, resume the panic on the test thread.
|
|
316
|
+
match result_rx.recv().expect("executor result channel closed") {
|
|
317
|
+
Ok(value) => Some(value),
|
|
318
|
+
Err(payload) => panic::resume_unwind(payload),
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Keep the old API available for backward compatibility during migration.
|
|
323
|
+
// These can be removed once all tests are migrated to with_ruby_python.
|
|
324
|
+
|
|
325
|
+
/// RAII guard that holds both a Ruby VM handle and a Python GIL.
|
|
326
|
+
pub struct RubyPythonGuard<'a> {
|
|
327
|
+
ruby: Ruby,
|
|
328
|
+
gil_guard: GilGuard<'a>,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
impl<'a> RubyPythonGuard<'a> {
|
|
332
|
+
pub fn api(&self) -> &'a PythonApi {
|
|
333
|
+
self.gil_guard.api()
|
|
334
|
+
}
|
|
335
|
+
pub fn ruby(&self) -> &Ruby {
|
|
336
|
+
&self.ruby
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/// Skip the test if Python is not available, and ensure the Ruby VM is initialized.
|
|
341
|
+
///
|
|
342
|
+
/// **Deprecated**: Use `with_ruby_python` instead. This function only works when
|
|
343
|
+
/// the test happens to run on the Ruby init thread (which is unreliable with Cargo's
|
|
344
|
+
/// test harness).
|
|
345
|
+
pub fn skip_if_no_ruby_python() -> Option<RubyPythonGuard<'static>> {
|
|
346
|
+
let _ = get_api()?;
|
|
347
|
+
ensure_ruby_vm();
|
|
348
|
+
let gil_guard = skip_if_no_python()?;
|
|
349
|
+
let ruby = Ruby::get().ok()?;
|
|
350
|
+
Some(RubyPythonGuard { ruby, gil_guard })
|
|
351
|
+
}
|
|
@@ -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
|
|
@@ -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
|