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.
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,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubyx-py
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Naiker
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake-compiler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ description: Call Python libraries directly from Ruby and Rails. No microservices,
55
+ no REST APIs — just seamless interop. Powered by Rust for safety and performance.
56
+ email:
57
+ - yinho999@gmail.com
58
+ executables: []
59
+ extensions:
60
+ - ext/rubyx/extconf.rb
61
+ extra_rdoc_files: []
62
+ files:
63
+ - Cargo.toml
64
+ - README.md
65
+ - ext/rubyx/Cargo.toml
66
+ - ext/rubyx/extconf.rb
67
+ - ext/rubyx/src/async_gen.rs
68
+ - ext/rubyx/src/context.rs
69
+ - ext/rubyx/src/convert.rs
70
+ - ext/rubyx/src/eval.rs
71
+ - ext/rubyx/src/exception.rs
72
+ - ext/rubyx/src/future.rs
73
+ - ext/rubyx/src/import.rs
74
+ - ext/rubyx/src/lib.rs
75
+ - ext/rubyx/src/nonblocking_stream.rs
76
+ - ext/rubyx/src/pipe_notify.rs
77
+ - ext/rubyx/src/python/sync_adapter.py
78
+ - ext/rubyx/src/python_api.rs
79
+ - ext/rubyx/src/python_ffi.rs
80
+ - ext/rubyx/src/python_finder.rs
81
+ - ext/rubyx/src/python_guard.rs
82
+ - ext/rubyx/src/ruby_helpers.rs
83
+ - ext/rubyx/src/rubyx_object.rs
84
+ - ext/rubyx/src/rubyx_stream.rs
85
+ - ext/rubyx/src/stream.rs
86
+ - ext/rubyx/src/test_helpers.rs
87
+ - lib/generators/rubyx/install_generator.rb
88
+ - lib/generators/rubyx/templates/rubyx_initializer.rb
89
+ - lib/rubyx-py.rb
90
+ - lib/rubyx.rb
91
+ - lib/rubyx/context.rb
92
+ - lib/rubyx/error.rb
93
+ - lib/rubyx/rails.rb
94
+ - lib/rubyx/railtie.rb
95
+ - lib/rubyx/uv.rb
96
+ - lib/rubyx/version.rb
97
+ homepage: https://github.com/yinho999/rubyx
98
+ licenses:
99
+ - MIT
100
+ metadata:
101
+ homepage_uri: https://github.com/yinho999/rubyx
102
+ source_code_uri: https://github.com/yinho999/rubyx
103
+ changelog_uri: https://github.com/yinho999/rubyx/blob/main/CHANGELOG.md
104
+ bug_tracker_uri: https://github.com/yinho999/rubyx/issues
105
+ rubygems_mfa_required: 'true'
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: 3.0.0
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.6.9
121
+ specification_version: 4
122
+ summary: Ruby-Python bridge powered by Rust
123
+ test_files: []