﻿---
title: "Python Dev Environment and Toolchain: VSCode, UV, Ruff"
date: 2024-11-06
excerpt: "Build a modern Python workflow from interpreter, virtual env, and dependency management to editor setup and Ruff, with the trade-offs behind each step."
tags: [Python, VSCode, Ruff, UV]
cover: https://assets.vluv.space/cover/Lang/Python/python1.webp
updated: 2026-07-08 21:23:39
lang: en
i18n:
  cn: /python_dev_environment_and_toolchain
  translation: 2
---

<script type="module" src="/js/components/tab.js"></script>

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.

<script type="module" src="/js/components/text-image-section.js"></script>
<text-image-section image="https://assets.vluv.space/Python/pydevlog1/benchmark.webp" alt="uv dependency resolution benchmark" width="420px">

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.

</text-image-section>

## What an Environment Needs to Solve

A Python project has at least four layers of state:

| Layer              | Typical question                                        | Recommended carrier                               |
| ------------------ | ------------------------------------------------------- | ------------------------------------------------- |
| Python interpreter | Does the project use 3.10, 3.11, or 3.12?               | `.python-version`, `requires-python`, `uv python` |
| Virtual env        | Where do dependencies install? Is the system polluted?  | `.venv`                                           |
| Dependency spec    | Which libraries does the project depend on directly?    | `pyproject.toml`                                  |
| Dependency lock    | Which 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:

```shell
uv init demo
cd demo
uv add requests
uv run python main.py
```

After running these commands, the project gradually gains:

<script type="module" src="/js/components/tree.js"></script>
<x-tree root="demo">

- .python-version
- .venv/
- README.md
- main.py
- pyproject.toml
- uv.lock

