Skip to content

Commit 8da90ad

Browse files
committed
Add optional support for building native extensions of path sources
This adds a new `build_path_extensions` setting for local path gems with native extensions. When enabled, Bundler compiles those extensions out of tree under `BUNDLE_PATH`, keeps the checkout untouched, and puts the built extension directory ahead of the gem's own load paths so `bundle exec` and `require` can load the compiled artifacts. The feature supports per-gem opt in via `build_path_extensions.<gem>`, reuses existing build flags from `build.<gem>`, avoids rebuilding when nothing changed, and rebuilds when native extension sources or build flags change. It also surfaces native extension build output for path sources and preserves the existing failure mode when a required extension has not been built or has gone stale.
1 parent cb0942e commit 8da90ad

9 files changed

Lines changed: 404 additions & 2 deletions

File tree

lib/bundler/cli/pristine.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ def run
4646

4747
next if git_sources.include?(source)
4848
git_sources << source
49+
when Source::Path
50+
extension_dir = source.extension_dir_for(spec)
51+
unless extension_dir
52+
Bundler.ui.warn("Cannot pristine #{gem_name}. Gem is sourced from local path.")
53+
next
54+
end
55+
56+
FileUtils.rm_rf extension_dir
4957
else
5058
Bundler.ui.warn("Cannot pristine #{gem_name}. Gem is sourced from local path.")
5159
next

lib/bundler/man/bundle-config.1

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ The following is a list of all configuration keys and their purpose\. You can le
7676
.IP "\(bu" 4
7777
\fBbin\fR (\fBBUNDLE_BIN\fR): If configured, \fBbundle binstubs\fR will install executables from gems in the bundle to the specified directory\. Otherwise it will create them in a \fBbin\fR directory relative to the Gemfile directory\. These executables run in Bundler's context\. If used, you might add this directory to your environment's \fBPATH\fR variable\. For instance, if the \fBrails\fR gem comes with a \fBrails\fR executable, \fBbundle binstubs\fR will create a \fBbin/rails\fR executable that ensures that all referred dependencies will be resolved using the bundled gems\.
7878
.IP "\(bu" 4
79+
\fBbuild_path_extensions\fR (\fBBUNDLE_BUILD_PATH_EXTENSIONS\fR): Whether Bundler should build the native extensions of gems sourced from a local path\. Off by default, since building them means compiling code out of a directory Bundler does not own\. It can also be set for a single gem, as \fBbuild_path_extensions\.<gem>\fR (\fBBUNDLE_BUILD_PATH_EXTENSIONS__<GEM>\fR), which takes precedence over the global setting\.
80+
.IP
81+
Extensions are built out of tree: the gem is copied into a directory under \fBBUNDLE_PATH\fR and compiled there, so that nothing is ever written into your checkout, and the build directory is put ahead of the gem's own \fBlib\fR on the load path\. A rebuild happens whenever the size or modification time of any file under an extension's directory changes, or when the flags configured through \fBbuild\.<gem>\fR change\. Run \fBbundle pristine <gem>\fR to force one\.
82+
.IP "\(bu" 4
7983
\fBcache_all\fR (\fBBUNDLE_CACHE_ALL\fR): Cache all gems, including path and git gems\. This needs to be explicitly before bundler 4, but will be the default on bundler 4\.
8084
.IP "\(bu" 4
8185
\fBcache_all_platforms\fR (\fBBUNDLE_CACHE_ALL_PLATFORMS\fR): Cache gems for all platforms\.

