Rip out bundler notes, they're now living at http://github.com/radar/how-rails-works/raw/master/bundler.textile. It really needs its own guide.

This commit is contained in:
Ryan Bigg 2010-04-14 19:43:27 +10:00
parent 8b14275f0f
commit 50aa106fa5

@ -1792,556 +1792,9 @@ Now that Rails has finished loading all the Railties by way of +require 'rails/a
NOTE: It is worth mentioning here that you are not tied to using Bundler with Rails 3, but it is (of course) advised that you do. To "turn off" Bundler, comment out or remove the corresponding lines in _config/application.rb_ and _config/boot.rb_.
Bundler was +require+'d back in _config/boot.rb_ and now we'll dive into the internals of Bundler to determine precisely what this line accomplishes.
h4. +Bundler.require+
+Bundler.require+ is defined in _lib/bundler.rb_:
<ruby>
def require(*groups)
gemfile = default_gemfile
load(gemfile).require(*groups)
end
</ruby>
The +groups+ variable here would be a two-element array of the arguments passed to +Bundler.require+. In this case we're going to assume, +Rails.env+ is +"development"+.
h4. Locating the Gemfile
+default_gemfile+ is defined in _lib/bundler.rb_ and makes a call out to the +SharedHelpers+ module:
<ruby>
def default_gemfile
SharedHelpers.default_gemfile
end
</ruby>
+SharedHelpers+ defines +default_gemfile+ like this:
<ruby>
def default_gemfile
gemfile = find_gemfile
gemfile or raise GemfileNotFound, "The default Gemfile was not found"
Pathname.new(gemfile)
end
</ruby>
+find_gemfile+ is defined like this:
<ruby>
def find_gemfile
return ENV['BUNDLE_GEMFILE'] if ENV['BUNDLE_GEMFILE']
previous = nil
current = File.expand_path(Dir.pwd)
until !File.directory?(current) || current == previous
filename = File.join(current, 'Gemfile')
return filename if File.file?(filename)
current, previous = File.expand_path("..", current), current
end
end
</ruby>
The first line of course means if you define the environment variable +BUNDLE_GEMFILE+ this is the name of the file that will be used and returned. If not, then Bundler will look for a file called _Gemfile_ in the current directory and if it can find it then it will return the filename. If it cannot, it will recurse up the directory structure until it does. Once the file is found a +Pathname+ is made from the expanded path to _Gemfile_.
If the file cannot be found at all then +GemfileNotFound+ will be raised back in +default_gemfile+.
h4. Loading the Gemfile
Now that Bundler has determined what the _Gemfile_ is, it goes about loading it:
<ruby>
def require(*groups)
gemfile = default_gemfile
load(gemfile).require(*groups)
end
</ruby>
+load+ is defined like this in _lib/bundler.rb_:
<ruby>
def load(gemfile = default_gemfile)
root = Pathname.new(gemfile).dirname
Runtime.new root, definition(gemfile)
end
</ruby>
The next method to be called here would be +definition+ and it is defined like this:
<ruby>
def definition(gemfile = default_gemfile)
configure
root = Pathname.new(gemfile).dirname
lockfile = root.join("Gemfile.lock")
if lockfile.exist?
Definition.from_lock(lockfile)
else
Definition.from_gemfile(gemfile)
end
end
</ruby>
+configure+ is responsible for setting up the path to gem home and gem path:
<ruby>
def configure
@configured ||= begin
configure_gem_home_and_path
true
end
end
</ruby>
+configure_gem_home_and_path+ defined like this:
<ruby>
def configure_gem_home_and_path
if settings[:disable_shared_gems]
ENV['GEM_HOME'] = File.expand_path(bundle_path, root)
ENV['GEM_PATH'] = ''
else
gem_home, gem_path = Gem.dir, Gem.path
ENV["GEM_PATH"] = [gem_home, gem_path].flatten.compact.join(File::PATH_SEPARATOR)
ENV["GEM_HOME"] = bundle_path.to_s
end
Gem.clear_paths
end
</ruby>
We do not have +settings[:disabled_shared_gems]+ set to true so this will execute the code under the +else+. The +ENV["GEM_PATH"]+ will resemble +/usr/local/lib/ruby/gems/1.9.1:/home/you/.gem/ruby/1.9.1+
And +ENV["GEM_HOME"]+ will be the path to the gems installed into your home directory by Bundler, something resembling +/home/you/.bundle/ruby/1.9.1+.
After +configure_gem_home_and_path+ is done the +definition+ method goes about creating a +Definition+ from either +Gemfile.lock+ if it exists, or the +gemfile+ previously located. +Gemfile.lock+ only exists if +bundle lock+ has been ran and so far it has not.
+Definition.from_gemfile+ is defined in _lib/bundler/definition.rb_:
<ruby>
def self.from_gemfile(gemfile)
gemfile = Pathname.new(gemfile).expand_path
unless gemfile.file?
raise GemfileNotFound, "#{gemfile} not found"
end
Dsl.evaluate(gemfile)
end
</ruby>
Now that the +gemfile+ is located +Dsl.evaluate+ goes about loading it. The code for this can be found in _lib/bundler/dsl.rb_:
<ruby>
def self.evaluate(gemfile)
builder = new
builder.instance_eval(File.read(gemfile.to_s), gemfile.to_s, 1)
builder.to_definition
end
</ruby>
+new+ here will, of course, call +initialize+ which sets up a couple of variables:
<ruby>
def initialize
@source = nil
@sources = []
@dependencies = []
@group = nil
end
</ruby>
When Bundler calls +instance_eval+ on the new +Bundler::Dsl+ object it evaluates the content of the +gemfile+ file within the context of this instance. The Gemfile for a default Rails 3 project with all the comments stripped out looks like this:
<ruby>
source 'http://rubygems.org'
gem 'rails', '3.0.0.beta1'
# Bundle edge Rails instead:
# gem 'rails', :git => 'git://github.com/rails/rails.git'
gem 'sqlite3-ruby', :require => 'sqlite3'
</ruby>
When Bundler loads this file it firstly calls the +source+ method on the +Bundler::Dsl+ object:
<ruby>
def source(source, options = {})
@source = case source
when :gemcutter, :rubygems, :rubyforge then Source::Rubygems.new("uri" => "http://gemcutter.org")
when String then Source::Rubygems.new("uri" => source)
else source
end
options[:prepend] ? @sources.unshift(@source) : @sources << @source
yield if block_given?
@source
ensure
@source = nil
end
</ruby>
TODO: Perhaps make this a side-note. However you do that.
The interesting thing to note about this method is that it takes a block, so you may do:
<ruby>
source 'http://someothergemhost.com' do
gem 'your_favourite_gem'
end
</ruby>
if you wish to install _your_favourite_gem_ from _http://someothergemhost.com_.
In this instance however a block is not specified so this sets up the +@source+ instance variable to be +'http://rubygems.org'+.
The next method that is called is +gem+:
<ruby>
def gem(name, *args)
options = Hash === args.last ? args.pop : {}
version = args.last || ">= 0"
if options[:group]
options[:group] = options[:group].to_sym
end
_deprecated_options(options)
_normalize_options(name, version, options)
@dependencies << Dependency.new(name, version, options)
end
</ruby>
This sets up a couple of important things initially. If you specify a gem like the following:
<ruby>
gem 'rails', "2.3.4"
</ruby>
This sets +options+ to be an empty hash, but +version+ to be +"2.3.4"+. TODO: How does one pass options and versions at the same time?
In the Gemfile for a default Rails project, the first +gem+ line is:
<ruby>
gem 'rails', '3.0.0.beta2'
</ruby>
TODO: change version number.
This line will check that +options+ contains no deprecated options by using the +_deprecated_options+ method, but the +options+ hash is empty. This is of course until +_normalize_options+ has its way:
<ruby>
def _normalize_options(name, version, opts)
_normalize_hash(opts)
group = opts.delete("group") || @group
# Normalize git and path options
["git", "path"].each do |type|
if param = opts[type]
options = _version?(version) ? opts.merge("name" => name, "version" => version) : opts.dup
source = send(type, param, options, :prepend => true)
opts["source"] = source
end
end
opts["source"] ||= @source
opts["group"] = group
end
</ruby>
+_normalize_hash+ will convert all the keys in the +opts+ hash to strings. There is neither a +git+ or a +path+ key in the +opts+ hash so the next couple of lines are ignored, then the +source+ and +group+ keys are set up.
TODO: Maybe it is best to cover what would happen in the case these lines did exist?
The next line goes about defining a dependency for this gem:
<ruby>
@dependencies << Dependency.new(name, version, options)
</ruby>
This class is defined like this:
<ruby>
module Bundler
class Dependency < Gem::Dependency
attr_reader :autorequire
attr_reader :groups
def initialize(name, version, options = {}, &blk)
super(name, version)
@autorequire = nil
@groups = Array(options["group"] || :default).map { |g| g.to_sym }
@source = options["source"]
if options.key?('require')
@autorequire = Array(options['require'] || [])
end
end
end
end
</ruby>
The +initialize+ method in +Gem::Dependency+ is defined:
<ruby>
def initialize(name, version_requirements, type=:runtime)
@name = name
unless TYPES.include? type
raise ArgumentError, "Valid types are #{TYPES.inspect}, not #{@type.inspect}"
end
@type = type
@version_requirements = Gem::Requirement.create version_requirements
@version_requirement = nil # Avoid warnings.
end
</ruby>
The +version_requirements+ that was passed in here will be inspected by +Gem::Requirement.create+ and return, for our +3.0.0beta2+ version string a +Gem::Requirement+ object:
<ruby>
#<Gem::Requirement:0x101dd8c20 @requirements=[["=", #<Gem::Version "3.0.0beta2">]]>
</ruby>
Going back to +Bundler::Dependency+, the next line simply sets +@autorequire+ to +nil+ and the next line is a little more interesting:
<ruby>
@autorequire = nil
@groups = Array(options["group"] || :default).map { |g| g.to_sym }
</ruby>
Here, bundler sets the +groups+ variable to be whatever +group+ we've set for this gem and also demonstrates through code that the +group+ option allows for multiple groups, so in the _Gemfile_ you can specify the same gem for multiple groups:
<ruby>
group :test, :cucumber do
gem 'faker'
end
</ruby>
The final lines in +initialize+ work on the +require+ option which is not passed:
<ruby>
if options.key?('require')
@autorequire = Array(options['require'] || [])
end
</ruby>
If it were to be used in the _Gemfile_, it would look like this:
<ruby>
gem 'thinking-sphinx', :require => "thinking_sphinx"
</ruby>
So far, this is what simply loading the _Gemfile_ does.
h3. Bring forth the gems
Now that the _Gemfile_ has finished being parsed, the next line is:
<ruby>
builder.to_definition
</ruby>
This method is defined in _lib/bundler/dsl.rb_ and does this:
<ruby>
def to_definition
Definition.new(@dependencies, @sources)
end
</ruby>
The +Bundler::Definition#initialize+ method is this:
<ruby>
def initialize(dependencies, sources)
@dependencies = dependencies
@sources = sources
end
</ruby>
Now Bundler has a +Bundler::Definition+ object to be passed back to the +load+ method from _lib/bundler.rb_:
<ruby>
def load(gemfile = default_gemfile)
root = Pathname.new(gemfile).dirname
Runtime.new root, definition(gemfile)
end
</ruby>
The +Bundler::Runtime+ class inherits from +Bundler::Environment+ and the reason this is pointed out is because +super+ is used in the +initialize+ method in +Bundler::Runtime+:
<ruby>
super
if locked?
write_rb_lock
end
</ruby>
Thankfully, the +Bundler::Environment#initialize+ method is nothing too complex:
<ruby>
def initialize(root, definition)
@root = root
@definition = definition
end
</ruby>
The +locked?+ method checks if the _Gemfile.lock_ or _.bundler/environment.rb_ files exist:
<ruby>
def locked?
File.exist?("#{root}/Gemfile.lock") || File.exist?("#{root}/.bundle/environment.rb")
end
</ruby>
And if they do will call +write_rb_lock+:
<ruby>
def write_rb_lock
shared_helpers = File.read(File.expand_path("../shared_helpers.rb", __FILE__))
template = File.read(File.expand_path("../templates/environment.erb", __FILE__))
erb = ERB.new(template, nil, '-')
FileUtils.mkdir_p(rb_lock_file.dirname)
File.open(rb_lock_file, 'w') do |f|
f.puts erb.result(binding)
end
end
</ruby>
This will write out to _.bundler/environment.rb_ the state of the current environment.
Now a quick refresher. Bundler is still evaulating the code for the +require+ in _lib/bundler.rb_, and the +groups+ variable here is an +Array+ containing two elements: +:default+ and the current Rails environment: +development+:
<ruby>
def require(*groups)
gemfile = default_gemfile
load(gemfile).require(*groups)
end
</ruby>
The +load+ method returns a +Bundler::Runtime+ object. The second +require+ method here:
<ruby>
load(gemfile).require(*groups)
</ruby>
Is defined on _bundler/runtime.rb_:
<ruby>
def require(*groups)
groups.map! { |g| g.to_sym }
groups = [:default] if groups.empty?
autorequires = autorequires_for_groups(*groups)
groups.each do |group|
(autorequires[group] || [[]]).each do |path, explicit|
if explicit
Kernel.require(path)
else
begin
Kernel.require(path)
rescue LoadError
end
end
end
end
end
</ruby>
This method does TODO: Describe what magic this undertakes when you've gone through the rest of the source.
The first method to be called here is +autorequires_for_groups+ and this method is defined in _bundler/environment.rb_:
<ruby>
def autorequires_for_groups(*groups)
groups.map! { |g| g.to_sym }
autorequires = Hash.new { |h,k| h[k] = [] }
ordered_deps = []
specs_for(*groups).each do |g|
dep = @definition.dependencies.find{|d| d.name == g.name }
ordered_deps << dep if dep && !ordered_deps.include?(dep)
end
</ruby>
The +specs_for+ method looks like this:
<ruby>
def specs_for(groups)
deps = dependencies.select { |d| (d.groups & groups).any? }
specs.for(deps)
end
</ruby>
The first line here:
<ruby>
deps = dependencies.select { |d| (d.groups & groups).any? }
</ruby>
Goes through all the +dependencies+ defined and checks to see if their +groups+ match any of the +groups+ defined and returns the ones that are for the groups that are requested.
The next line firstly calls the +specs+ method:
<ruby>
specs.for(deps)
</ruby>
Which is defined like this:
<ruby>
def specs
@specs ||= resolve_locally || resolve_remotely
end
</ruby>
This attempts to resolve the dependencies locally, and if it cannot then it will attempt to resolve them remotely. Firstly, `resolve_locally`:
<ruby>
def resolve_locally
resolve(:local_specs, index)
end
</ruby>
The resolve method is defined like this:
<ruby>
def resolve(type, index)
source_requirements = {}
actual_dependencies.each do |dep|
next unless dep.source && dep.source.respond_to?(type)
source_requirements[dep.name] = dep.source.send(type)
end
# Run a resolve against the locally available gems
Resolver.resolve(actual_dependencies, index, source_requirements)
end
</ruby>
The +actual_dependencies+ referenced is defined back in _bundler/runtime.rb_:
<ruby>
def actual_dependencies
@definition.actual_dependencies
end
</ruby>
And the +@definition+ here is a +Bundler::Definition+ object, so that means the +actual_dependencies+ method called here is defined in _bundler/definition.rb_:
<ruby>
def actual_dependencies
@actual_dependencies ||= @details["specs"].map do |args|
name, details = args.to_a.flatten
details["source"] = sources[details["source"]] if details.include?("source")
Bundler::Dependency.new(name, details.delete("version"), details)
end
end
</ruby>
Bundler was +require+'d back in _config/boot.rb_, and so that is what makes it available here. This guide does not dive into the internals of Bundler; it's really it's own separate guide.
The +Bundler.require+ method adds all the gems not specified inside a +group+ in the +Gemfile+ and the ones specified in groups for the +Rails.env+ (in this case, _development_), to the load path. This is how an application is able to find them.
@ -2712,7 +2165,7 @@ The method +find_with_root_flag+ is defined on +Rails::Engine+ (the superclass o
root = File.exist?("#{root_path}/#{flag}") ? root_path : default
raise "Could not find root path for #{self}" unless root
RUBY_PLATFORM =~ /mswin|mingw/ ?
RUBY_PLATFORM =~ /(:?mswin|mingw)/ ?
Pathname.new(root).expand_path : Pathname.new(root).realpath
end
</ruby>