</x-tree>

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](https://docs.astral.sh/uv/). The standalone installer doesn't depend on an existing Python, which makes it the best first choice.

<x-tabs>

<x-tab title="macOS / Linux" active>

```shell
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:

```shell
uv --version
```

</x-tab>

<x-tab title="Windows PowerShell">

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

After installation, reopen PowerShell and check the version:

```powershell
uv --version
```

</x-tab>

<x-tab title="pip">

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

```shell
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.

</x-tab>

</x-tabs>

## 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:

```shell
uv python list
```

Install a specific version:

```shell
uv python install 3.12
```

Pin the Python version for the current project:

```shell
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`:

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

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

| File / field              | Purpose                                                                                |
| ------------------------- | -------------------------------------------------------------------------------------- |
| `.python-version`         | The interpreter version preferred in the local working directory; about dev experience |
| `project.requires-python` | The 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:

```shell
uv init my-app
cd my-app
```

If the current directory is already the project directory:

```shell
uv init
```

Run the sample program:

```shell
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:

```shell
uv run python --version
uv 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`:

```shell
uv run python main.py
uv run pytest
uv 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:

<x-tabs>

<x-tab title="Bash / Zsh" active>

```shell
source .venv/bin/activate
python main.py
deactivate
```

</x-tab>

<x-tab title="PowerShell">

```powershell
.\.venv\Scripts\Activate.ps1
python main.py
deactivate
```

</x-tab>

<x-tab title="Nushell">

```nu
overlay use .venv/bin/activate.nu
python main.py
deactivate
```

On Windows the Nushell path is usually:

```nu
overlay use .venv/Scripts/activate.nu
```

</x-tab>

</x-tabs>

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:

```shell
uv add requests
```

Add dev dependencies:

```shell
uv add --dev pytest ruff
```

Pin versions:

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

Remove dependencies:

```shell
uv remove requests
uv remove --dev ruff
```

View the dependency tree:

```shell
uv tree
```

It's worth distinguishing `uv add` from `uv pip install`:

| Command                     | Suited for                                       | Written to project deps?                          |
| --------------------------- | ------------------------------------------------ | ------------------------------------------------- |
| `uv add <pkg>`              | Project dependency management                    | Yes, writes `pyproject.toml` and updates the lock |
| `uv pip install <pkg>`      | Legacy pip workflows or ad-hoc environment work  | Usually doesn't touch project metadata            |
| `uv run --with <pkg> <cmd>` | Running a command with a package temporarily     | Not persisted to the project                      |

For example, run a tool temporarily:

```shell
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:

```shell
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:

```shell
# Update the lockfile only
uv lock

# Sync the environment from the lockfile
uv sync

# Run a command after syncing
uv run pytest
```

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

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

The old pip-tools style works too:

```shell
uv pip compile pyproject.toml -o requirements.txt
uv 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:

| Extension                 | Main responsibility                                        |
| ------------------------- | ---------------------------------------------------------- |
| Python                    | Interpreter selection, test running, debugging entry point |
| Pylance                   | Language server, navigation, completion, type checking     |
| Ruff                      | Formatting, import sorting, lint diagnostics and autofix   |
| Python Debugger           | Debugging Python programs                                  |
| Python postfix completion | Postfix completion for faster editing                      |

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

```text
Python: Select Interpreter
```

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

```shell
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:

```json
{
  "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:

```json
{
  "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:

| Value    | Suited for                                                              |
| -------- | ----------------------------------------------------------------------- |
| `off`    | You only want completion and navigation, no type errors                 |
| `basic`  | The recommended starting point for everyday projects                    |
| `strict` | Strongly 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](https://docs.astral.sh/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:

```shell
uv add --dev ruff
```

Run checks manually:

```shell
uv run ruff check .
```

Auto-fix what's fixable:

```shell
uv run ruff check . --fix
```

Format code:

```shell
uv run ruff format .
```

Check formatting without changing files, good for CI:

```shell
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.

```toml
[tool.ruff]
line-length = 100
target-version = "py312"
respect-gitignore = true
indent-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:

```shell
#!/bin/sh

uv run ruff format . --check
uv run ruff check .
uv run pytest
```

Save it as:

```text
.git/hooks/pre-push
```

Then make it executable:

```shell
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:

```shell
uv run ruff check . --fix
uv 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:

```toml
[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 = 100
target-version = "py312"
respect-gitignore = true
indent-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 = true
log_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:

<x-tree root="vnet">

- pyproject.toml
- uv.lock
- README.md
- src/
  - vnet/
    - __init__.py
    - main.py
- tests/
  - test_main.py

</x-tree>

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:

```python
# src/vnet/main.py
def add(left: int, right: int) -> int:
    return left + right
```

```python
# tests/test_main.py
from vnet.main import add


def test_add() -> None:
    assert add(1, 2) == 3
```

Run:

```shell
uv run pytest
uv 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:

```txt
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
dist/
build/
```

To clean up:

```shell
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:

```powershell
Get-ChildItem -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force
Get-ChildItem -Recurse -Directory -Filter ".pytest_cache" | Remove-Item -Recurse -Force
Get-ChildItem -Recurse -Directory -Filter ".ruff_cache" | Remove-Item -Recurse -Force
Get-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.

```python
from dataclasses import dataclass


@dataclass
class User:
    name: str
    age: int


user = User("alice", 18)
print(user)
```

Output looks like:

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

If you want objects to be immutable after creation:

```python
from dataclasses import dataclass


@dataclass(frozen=True)
class Point:
    x: int
    y: int


p = Point(10, 20)
# p.x = 30  # raises an error, because frozen=True forbids modifying fields
```

To hide sensitive fields:

```python
from dataclasses import dataclass, field


@dataclass
class Account:
    username: str
    password: str = field(repr=False)


account = Account(username="alice", password="secret")
print(account)
```

The output won't include the password:

```text
Account(username='alice')
```

To convert to a dict:

```python
from dataclasses import asdict, dataclass


@dataclass
class Point:
    x: int
    y: int


payload = 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:

```shell
# Create a project
uv init my-app
cd my-app

# Pin the Python version
uv python pin 3.12
uv sync

# Add dependencies
uv add requests
uv add --dev pytest ruff

# Run
uv run python main.py
uv run pytest

# Check and format
uv run ruff check .
uv run ruff check . --fix
uv run ruff format .

# View dependencies
uv tree

# Sync the environment
uv sync
```

## How to Choose

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

```shell
uv run --with requests script.py
```

For a long-lived project:

```shell
uv init
uv 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

- [uv official documentation](https://docs.astral.sh/uv/)
- [uv CLI reference](https://docs.astral.sh/uv/reference/cli/)
- [uv project layout](https://docs.astral.sh/uv/concepts/projects/layout/)
- [Ruff documentation](https://docs.astral.sh/ruff/)
- [Ruff configuration](https://docs.astral.sh/ruff/configuration/)
- [Ruff editor settings](https://docs.astral.sh/ruff/editors/settings/)
- [Writing your pyproject.toml](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)
- [dataclasses — Data Classes](https://docs.python.org/3/library/dataclasses.html)
