Gems are versioned; Bundler pins them¶
Level: 201 · a Python programmer who knows pip, venv and pyproject.toml and wants the Ruby names for the same jobs
One line: A gem is a versioned package that RubyGems can activate by name (gem "x"), some ship inside Ruby (default gems), some ship beside it (bundled gems), and Bundler's Gemfile plus Gemfile.lock pin the whole set — while Python has the stdlib, pip, and one pyproject.toml playing the parts of Gemfile and gemspec at once.
Three layers matter. Core classes (Array, Set since 4.0) need no require at all. Default gems (json, bundler) are installed with Ruby and require finds them at once; they are gems, so Gem.loaded_specs knows their name after a require. Bundled gems (minitest, csv since 3.4) are installed alongside Ruby by the installer and are ordinary gems that a Gemfile must list once you use Bundler — Gem::BUNDLED_GEMS::SINCE records the release in which a former default gem became one, which is the source of the "csv was loaded from the standard library, but will no longer be part of the default gems" warning people met in 3.3. Gem::Version compares versions as numbers, not strings, and understands prereleases; Gem::Requirement is the ~> 1.2 pessimistic operator (>= 1.2, < 2.0). A Gemfile is Ruby code evaluated by Bundler's DSL, and a gemspec is a Ruby object too — both are built and read below, in-process, with nothing installed and no network. bundle install, bundle exec and the lockfile are described after the output (Not machine-checked here.).
Verified output of gems_bundler_and_gemfile_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. json: part of the standard install? default gem: true
2. csv: part of the standard install? default gem: false bundled gem since 3.4.0
3. minitest: default gem: false in the since-list: false (bundled since before 3.0)
4. set: no gem at all (Gem::MissingSpecError) -- a core class in 4.0
5. what require "json" activated Gem.loaded_specs["json"].name = json; a default gem has a spec too
6. gem "minitest" in code (activate) first: true second: false
7. gem "nope" Gem::MissingSpecError is a LoadError: true e.name: "nope"
8. versions compare as numbers Gem::Version 1.10 > 1.9: true as strings: false 2.0.0.pre < 2.0.0: true
9. a requirement, ~> 1.2 satisfied by 1.9: true by 2.0: false
10. a Gemfile, evaluated by Bundler minitest (~> 5.0) [:default], csv (>= 0) [:default], rake (>= 0) [:development]
11. a gemspec object demo-0.1.0 runtime: json (~> 2.0) development: minitest (>= 5, development)
Reading the output¶
Rows 1–4 sort four familiar names into the three layers: json is a default gem, csv and minitest are bundled gems (csv since 3.4.0, minitest for so long that the since-list does not even mention it), and Set is a core class in 4.0 — asking RubyGems for a set gem raises Gem::MissingSpecError. Row 5 shows that a require of a default gem activates it: Gem.loaded_specs["json"] holds a specification afterwards. Row 6 is the explicit form, gem "minitest", which puts the newest installed version of that gem on $LOAD_PATH without requiring anything: true when it activated something, false when it was already active. Row 7 is what a missing gem raises — Gem::MissingSpecError, a LoadError, so a bare rescue will not catch it; its message names the number of installed gems, so only the class and name are printed. Row 8 is why version strings must never be compared as strings: "1.10" > "1.9" is false as text and true as Gem::Version, and 2.0.0.pre sorts before 2.0.0. Row 9 reads ~> 1.2 as "1.2 or newer, but below 2.0". Row 10 evaluates a five-line Gemfile with Bundler::Dsl.evaluate and lists what it declared, including the :development group; row 11 builds a Gem::Specification and prints the same three facts a gemspec file holds — name, version, dependencies.
A project's Gemfile looks like this (Not machine-checked here.):
bundle install resolves the requirements against the gem server, installs what is missing, and writes Gemfile.lock — the exact versions chosen, which is committed so that every machine and CI run gets the same set. bundle exec ruby app.rb (or a require "bundler/setup" at the top of the program) restricts $LOAD_PATH to exactly the locked versions, so a newer gem installed system-wide cannot leak in. A gem you publish has a demo.gemspec, the file form of row 11 (Not machine-checked here.):
Gem::Specification.new do |s|
s.name = "demo"
s.version = "0.1.0"
s.summary = "A demo gem"
s.authors = ["A. Reader"]
s.files = Dir["lib/**/*.rb"]
s.add_dependency "json", "~> 2.0"
s.add_development_dependency "minitest", ">= 5"
end
gem build demo.gemspec makes demo-0.1.0.gem; gem push publishes it; gem list, gem env and gem which json answer the questions this page asked from code.
Compared with Python¶
Python's layers are two, not three: the stdlib (sys.stdlib_module_names says whether a name belongs to it — rows 1–3 — and set is a builtin type, row 4) and installed distributions in site-packages, which pip install puts there and importlib.metadata describes. A stdlib module has no package metadata at all (row 5 raises PackageNotFoundError), whereas Ruby's default gems do have a specification. There is no gem "x" activation step: import takes the first match on sys.path, so choosing a version means choosing an environment — a venv per project, or uv managing both the interpreter and the environment (Not machine-checked here.). Row 7's PackageNotFoundError is a ModuleNotFoundError, the same family as Ruby's LoadError. Rows 8 and 9 show a real gap: the stdlib has no Version or Requirement class — the twin compares tuples of integers and writes ~= 1.2 by hand; the packaging library on PyPI provides both (Not machine-checked here.). Rows 10 and 11 read one pyproject.toml with tomllib (3.11+): its [project] table is at once the gemspec (name, version) and the Gemfile (dependencies, optional-dependencies), and the lockfile role falls to uv.lock, poetry.lock or a pinned requirements.txt. Note the operator spelling: Ruby's pessimistic ~> 1.2 is Python's compatible-release ~= 1.2. The Python library's pyproject.toml page ↗ walks that file table by table.
Verified output of gems_bundler_and_gemfile_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. json: part of the standard install? stdlib module: True
2. csv: part of the standard install? stdlib module: True (Ruby moved csv out; Python did not)
3. minitest / unittest: minitest: False unittest: True
4. set: builtin type: True -- no module at all
5. what import json loaded json.__spec__.name = json; no package metadata (PackageNotFoundError: the stdlib is not a package)
6. no gem 'x' in code: import just finds first: loads second: cached (same object: True)
7. importlib.metadata.version('nope') PackageNotFoundError is a ModuleNotFoundError: True e.name: 'nope'
8. versions compare as numbers tuples (1, 10) > (1, 9): True as strings: False (no Version class in the stdlib)
9. a requirement, ~= 1.2 by hand satisfied by 1.9: True by 2.0: False
10. a pyproject.toml, read by tomllib dependencies: ['minitest~=5.0', 'csv'] dev: ['rake']
11. the [project] table is the gemspec demo-0.1.0 (name, version, dependencies in one file)
Try it¶
- Take the version strings
1.10.0,1.9.2,1.9.10,2.0.0.pre1and2.0.0. Print them sorted as strings and sorted asGem::Version, then say for each whether~> 1.9accepts it and whether it is a prerelease, and finish with the newest non-prerelease. - Write a
Gemfilestring with two gems in the default group and one in:test, evaluate it withBundler::Dsl.evaluate, and print only the dependencies thatdefinition.dependenciesputs in the:testgroup. Then addplatforms: :mrito one gem and print whatd.platformssays.
Solution to 1
gems_bundler_and_gemfile_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: sort version strings the way RubyGems does, next to a plain string sort,
# and check each against a pessimistic requirement.
versions = ["1.10.0", "1.9.2", "1.9.10", "2.0.0.pre1", "2.0.0"]
puts "1. as strings: #{versions.sort.inspect}"
puts "2. as Gem::Version: #{versions.sort_by { Gem::Version.new(it) }.inspect}"
req = Gem::Requirement.new("~> 1.9")
puts "3. #{req} accepts:"
versions.each do |s|
v = Gem::Version.new(s)
puts format(" %-10s %-5s prerelease: %s", s, req.satisfied_by?(v), v.prerelease?)
end
puts "4. newest non-prerelease: #{versions.map { Gem::Version.new(it) }.reject(&:prerelease?).max}"
Verified output of gems_bundler_and_gemfile_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. as strings: ["1.10.0", "1.9.10", "1.9.2", "2.0.0", "2.0.0.pre1"]
2. as Gem::Version: ["1.9.2", "1.9.10", "1.10.0", "2.0.0.pre1", "2.0.0"]
3. ~> 1.9 accepts:
1.10.0 true prerelease: false
1.9.2 true prerelease: false
1.9.10 true prerelease: false
2.0.0.pre1 false prerelease: true
2.0.0 false prerelease: false
4. newest non-prerelease: 2.0.0
See also¶
requireloads once;loadruns every time — whatrequiredoes with the$LOAD_PATHentries a gem activation adds- What changed, 1.9 to 4.0 — the releases in which
csv,bigdecimaland friends stopped being default gems Setis a core class — the class row 4 says has no gem- Resources — where RubyGems, Bundler and rubygems.org are documented
- Python:
pyproject.toml↗ — the one file that is Python's Gemfile and gemspec together - Rust:
Cargo.lock↗ — the same lockfile idea, measured in a third language - Rust: Cargo dependencies ↗ — caret and tilde requirements next to
~> JSON.parsegives string keys;YAML.loadis safe — JSON is text for everyone and loses what JSON cannot say- A Minitest test is a plain class — Minitest, a bundled gem, runs every
test_*method of aMinitest::Testsubclass in a seeded random…