Step 02 of 13

Projects & environments

Set up real Python projects: virtual environments, uv, pyproject.toml, dependencies and lock files, packages, and linting with Ruff.

16 min 9 sections

A single .py file is fine for learning. Real projects depend on other people's packages, need a specific Python version, must install identically on your laptop, a colleague's machine and a server, and are split into packages of modules. Python's history here is messy, with many overlapping tools, but the modern answer is short. This lesson explains virtual environments, sets up a project with uv, covers pyproject.toml, dependencies and lock files, package layout, and linting and formatting with Ruff.

The problem: one Python, many projects

Install a package with pip install requests and it goes into the Python installation on your machine, shared by every project. That breaks down quickly:

  • Project A needs pydantic 1.x, project B needs 2.x. Only one can be installed.
  • You upgrade a package for one project and silently break another.
  • A colleague clones your project and has no idea which packages, or versions, it needs.
  • On Linux and macOS, the system Python belongs to the operating system; installing into it can break OS tools, and modern systems refuse to let you.

A virtual environment solves the first three. It's a folder (conventionally .venv) containing its own Python interpreter link and its own installed packages, isolated per project. The fourth is solved by never installing into the system Python at all.

Virtual environments by hand

Python ships with the venv module, so this works everywhere:

bash
mkdir weather && cd weather
python3 -m venv .venv            # create the environment
source .venv/bin/activate        # activate it (Windows: .venv\Scripts\activate)
python -m pip install httpx      # installs into .venv only
python -c "import httpx; print(httpx.__version__)"
deactivate                       # leave the environment

Activating changes your shell's PATH so python and pip refer to the environment's copies; your prompt usually shows (.venv). Use python -m pip rather than bare pip, which guarantees you're installing into the Python you think you are.

This works, but you still need to record dependencies, pin versions, and pick a Python version yourself. That's what a project manager does.

uv: one tool for the whole job

uv is a fast, all-in-one Python project manager: it installs Python versions, creates virtual environments, resolves and locks dependencies, and runs your code and tools. It replaces pip, venv, pip-tools, pyenv and pipx for most work. Install it once:

bash
curl -LsSf https://astral.sh/uv/install.sh | sh       # macOS and Linux
# Windows (PowerShell): irm https://astral.sh/uv/install.ps1 | iex

Create a project:

bash
uv init weather --package
cd weather
uv add httpx               # add a dependency
uv add --dev pytest ruff   # development-only tools
uv run python -c "import httpx; print('ok')"

uv add updates pyproject.toml, resolves compatible versions of everything, writes them to uv.lock, and syncs a .venv in the project folder. You never activate anything: uv run <command> makes sure the environment is up to date, then runs the command inside it.

To pin a Python version for the project:

bash
uv python pin 3.13        # writes .python-version; uv downloads 3.13 if needed

pyproject.toml: the project's description

pyproject.toml is the standard file describing a Python project. After the commands above it looks like this:

toml
[project]
name = "weather"
version = "0.1.0"
description = "Fetch and summarise weather forecasts"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "httpx>=0.28.1",
]

[project.scripts]
weather = "weather:main"

[build-system]
requires = ["uv_build>=0.8,<0.9"]
build-backend = "uv_build"

[dependency-groups]
dev = [
    "pytest>=8.4.0",
    "ruff>=0.12.0",
]
  • dependencies lists what the project needs at runtime, with minimum versions. These are deliberately loose.
  • [dependency-groups] dev lists tools for development (tests, linters) that a production install doesn't need.
  • [project.scripts] defines a command: after installing, weather runs the main function in the weather package. uv run weather works straight away.
  • [build-system] says how to build the package into an installable file; uv chose its own backend. The exact versions in your file will match the uv you have.

Lock files: the same versions everywhere

The loose ranges in pyproject.toml say what could work. uv.lock records what does: the exact version and hash of every package, including dependencies of dependencies. Commit both files.

bash
git clone https://example.com/team/weather.git
cd weather
uv sync          # installs exactly what uv.lock says

Everyone, including CI and your production build, gets identical packages, so "works on my machine" means it works everywhere. To upgrade deliberately:

bash
uv lock --upgrade-package httpx    # upgrade one package
uv lock --upgrade                  # upgrade everything within the allowed ranges

Review the lock file diff like code: it's exactly what changed in your software supply chain.

Packages and project layout

uv init --package created this layout:

