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 Python ecosystem has grown to the point where every task has several competing tools, so picking one when setting up an environment is hard. You often end up installing separate tools for interpreter versions, virtual environments, and dependency management, which makes you wish for an all-in-one solution.

This post lays out a day-to-day toolchain:

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

The first thing that draws people to uv is speed, but what matters more is that it consolidates capabilities once scattered across pip, pip-tools, virtualenv, pipx, pyenv, and various project management tools into a single command.

Installing uv

The official docs live 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
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"

Managing Python Versions

A common hidden problem in Python projects: the author runs 3.12 locally while CI or a teammate’s machine runs 3.10. Syntax, the standard library, type annotations, and dependency resolution can all differ as a result.

# List available versionsuv python list# Install a specific versionuv python install 3.12# Pin the version for the current projectuv python pin 3.12

uv python pin writes to .python-version. From then on, when you run uv run, uv sync, and similar commands in the project directory, uv looks for an interpreter matching that version first; if none is found and downloads are allowed, it fetches one automatically.

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

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

.python-version and requires-python each cover a different layer:

File / fieldPurpose
.python-versionPreferred interpreter for the local working directory; about developer experience
project.requires-pythonPython range the project claims to support; about package metadata and dependency resolution

In personal projects it’s fine to write both. For example, require >=3.12 for the project while pinning 3.12 or 3.13 locally.

Creating a Project

For new projects, start with uv init:

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 with manually activating a virtual environment, uv run carries its own context: you are explicitly running inside the project environment, instead of depending on whether the current shell has 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 ways of working.

The first is to skip activation and prefix every command with uv run:

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

This works well for scripts, CI, and documentation, because each command carries its own context and doesn’t depend on shell state.

The second is to activate .venv and 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: activating in an everyday terminal is fine, but documentation and automation scripts should use uv run, so the state of my current shell never becomes part of the project’s build.

Adding, Updating, and Removing Dependencies

# Add a runtime dependencyuv add requests# Add dev dependenciesuv add --dev pytest ruff# Pin versionsuv add "ruff==0.8.0"uv add "fastapi>=0.115"# Remove dependenciesuv remove requestsuv remove --dev ruff# Show the dependency treeuv tree

Note the difference between uv add and uv pip install:

CommandBest forWritten to project dependencies?
uv add <pkg>Project dependency managementYes, updates pyproject.toml and the lockfile
uv pip install <pkg>Legacy pip workflows or ad-hoc environment opsUsually doesn’t touch project metadata
uv run --with <pkg> <cmd>Running a command with a temporary packageNot persisted to the project

For example, running 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 removes packages that aren’t declared, which makes it better for reproducing environments than “install whatever’s missing.”

Common commands:

# Update the lockfile onlyuv lock# Sync the environment from the lockfileuv sync# Sync, then run a commanduv run pytest

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

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

The old pip-tools style also works:

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

Which one to choose depends on the project’s boundaries:

  • Internal development: prefer uv.lock + uv sync.
  • Deployment platforms that only accept requirements.txt: generate it with uv export or uv pip compile.
  • Migrating legacy projects: keep requirements.txt for now, 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 its own job:

ExtensionMain responsibility
PythonInterpreter selection, test running, debug entry
PylanceLanguage server, navigation, completion, type checks
RuffFormatting, import sorting, lint diagnostics and fixes
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 one, run this in the project root first:

uv sync

Format and Fix on Save in VS Code

Putting the config in a project-level .vscode/settings.json is more reproducible than a global one. 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"    }  }}

The effect: on save, Ruff formats the file, applies auto-fixable lint rules, and organizes imports.

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

Pylance Configuration

Pylance handles “understanding code as you edit.” Keep its boundary with Ruff clear:

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

A good starting point:

{  "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}

Common values for typeCheckingMode:

ValueBest for
offYou only want completion and navigation, no type errors
basicRecommended starting point for everyday projects
strictStrongly typed projects, core libraries, long-term maintenance

Don’t turn on strict for every project right away. Strict type checking is valuable, but it also surfaces a lot of legacy issues. If the codebase has plenty of dynamic code, start with basic, keep new modules clean, and raise the bar gradually.

Ruff: Formatting and Static Checks

Ruff is a Python linter and formatter from Astral. Its lint rules cover the common functionality of Flake8, isort, and pyupgrade, and its formatter is a drop-in replacement for Black.

After adding it as a dev dependency, four commands cover daily use:

uv add --dev ruff# Lint / auto-fixuv run ruff check .uv run ruff check . --fix# Format / check formatting without changing files (good for CI)uv run ruff format .uv run ruff format . --check

Ruff config can live in pyproject.toml, ruff.toml, or .ruff.toml. If the project already has a pyproject.toml, centralizing 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 notes from experience:

  • 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 be too aggressive with select at first. Start with E/F/I/UP/B and expand once things are stable.
  • If you’ve already set requires-python under [project], Ruff can infer the target version from it; when target-version is set explicitly, the Ruff config wins.

A pyproject.toml Example

pyproject.toml is the central configuration file of a modern Python project. It grew out of the ecosystem’s evolution after PEP 518, to reduce 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 = [][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 is for runtime dependencies, the libraries your package actually needs to run.
  • Don’t mix dev dependencies like dev into runtime dependencies, or deployment environments get heavier.
  • Avoid writing pythonpath as an absolute path on your machine, such as /home/name/workspace/project. That config only works on your computer. Prefer a standard package layout, or set the path explicitly in the test command.
  • Ruff supports indent-style = "tab", but spaces are far more common in the Python community. Unless the team explicitly requires tabs, don’t change it.

Daily Command Cheat Sheet

This set of commands covers a personal project from init 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 .# Show dependenciesuv tree# Sync the environmentuv sync

References