lib/bundler/man/bundle-config.1.ronn

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,19 @@ learn more about their operation in [bundle install(1)](bundle-install.1.html).
122122
`rails` executable, `bundle binstubs` will create a `bin/rails` executable
123123
that ensures that all referred dependencies will be resolved using the
124124
bundled gems.
125+
* `build_path_extensions` (`BUNDLE_BUILD_PATH_EXTENSIONS`):
126+
Whether Bundler should build the native extensions of gems sourced from a
127+
local path. Off by default, since building them means compiling code out of
128+
a directory Bundler does not own. It can also be set for a single gem, as
129+
`build_path_extensions.<gem>` (`BUNDLE_BUILD_PATH_EXTENSIONS__<GEM>`), which
130+
takes precedence over the global setting.
131+
132+
Extensions are built out of tree: the gem is copied into a directory under
133+
`BUNDLE_PATH` and compiled there, so that nothing is ever written into your
134+
checkout, and the build directory is put ahead of the gem's own `lib` on the
135+
load path. A rebuild happens whenever the size or modification time of any
136+
file under an extension's directory changes, or when the flags configured
137+
through `build.<gem>` change. Run `bundle pristine <gem>` to force one.
125138
* `cache_all` (`BUNDLE_CACHE_ALL`):
126139
Cache all gems, including path and git gems. This needs to be explicitly
127140
before bundler 4, but will be the default on bundler 4.

lib/bundler/rubygems_ext.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,19 @@ def load_paths
223223
full_require_paths
224224
end
225225

226+
alias_method :rg_full_require_paths, :full_require_paths
227+
def full_require_paths
228+
@bundler_full_require_paths ||= begin # rubocop:disable Naming/MemoizedInstanceVariableName
229+
paths = rg_full_require_paths
230+
231+
if source.respond_to?(:path?) && source.path? && paths.include?(extension_dir)
232+
[extension_dir] + (paths - [extension_dir])
233+
else
234+
paths
235+
end
236+
end
237+
end
238+
226239
alias_method :rg_extension_dir, :extension_dir
227240
def extension_dir
228241
# following instance variable is already used in original method

lib/bundler/settings.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Settings
88

99
BOOL_KEYS = %w[
1010
auto_install
11+
build_path_extensions
1112
cache_all
1213
cache_all_platforms
1314
clean

lib/bundler/source/path.rb

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ def name
7575

7676
def install(spec, options = {})
7777
using_message = "Using #{version_message(spec, options[:previous_spec])} from #{self}"
78+
using_message += " with native extensions" if missing_extensions?(spec)
7879
using_message += " and installing its executables" unless spec.executables.empty?
7980
print_using_message using_message
80-
generate_bin(spec, disable_extensions: true)
81+
generate_bin(spec, disable_extensions: !missing_extensions?(spec), build_args: options[:build_args])
8182
nil # no post-install message
8283
end
8384

@@ -119,6 +120,16 @@ def expanded_original_path
119120
@expanded_original_path ||= expand(original_path)
120121
end
121122

123+
def extension_dir_for(spec)
124+
return unless build_extensions?(spec)
125+
126+
@extension_dirs ||= {}
127+
@extension_dirs[spec.full_name] ||= Bundler.install_path.join(
128+
"extensions", Gem::Platform.local.to_s, Gem.extension_api_version,
129+
"#{spec.full_name}-#{extension_digest(spec)}"
130+
).to_s
131+
end
132+
122133
private
123134

124135
def expanded_path
@@ -172,6 +183,10 @@ def load_spec_files
172183
# consider that for activation and never makes sense to ignore it.
173184
spec.ignored = false
174185

186+
if extension_dir = extension_dir_for(spec)
187+
spec.extension_dir = extension_dir
188+
end
189+
175190
# Validation causes extension_dir to be calculated, which depends
176191
# on #source, so we validate here instead of load_gemspec
177192
validate_spec(spec)
@@ -207,6 +222,55 @@ def load_spec_files
207222
index
208223
end
209224

225+
def build_extensions?(spec)
226+
return false unless path?
227+
return false if spec.extensions.empty?
228+
229+
per_gem = Bundler.settings["build_path_extensions.#{spec.name}"]
230+
return per_gem unless per_gem.nil?
231+
232+
Bundler.settings[:build_path_extensions] || false
233+
end
234+
235+
def missing_extensions?(spec)
236+
return false unless extension_dir_for(spec)
237+
238+
spec.missing_extensions?
239+
end
240+
241+
def extension_digest(spec)
242+
gem_dir = spec.full_gem_path
243+
digest = SharedHelpers.digest(:SHA256).new
244+
digest << Bundler.settings["build.#{spec.name}"].to_s
245+
246+
extension_source_files(spec).each do |file|
247+
digest << file.delete_prefix("#{gem_dir}/") << "\0"
248+
stat = File.stat(file)
249+
digest << "#{stat.size}-#{stat.mtime.to_i}-#{stat.mtime.nsec}" << "\0"
250+
end
251+
252+
digest.hexdigest[0, 12]
253+
end
254+
255+
def extension_source_files(spec)
256+
files = spec.extensions.flat_map do |extension|
257+
dir = File.dirname(extension)
258+
base = dir == "." ? spec.full_gem_path : File.join(spec.full_gem_path, dir)
259+
Gem::Util.glob_files_in_dir("**/*", base)
260+
end
261+
262+
files << spec.loaded_from.to_s
263+
files.uniq.select {|file| File.file?(file) }.sort
264+
end
265+
266+
def extension_build_dir(spec)
267+
return unless extension_dir_for(spec)
268+
269+
Bundler.bundle_path.join(
270+
"cache", "bundler", "path_extensions", File.basename(extension_dir_for(spec))
271+
).to_s
272+
end
273+
210274
def relative_path(path = self.path)
211275
if path.to_s.start_with?(root_path.to_s)
212276
return path.relative_path_from(root_path)
@@ -235,7 +299,8 @@ def generate_bin(spec, options = {})
235299
env_shebang: false,
236300
disable_extensions: options[:disable_extensions],
237301
build_args: options[:build_args],
238-
bundler_extension_cache_path: extension_cache_path(spec)
302+
bundler_extension_cache_path: extension_cache_path(spec),
303+
extension_build_dir: extension_build_dir(spec)
239304
)
240305
installer.post_install
241306
rescue Gem::InvalidSpecificationException => e

