placeholderPython Dev Environment and Toolchain: VSCode, UV, Ruff

Python Dev Environment and Toolchain: VSCode, UV, Ruff

Build a modern Python workflow from interpreter, virtual env, and dependency management to editor setup and Ruff, with the trade-offs behind each step.

The most confusing part of setting up a Python environment isn’t any single command. It’s the sheer number of tools: the system Python, the one from python.org, conda, venv, pip, pipx, poetry, pdm, pyenv, ruff, mypy, pytest, VS Code extensions. Each tool solves part of the problem, but when the boundaries blur, a project quickly turns into “it runs, but nobody knows why.”

This post lays out the day-to-day toolchain I recommend, organized around a mental model:

  • Use uv to manage Python versions, virtual environments, and dependencies.
  • Use pyproject.toml for project metadata and tool configuration.
  • Use VS Code + Pylance for completion, navigation, and type hints in the editor.
  • Use Ruff for formatting, import sorting, and static checks.

The goal isn’t to chase the newest tools. It’s a reproducible environment, fewer commands, a clean project root, and as little guessing as possible when you switch machines.

What draws people to uv first is speed, but what matters more is that it consolidates capabilities previously scattered across pip, pip-tools, virtualenv, pipx, pyenv, and various project managers into one command. Speed is the entry point; the unified workflow is the long-term payoff.

If you only write the occasional script, python -m venv plus pip still works. If you maintain a project that needs collaboration, continuous testing, locked dependencies, and consistent formatting, uv + Ruff + pyproject.toml will save you more trouble.

What an Environment Needs to Solve

A Python project has at least four layers of state:

LayerTypical questionRecommended carrier
Python interpreterDoes the project use 3.10, 3.11, or 3.12?.python-version, requires-python, uv python
Virtual envWhere do dependencies install? Is the system polluted?.venv
Dependency specWhich libraries does the project depend on directly?pyproject.toml
Dependency lockWhich exact version does each library resolve to?uv.lock or an exported requirements.txt

The classic problem is these four layers getting mixed together. For instance, pip install requests installs the package into the current environment, but it doesn’t naturally answer “where should this package be recorded,” “how does someone else reproduce this,” or “what if Python versions differ.” uv’s project mode ties these questions together:

uv init democd demouv add requestsuv run python main.py

After running these commands, the project gradually gains:

The point isn’t more files, it’s clear responsibilities:

  • .python-version states which Python this directory prefers.
  • .venv is the project-local virtual environment; don’t commit it to Git.
  • pyproject.toml records project metadata and direct dependencies.
  • uv.lock records the full resolution result; commit it to Git so collaborators’ machines stay consistent.

Installing Uv

The official docs are at uv docs. The standalone installer doesn’t depend on an existing Python, which makes it the best first choice.

curl -LsSf https://astral.sh/uv/install.sh | sh

After installation, reopen the terminal or refresh your shell config as the installer suggests. Then check the version:

uv --version
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

After installation, reopen PowerShell and check the version:

uv --version

If a working Python is already available, you can install with pip:

pip install uv

This suits throwaway machines or CI images. For a long-term personal machine, the standalone installer is better because it doesn’t depend on any particular Python environment.

Managing Python Versions

The most common hidden bug in Python projects: the author runs 3.12 locally while CI or a colleague’s machine runs 3.10. Syntax, standard library, type annotations, and dependency resolution can all diverge.

List available versions:

uv python list

Install a specific version:

uv python install 3.12

Pin the Python version for the current project:

uv python pin 3.12

This writes .python-version. Afterwards, when you run uv run, uv sync, and similar commands in the project directory, uv looks for an interpreter of that version first; if none is found and downloads are allowed, it downloads one automatically.

You can also declare the supported Python range in pyproject.toml:

[project]requires-python = ">=3.12"

.python-version and requires-python are not the same thing:

File / fieldPurpose
.python-versionThe interpreter version preferred in the local working directory; about dev experience
project.requires-pythonThe Python range the project claims to support; about package metadata and resolution

For personal projects, write both. For example, require >=3.12 for the project and pin 3.12 or 3.13 locally.

Creating a Project

For new projects, use uv init directly:

uv init my-appcd my-app

If the current directory is already the project directory:

uv init

Run the sample program:

uv run python main.py

The first time you run a project command, uv creates .venv, resolves dependencies, and generates uv.lock. Compared to manually activating a virtual environment, uv run makes the command more explicit: you are running it “in the project environment,” rather than depending on whether the current shell happens to have the right environment activated.

For example:

