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.
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
pydantic1.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:
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 environmentActivating 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:
curl -LsSf https://astral.sh/uv/install.sh | sh # macOS and Linux
# Windows (PowerShell): irm https://astral.sh/uv/install.ps1 | iexCreate a project:
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:
uv python pin 3.13 # writes .python-version; uv downloads 3.13 if neededpyproject.toml: the project's description
pyproject.toml is the standard file describing a Python project. After the commands above it looks like this:
[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",
]dependencieslists what the project needs at runtime, with minimum versions. These are deliberately loose.[dependency-groups] devlists tools for development (tests, linters) that a production install doesn't need.[project.scripts]defines a command: after installing,weatherruns themainfunction in theweatherpackage.uv run weatherworks 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.
git clone https://example.com/team/weather.git
cd weather
uv sync # installs exactly what uv.lock saysEveryone, including CI and your production build, gets identical packages, so "works on my machine" means it works everywhere. To upgrade deliberately:
uv lock --upgrade-package httpx # upgrade one package
uv lock --upgrade # upgrade everything within the allowed rangesReview 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:
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:
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 textImport with the package name, from anywhere in the project:
# 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:
uv run ruff format . # format every file
uv run ruff check . # lint
uv run ruff check --fix . # lint and apply safe automatic fixessrc/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:
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"] # pycodestyle, pyflakes, import sorting, bugbear, pyupgradeSet your editor to format on save and run ruff check in CI. Within a week you'll stop thinking about style entirely.
Practice
- Create a project with
uv init quotes --package, pin Python 3.13, and addhttpxas a dependency andpytestandruffas dev dependencies. - Create
src/quotes/data.pywith a list of a few quotes (dictionaries withtextandauthor) and a functionrandom_quote()that returns one. - Make
main()insrc/quotes/__init__.pyprint a random quote, and run it withuv run quotes. - Configure Ruff with the rule selection above, introduce an unused import, and see Ruff report and fix it.
- Delete
.venv, runuv sync, and confirm everything still works.
Show solution
uv init quotes --package
cd quotes
uv python pin 3.13
uv add httpx
uv add --dev pytest ruff# 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)# src/quotes/__init__.py
from quotes.data import random_quote
def main() -> None:
quote = random_quote()
print(f"“{quote['text']}” — {quote['author']}")# appended to pyproject.toml
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]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 quotesuv 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 syncanduv runcover daily work. pyproject.tomldeclares loose dependency ranges;uv.lockpins exact versions. Commit both and review lock changes.- Use the
src/layout with packages split into modules, and run modules withpython -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.What problem does a virtual environment solve?
2.pyproject.toml says httpx>=0.28.1. Where are the exact installed versions recorded?
3.Why does the src/ layout help?
4.Which tool does Ruff replace in a typical project?
Finished reading?
Mark it complete to fill in your roadmap.