text
weather/
  pyproject.toml
  uv.lock
  .python-version
  README.md
  src/
    weather/
      __init__.py
  tests/            (add this yourself)

A package is a folder of modules with an __init__.py file. Put code in src/weather/, split into modules by responsibility:

text
src/weather/
  __init__.py      # def main(): the command-line entry point
  client.py        # talks to the weather API
  summary.py       # turns raw forecasts into readable text

Import with the package name, from anywhere in the project:

python
# src/weather/__init__.py
from weather.client import fetch_forecast
from weather.summary import summarise


def main() -> None:
    forecast = fetch_forecast(latitude=52.52, longitude=13.41)
    print(summarise(forecast))

Why the extra src/ folder? It stops Python from accidentally importing your code straight from the working folder. Your tests then run against the installed package, exactly as users would, which catches packaging mistakes such as a missing file. uv installs your own package into the environment in editable mode, so code changes take effect immediately.

To run a module inside a package directly, use -m with its dotted name: uv run python -m weather.client. Running python src/weather/client.py breaks imports that use the package name.

Linting and formatting with Ruff

A formatter rewrites code into one consistent style, so nobody argues about spacing in code review. A linter finds likely bugs and bad patterns: unused imports, undefined names, mutable default arguments. Ruff does both, very quickly:

bash
uv run ruff format .          # format every file
uv run ruff check .           # lint
uv run ruff check --fix .     # lint and apply safe automatic fixes
text
src/weather/summary.py:3:8: F401 [*] `json` imported but unused
src/weather/client.py:14:5: E722 Do not use bare `except`
Found 2 errors.
[*] 1 fixable with the `--fix` option.

Configure it in pyproject.toml:

toml
[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]   # pycodestyle, pyflakes, import sorting, bugbear, pyupgrade

Set your editor to format on save and run ruff check in CI. Within a week you'll stop thinking about style entirely.

Practice

  1. Create a project with uv init quotes --package, pin Python 3.13, and add httpx as a dependency and pytest and ruff as dev dependencies.
  2. Create src/quotes/data.py with a list of a few quotes (dictionaries with text and author) and a function random_quote() that returns one.
  3. Make main() in src/quotes/__init__.py print a random quote, and run it with uv run quotes.
  4. Configure Ruff with the rule selection above, introduce an unused import, and see Ruff report and fix it.
  5. Delete .venv, run uv sync, and confirm everything still works.
Show solution
bash
uv init quotes --package
cd quotes
uv python pin 3.13
uv add httpx
uv add --dev pytest ruff
python
# src/quotes/data.py
import random

QUOTES = [
    {"text": "Simple is better than complex.", "author": "Tim Peters"},
    {"text": "Programs must be written for people to read.", "author": "Harold Abelson"},
    {"text": "Make it work, make it right, make it fast.", "author": "Kent Beck"},
]


def random_quote() -> dict[str, str]:
    return random.choice(QUOTES)
python
# src/quotes/__init__.py
from quotes.data import random_quote


def main() -> None:
    quote = random_quote()
    print(f"“{quote['text']}” — {quote['author']}")
toml
# appended to pyproject.toml
[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
bash
uv run quotes
# “Make it work, make it right, make it fast.” — Kent Beck

echo "import os" >> src/quotes/data.py
uv run ruff check .          # F401 `os` imported but unused
uv run ruff check --fix .    # removes it

rm -rf .venv
uv sync                      # recreates the environment from uv.lock
uv run quotes

uv run quotes works because [project.scripts] maps the quotes command to quotes:main, and uv installed the project into its environment. Deleting .venv is always safe: it's a disposable cache rebuilt from pyproject.toml and uv.lock, which is also why .venv belongs in .gitignore (uv adds it for you).

Key takeaways

  • Give every project its own virtual environment; never install packages into the system Python.
  • uv manages Python versions, environments, dependencies and commands; uv add, uv sync and uv run cover daily work.
  • pyproject.toml declares loose dependency ranges; uv.lock pins exact versions. Commit both and review lock changes.
  • Use the src/ layout with packages split into modules, and run modules with python -m package.module.
  • Format and lint with Ruff on save and in CI so style never reaches code review.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.What problem does a virtual environment solve?

  2. 2.pyproject.toml says httpx>=0.28.1. Where are the exact installed versions recorded?

  3. 3.Why does the src/ layout help?

  4. 4.Which tool does Ruff replace in a typical project?

Finished reading?

Mark it complete to fill in your roadmap.