uv run python --versionuv run python -c "import sys; print(sys.executable)"

The second command should print the Python path inside the project’s .venv. That’s the most direct way to check the environment is correct.

Virtual Environments: To Activate or Not

uv supports two styles.

The first is to never activate, prefixing every command with uv run:

uv run python main.pyuv run pytestuv run ruff check .

This suits scripts, CI, and documentation, because each command carries its own context instead of relying on shell state.

The second is to activate .venv and then run commands directly:

source .venv/bin/activatepython main.pydeactivate
.\.venv\Scripts\Activate.ps1python main.pydeactivate
overlay use .venv/bin/activate.nupython main.pydeactivate

On Windows the Nushell path is usually:

overlay use .venv/Scripts/activate.nu

My habit: activate in an everyday terminal if you like, but use uv run in docs and automation scripts. That way “the state of my current shell” never becomes part of the project’s build.

Adding, Updating, and Removing Dependencies

Add a runtime dependency:

uv add requests

Add dev dependencies:

uv add --dev pytest ruff

Pin versions:

uv add "ruff==<version>"uv add "fastapi>=0.115"

Remove dependencies:

uv remove requestsuv remove --dev ruff

View the dependency tree:

uv tree

It’s worth distinguishing uv add from uv pip install:

CommandSuited forWritten to project deps?
uv add <pkg>Project dependency managementYes, writes pyproject.toml and updates the lock
uv pip install <pkg>Legacy pip workflows or ad-hoc environment workUsually doesn’t touch project metadata
uv run --with <pkg> <cmd>Running a command with a package temporarilyNot persisted to the project

For example, run a tool temporarily:

uv run --with rich python -c "from rich import print; print('[green]hello[/green]')"

If the package is a long-term project dependency, skip the temporary command and uv add rich instead.

Syncing and Locking Dependencies

In collaborative projects, commit uv.lock. After pulling the project, others run:

uv sync

uv sync brings .venv in line with the project declaration and the lockfile. Per the official CLI docs, it creates the project environment if it doesn’t exist, and by default it removes extra packages that aren’t declared, so it’s better suited to reproducing an environment than “install only what’s missing.”

Common commands:

# Update the lockfile onlyuv lock# Sync the environment from the lockfileuv sync# Run a command after syncinguv run pytest

If you need to provide requirements.txt for environments that don’t use uv, export it:

uv export --format requirements-txt --output-file requirements.txt

The old pip-tools style works too:

uv pip compile pyproject.toml -o requirements.txtuv pip sync requirements.txt

Which one to choose depends on the project boundary:

  • Development inside the project: prefer uv.lock + uv sync.
  • Deployment platforms that only accept requirements.txt: generate it with uv export or uv pip compile.
  • Migrating an old project: keep requirements.txt first, let uv pip take over for install speed, then migrate to pyproject.toml gradually.

VS Code Extensions

Recommended extensions:

  • Python
  • Pylance
  • Ruff
  • Python Debugger
  • Python postfix completion

Each has a distinct job:

ExtensionMain responsibility
PythonInterpreter selection, test running, debugging entry point
PylanceLanguage server, navigation, completion, type checking
RuffFormatting, import sorting, lint diagnostics and autofix
Python DebuggerDebugging Python programs
Python postfix completionPostfix completion for faster editing

After installing, pick the interpreter from the VS Code command palette:

Python: Select Interpreter

Choose the project’s .venv. If you don’t see .venv, run this in the project root first:

uv sync

Format and Fix on Save in VS Code

Putting configuration in a project-level .vscode/settings.json is more reproducible than global settings. A baseline:

{  "editor.formatOnPaste": true,  "editor.formatOnSave": true,  "editor.formatOnType": true,  "[python]": {    "editor.defaultFormatter": "charliermarsh.ruff",    "editor.codeActionsOnSave": {      "source.fixAll.ruff": "always",      "source.organizeImports.ruff": "always"    }  }}

This config does three things:

  • Runs the Ruff formatter on save.
  • Applies Ruff’s auto-fixable lints on save.
  • Organizes imports on save.

If the project already uses Black, Microsoft’s Black Formatter extension works too. But for a new project, letting Ruff handle both formatting and linting means less configuration and more consistent speed.

Pylance Configuration

Pylance handles “understanding code while editing.” Keep its boundary with Ruff clear:

  • Ruff is more of a fast code-quality tool: formatting, imports, common errors, style rules.
  • Pylance is more of language intelligence: type inference, navigation, completion, find references.

You can start from this configuration:

