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.
@@ -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
@@ -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