Python Tooling for Ruby Developers
Bundler manages dependencies, RuboCop enforces style, RSpec runs tests, and rbenv switches Ruby versions. Python has equivalent tools for each of those jobs, though they are organized differently. uv consolidates dependency management, interpreter installation, and virtual environment handling into one command, covering territory that Ruby spreads across Bundler, rbenv, and ruby-build. This guide maps Ruby tooling concepts to their Python counterparts so Ruby developers can orient quickly.
What Will Feel Familiar, and What Will Not
Most Ruby workflows have a recognizable Python equivalent:
| Ruby | Python | Notes |
|---|---|---|
| rbenv / ruby-build | uv python install + uv python pin |
Interpreter version management |
| Bundler | uv | Dependency resolution and installation |
Gemfile |
pyproject.toml dependencies under [project] |
Dependency declarations |
Gemfile.lock |
uv.lock |
Pinned dependency graph |
.gemspec |
pyproject.toml [project] |
Package metadata for publishing |
| RuboCop | Ruff | Linter and formatter |
| RSpec / Minitest | pytest | Test framework |
| Rake | No dominant equivalent | See Replacing Rake |
bundle exec |
uv run |
Run commands in the dependency context |
| RubyGems / rubygems.org | PyPI / pypi.org | Package registry |
gem build + gem push |
uv build + uv publish |
Build and publish a package |
| Sorbet / RBS | mypy / pyright / ty | Optional type checking (Sorbet checks types; RBS is a type-signature format) |
Important
These analogies are orientation aids, not exact equivalences. Each pairing hides real differences in scope and behavior.
Two mental model gaps affect daily work.
pyproject.toml replaces both Gemfile and .gemspec. Ruby separates dependency declarations (Gemfile, consumed by Bundler) from package metadata (.gemspec, consumed by RubyGems). Python’s pyproject.toml merges both into one file: the [project] table holds metadata (name, version, authors) and a dependencies list for runtime dependencies, and [dependency-groups] handles development and test dependencies. Tool configuration for Ruff, pytest, and type checkers also lives in pyproject.toml under [tool.*] sections, consolidating what Ruby spreads across .rubocop.yml, .rspec, and separate config files.
Standards and tools are separate. In Ruby, Bundler defines the dependency format and implements the resolver. In Python, PEPs define interfaces (how to declare dependencies, how to build packages, how to specify metadata), and multiple tools implement those interfaces. This is why so many packaging tools exist. uv covers most of the surface area, but understanding that standards and tools are decoupled explains choices that otherwise seem arbitrary.
Python’s Three-Layer Model
Ruby keeps interpreter management (rbenv) separate from dependency management (Bundler), but Bundler reads the Gemfile and resolves gems against whatever Ruby version is active. Python separates three layers more explicitly.
Interpreter Management
uv python install 3.12 fills the role of rbenv install 3.2.4. It downloads and manages Python interpreters. A .python-version file pins the project’s interpreter, identical in concept to rbenv’s .ruby-version. The difference: uv handles both interpreter installation and dependency management, while Ruby requires rbenv (or rvm) for version management and Bundler for dependencies.
The Virtual Environment
Ruby gems install into a shared or vendored location. bundle install --path vendor/bundle isolates gems per project, but this is opt-in. Python makes isolation the default through virtual environments. A virtual environment is a directory containing a link to a Python interpreter and its own site-packages folder where dependencies live. Without one, all projects share the same global set of packages, leading to version conflicts.
uv creates and manages virtual environments automatically. Running uv sync or uv run ensures the environment exists and matches the project’s declared dependencies. The .venv directory serves a role similar to vendor/bundle, but it is always project-scoped. Unlike vendor/bundle, a virtual environment’s executables live in .venv/bin/ (or .venv\Scripts\ on Windows) and can be invoked directly (.venv/bin/python main.py), through shell activation (source .venv/bin/activate), or through uv run, which handles this automatically.
pyproject.toml
pyproject.toml is the single file that replaces Gemfile, .gemspec, and most tool-specific config files. It declares the project name, version, dependencies, and Python version constraints. It also hosts configuration for other tools in [tool.*] tables:
[project]
name = "myapp"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["requests>=2.31"]
[dependency-groups]
dev = ["pytest>=8.0", "ruff>=0.11"]
[tool.ruff]
line-length = 88
[tool.pytest.ini_options]
testpaths = ["tests"]Compare this to a Ruby project that needs a Gemfile, a .gemspec (for libraries), .rubocop.yml, and .rspec. Python puts everything in one place.
The Daily Development Loop
Adding and Managing Dependencies
uv add requests works like adding gem 'faraday' to a Gemfile and running bundle install in one step. It updates pyproject.toml and writes a lock file (uv.lock). To install from an existing lock file on a fresh clone:
uv sync # like bundle install (from lock file)uv sync is closer to bundle install than to bundle update: it resolves and installs dependencies from the lock file. If uv.lock is out of date, uv sync updates it first. To enforce that the lock file is current and fail if it is stale (useful in CI), pass --locked. To skip the freshness check and use the existing lock file as-is, pass --frozen.
Python supports optional extras and dependency groups for separating development, testing, and documentation dependencies. Dependency groups fill the role of Bundler’s groups (group :development do ... end), with the distinction that Python’s groups are declared in [dependency-groups] tables. The dev group is synced by default; non-default groups are installed selectively with uv sync --group docs or uv sync --only-group lint.
Running Python Code
Running a script through uv:
uv run python main.py # like bundle exec ruby main.rb
uv run main.py # also works for .py filesuv run ensures the virtual environment exists and dependencies are installed before executing. It fills the same role as bundle exec, which ensures commands run against the project’s bundled gems rather than system-wide installations.
Python has two execution modes that Ruby does not distinguish. python file.py runs a single file. python -m package runs a package as a module, which affects how imports resolve. Ruby’s require and require_relative handle both cases transparently; Python’s import system needs to know whether the code is a script or part of a package. When in doubt, prefer uv run python -m mypackage.
Console scripts (entry points defined in pyproject.toml) serve a similar role to binstubs or executable entries in a gemspec:
[project.scripts]
myapp = "mypackage.cli:main"For library development, the src/ layout keeps source code separate from configuration and tests. Combined with an editable install (which uv sync handles automatically), code changes take effect without reinstalling.
Replacing Rake
Rake is Ruby’s built-in task runner, used for database migrations, code generation, and custom build steps. Python has no single dominant equivalent.
uv runwith explicit commands.uv run pytest --covreplacesrake test. No aliases, but tab completion and shell history reduce the friction.- Makefile. A
Makefilewith targets likemake testandmake lintis the closest analog to aRakefile. It works on macOS and Linux out of the box. Windows users needmakeinstalled or can use just as a cross-platform alternative. - nox or tox. These test automation tools define sessions (nox) or environments (tox) in Python or config files. They excel at multi-version testing but are heavier than Rake for simple tasks.
Start with direct uv run commands; add a Makefile only when the command list feels repetitive.
Code Quality: Linting and Formatting
Ruff combines linting and formatting into one tool, covering the territory RuboCop handles alone. Running ruff check lints, ruff format formats, and ruff check --fix auto-fixes:
uv run ruff check . # lint (like rubocop)
uv run ruff format . # format (like rubocop -A for layout cops)RuboCop organizes its checks into departments (Layout, Lint, Style, Metrics, Naming, Security). Ruff uses a similar rule-code system where each rule belongs to a category (E for pycodestyle errors, F for pyflakes, I for import sorting, S for security). Configuration lives in pyproject.toml:
[tool.ruff.lint]
select = ["E", "F", "I", "S"]Where RuboCop reads .rubocop.yml and allows per-directory overrides, Ruff reads pyproject.toml and supports per-file rule overrides through [tool.ruff.lint.per-file-ignores].
Type Checking
Ruby developers familiar with Sorbet or RBS will find Python’s type-checking ecosystem more widely adopted. Python’s function annotation syntax has been part of the language since Python 3.0, and PEP 484 (Python 3.5) standardized it for type hints. Annotations appear inline alongside function signatures and variable declarations. Ruby’s Sorbet supports inline sig annotations in .rb files but also uses supplemental .rbi interface files; RBS uses separate .rbs signature files entirely.
mypy is the original type checker and the most widely adopted. pyright is the type checker behind VS Code’s Pylance extension. ty is the newest, built by the team behind Ruff. See how they compare.
uv run mypy .Type annotations are optional. Python itself runs code with zero annotations, but type checkers can still report errors in unannotated code through type inference. Because typing is gradual, most teams adopt it incrementally: add annotations to new code, tighten strictness over time.
Testing with pytest
pytest is Python’s dominant test framework. RSpec developers will notice a shift from BDD-style DSL (describe/it/expect) to plain functions and assertions:
# RSpec equivalent:
# describe "addition" do
# it "adds two numbers" do
# expect(1 + 1).to eq(2)
# end
# end
def test_addition():
assert 1 + 1 == 2No imports needed, no test class required. pytest discovers tests by naming convention: files named test_*.py containing functions named test_*.
pytest fixtures replace RSpec’s let, before, and after blocks with a dependency-injection model. A fixture is a function that provides test dependencies, and test functions declare which fixtures they need as parameters:
def test_read_config(tmp_path):
config_file = tmp_path / "config.toml"
config_file.write_text('key = "value"')
assert read_config(config_file)["key"] == "value"tmp_path is a built-in fixture that creates a temporary directory. pytest wires it up automatically, the way RSpec’s let blocks lazily evaluate and cache their values.
uv run pytest # like bundle exec rspec
uv run pytest -v # verbose output
uv run pytest -x # stop on first failurePackaging and Distribution
Library Packaging
Publishing to PyPI (the Python Package Index) is analogous to gem push. The package format differs: Python libraries are distributed as wheels (prebuilt) and sdists (source distributions), built by a build backend.
uv build # creates wheel and sdist (like gem build myapp.gemspec)
uv publish # uploads to PyPI (like gem push myapp-0.1.0.gem)Ruby gems can be source-only or platform-specific (prebuilt for a target OS and architecture), but the gem format bundles source and compiled extensions together. Python separates these into two distinct formats: sdists contain source code, and wheels contain prebuilt binaries. When a compatible wheel exists, installation skips the build step entirely. This is why pip install numpy finishes in seconds rather than compiling C code from source. If no suitable wheel is available, the installer falls back to building from an sdist.
Application Deployment
Ruby applications deploy as source code with dependencies installed via bundle install on the target, often managed by Capistrano, Docker, or a platform like Heroku. Python applications deploy the same way, with a virtual environment instead of bundled gems:
- Container-based: a
Dockerfilethat installs dependencies withuv sync --locked(or--frozenwhen the lock file is already verified) and copies the source. - Platform-managed: services like Heroku, Railway, or Render that read
pyproject.tomland install dependencies at deploy time.
Ruby developers using Puma or Unicorn for process management will find that gunicorn and uvicorn serve the same role for Python web applications: process supervision and worker management.
CLI Tools
uvx runs a package’s CLI in an ephemeral, isolated environment without installing it into the project, similar to npx in the Node ecosystem:
uvx ruff check . # run ruff without installing itFor permanent installation, uv tool install creates an isolated environment per tool:
uv tool install ruff # like gem install rubocopWhat Python Offers, and What Ruby Developers Will Miss
Python’s REPL (and IPython) supports interactive development like IRB and Pry. Jupyter notebooks extend this into a development paradigm used across data science and machine learning. Ruby has a Jupyter kernel (IRuby), but the notebook ecosystem is far more central to Python workflows. Tools like tox and nox automate testing across multiple Python versions, filling a gap that Ruby’s ecosystem addresses with CI matrix builds but no dedicated local tool.
Ruby developers will miss Rake. The task runner that ships with every Ruby installation has no Python counterpart, and third-party alternatives require choosing and configuring an extra tool. Rails’ convention-over-configuration approach to project structure (migrations, asset pipeline, generators) has no equivalent in Python’s standard tooling; Django provides some of this, but the Python ecosystem as a whole favors explicit configuration over convention. And while bundle exec and bundle install work identically across macOS, Linux, and Windows, Python’s virtual environment activation differs by platform (though uv run bypasses activation entirely, smoothing this out).