lib/bundler/source/path/installer.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative "../../rubygems_gem_installer"
4+
require_relative "../../vendored_fileutils"
45

56
module Bundler
67
class Source
@@ -18,6 +19,7 @@ def initialize(spec, options = {})
1819
@build_args = options[:build_args] || Bundler.rubygems.build_args
1920
@gem_bin_dir = "#{Bundler.rubygems.gem_dir}/bin"
2021
@disable_extensions = options[:disable_extensions]
22+
@extension_build_dir = options[:extension_build_dir]
2123
@bin_dir = @gem_bin_dir
2224
end
2325

@@ -34,8 +36,39 @@ def post_install
3436
run_hooks(:post_install)
3537
end
3638

39+
def build_extensions
40+
return super unless @extension_build_dir
41+
42+
stage_extension_sources
43+
44+
real_spec = @spec
45+
@spec = staged_spec(real_spec)
46+
begin
47+
super
48+
ensure
49+
@spec = real_spec
50+
FileUtils.rm_rf(@extension_build_dir)
51+
end
52+
end
53+
3754
private
3855

56+
def stage_extension_sources
57+
SharedHelpers.filesystem_access(@extension_build_dir, :create) do |path|
58+
FileUtils.rm_rf(path)
59+
FileUtils.mkdir_p(path)
60+
FileUtils.cp_r("#{@spec.full_gem_path}/.", path)
61+
end
62+
end
63+
64+
def staged_spec(spec)
65+
staged = spec.dup
66+
staged.source = nil
67+
staged.full_gem_path = @extension_build_dir
68+
staged.extension_dir = spec.extension_dir
69+
staged
70+
end
71+
3972
def run_hooks(type)
4073
hooks_meth = "#{type}_hooks"
4174
return unless Gem.respond_to?(hooks_meth)

0 commit comments

Comments
 (0)