{  "python.languageServer": "Pylance",  "python.analysis.cacheLSPData": true,  "python.analysis.autoFormatStrings": true,  "python.analysis.autoImportCompletions": true,  "python.analysis.completeFunctionParens": true,  "python.analysis.typeCheckingMode": "basic",  "editor.inlayHints.enabled": "offUnlessPressed",  "python.analysis.inlayHints.callArgumentNames": "all",  "python.analysis.inlayHints.functionReturnTypes": true,  "python.analysis.inlayHints.pytestParameters": true,  "python.analysis.inlayHints.variableTypes": true,  "python.terminal.activateEnvInCurrentTerminal": true,  "python.terminal.shellIntegration.enabled": true}

typeCheckingMode has a few common values:

ValueSuited for
offYou only want completion and navigation, no type errors
basicThe recommended starting point for everyday projects
strictStrongly typed projects, or core libraries and long-maintained projects

I don’t recommend turning on strict for every project from day one. Strict type checking is valuable, but it also surfaces a lot of legacy issues. If the project already has plenty of dynamic code, start with basic, keep new modules clean, and raise the bar gradually.

Ruff: Formatting and Static Checks

Ruff is Astral’s Python linter and formatter. It commonly replaces Flake8, isort, some pyupgrade rules, and part of Black’s formatting job.

Install it as a project dev dependency:

uv add --dev ruff

Run checks manually:

uv run ruff check .

Auto-fix what’s fixable:

uv run ruff check . --fix

Format code:

uv run ruff format .

Check formatting without changing files, good for CI:

uv run ruff format . --check

Ruff’s configuration can live in pyproject.toml, ruff.toml, or .ruff.toml. If the project already has pyproject.toml, consolidating there is usually cleaner.

[tool.ruff]line-length = 100target-version = "py312"respect-gitignore = trueindent-width = 4[tool.ruff.format]quote-style = "double"indent-style = "space"docstring-code-format = true[tool.ruff.lint]select = [  "E",    # pycodestyle error  "F",    # pyflakes  "I",    # isort  "UP",   # pyupgrade  "B",    # flake8-bugbear  "SIM"   # flake8-simplify]ignore = []

A few lessons:

  • line-length = 100 is comfortable on modern screens; teams also commonly use 88 or 120. What matters is consistency.
  • target-version should match the Python versions the project supports. Ruff uses it to decide whether newer syntax rewrites are safe.
  • Don’t make select too aggressive at once. Start with E/F/I/UP/B and expand once things stabilize.
  • If you’ve already declared requires-python under [project], Ruff can infer the target version from it; when target-version is set explicitly, the Ruff config wins.

Git Hook: Check Before Push

It’s worth codifying the check commands in the project. The simplest way is a pre-push hook:

#!/bin/shuv run ruff format . --checkuv run ruff check .uv run pytest

Save it as:

.git/hooks/pre-push

Then make it executable:

chmod +x .git/hooks/pre-push

This script deliberately doesn’t auto-fix, because the push stage should block problems, not silently modify the working tree. Auto-fixing belongs in editor-on-save or manual runs:

uv run ruff check . --fixuv run ruff format .

For team collaboration, prefer the pre-commit framework or CI over local .git/hooks alone, because Git hooks are not enabled automatically when a repo is cloned.

A pyproject.toml Example

pyproject.toml is the central configuration file of modern Python projects. It comes from the ecosystem’s evolution after PEP 518, aimed at reducing the sprawl of setup.py, setup.cfg, requirements.txt, and per-tool config files.

A typical project might look like:

[project]name = "vnet"version = "0.1.0"description = "Virtual network playground"readme = "README.md"requires-python = ">=3.12"dependencies = [  "pydantic>=2.10",][dependency-groups]dev = [  "pytest>=8.3",  "pytest-cov>=6.0",  "ruff",]doc = [  "mkdocs-material>=9.5",  "mkdocs-mermaid2-plugin>=1.1",][tool.ruff]line-length = 100target-version = "py312"respect-gitignore = trueindent-width = 4[tool.ruff.format]quote-style = "double"indent-style = "space"docstring-code-format = true[tool.ruff.lint]select = ["E", "F", "I", "UP", "B", "SIM"]ignore = ["E402"][tool.pytest.ini_options]addopts = "-ra"testpaths = ["tests"]python_files = ["test_*.py"]python_classes = ["Test*"]python_functions = ["test_*"]log_cli = truelog_cli_format = "%(asctime)s [%(levelname)s] %(message)s"log_cli_date_format = "%m.%d %H:%M:%S"

A few common pitfalls:

  • dependencies holds runtime dependencies, the libraries your package actually needs to run.
  • Don’t mix dev dependencies like dev into runtime dependencies, or your deployment environment gets heavier.
  • Don’t casually set pythonpath to an absolute path on your personal machine, like /home/name/workspace/project. That makes the config work only on your computer. Prefer a standard package layout, or set it explicitly in the test command.
  • Ruff supports indent-style = "tab", but the Python community overwhelmingly uses spaces. Unless the team explicitly requires tabs, don’t change it.

A More Complete Project Skeleton

If the project is more than a single-file script, use the src layout:

This avoids the problem of “tests pass because the current directory happens to be on sys.path, but the package can’t be imported after installation.”

Example:

# src/vnet/main.pydef add(left: int, right: int) -> int:    return left + right
# tests/test_main.pyfrom vnet.main import adddef test_add() -> None:    assert add(1, 2) == 3

Run:

uv run pytestuv run ruff check .uv run ruff format . --check

If all three commands pass, the project’s basic development loop is in place.

__pycache__ and Temporary Files

Python caches bytecode in __pycache__, pytest writes .pytest_cache, and Ruff writes .ruff_cache. These directories generally shouldn’t be committed.

.gitignore should include at least:

.venv/__pycache__/*.py[cod].pytest_cache/.ruff_cache/.mypy_cache/.coveragehtmlcov/dist/build/

To clean up:

find . -type d -name "__pycache__" -prune -exec rm -r {} +find . -type d -name ".pytest_cache" -prune -exec rm -r {} +find . -type d -name ".ruff_cache" -prune -exec rm -r {} +find . -type f -name "*.pyc" -delete

Windows PowerShell:

Get-ChildItem -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -ForceGet-ChildItem -Recurse -Directory -Filter ".pytest_cache" | Remove-Item -Recurse -ForceGet-ChildItem -Recurse -Directory -Filter ".ruff_cache" | Remove-Item -Recurse -ForceGet-ChildItem -Recurse -File -Filter "*.pyc" | Remove-Item -Force

Cleaning caches isn’t a daily task. You usually only need it when paths get tangled, tests behave strangely, or you want a clean directory before packaging.

dataclass: A Small but Frequent Data Modeling Tool

In Python, a class that only carries data tends to accumulate boilerplate: __init__, __repr__, __eq__. The dataclass decorator generates that boilerplate for you.

from dataclasses import dataclass@dataclassclass User:    name: str    age: intuser = User("alice", 18)print(user)

Output looks like:

User(name='alice', age=18)

If you want objects to be immutable after creation:

from dataclasses import dataclass@dataclass(frozen=True)class Point:    x: int    y: intp = Point(10, 20)# p.x = 30  # raises an error, because frozen=True forbids modifying fields

To hide sensitive fields:

from dataclasses import dataclass, field@dataclassclass Account:    username: str    password: str = field(repr=False)account = Account(username="alice", password="secret")print(account)

The output won’t include the password:

Account(username='alice')

To convert to a dict:

from dataclasses import asdict, dataclass@dataclassclass Point:    x: int    y: intpayload = asdict(Point(1, 2))

dataclass suits lightweight data objects. If you need complex validation, JSON schema, or API request/response models, Pydantic is usually a better fit; for structured data passed between internal functions, dataclass is enough.

Daily Command Checklist

This set of commands covers a personal project from initialization to checks:

# Create a projectuv init my-appcd my-app# Pin the Python versionuv python pin 3.12uv sync# Add dependenciesuv add requestsuv add --dev pytest ruff# Runuv run python main.pyuv run pytest# Check and formatuv run ruff check .uv run ruff check . --fixuv run ruff format .# View dependenciesuv tree# Sync the environmentuv sync

How to Choose

For a one-off script, the minimal viable option is:

uv run --with requests script.py

For a long-lived project:

uv inituv add --dev pytest ruff

Then keep pyproject.toml, uv.lock, .python-version, and .vscode/settings.json well maintained.

For an existing legacy project, don’t rush to replace every tool at once. Migrate in this order:

  1. Use uv pip sync requirements.txt first to replace slow installs.
  2. Then wire Ruff into the editor and CI.
  3. Finally move dependency declarations into pyproject.toml and freeze resolution with uv.lock.

The ideal toolchain isn’t “every tool at its latest version.” It’s every question having exactly one clear answer: where the Python version is set, where dependencies are declared, how the environment is reproduced, and who owns formatting and checks. Get there, and a Python project loses most of its mystery.

References