# Projects & environments

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

Python roadmap · Foundations · 16 min read · https://levelcode.org/roadmaps/python/python-projects

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.

> [!NOTE]
> You'll still see `requirements.txt` files everywhere: a flat list of packages, often with pinned versions, installed with `pip install -r requirements.txt`. It's the older convention and works, but it doesn't separate direct from indirect dependencies or dev tools. `uv export --format requirements-txt` produces one from your lock file when a tool needs it.

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

**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.

## Quiz

1. What problem does a virtual environment solve?

   A. It makes Python run faster
   B. It gives each project its own isolated set of installed packages
   C. It compiles Python to machine code
   D. It encrypts your source code

   **Answer: B.** Without isolation, every project shares one set of packages, so conflicting version requirements break each other.

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

   A. In uv.lock, which you commit
   B. In .venv, which you commit
   C. Nowhere; uv picks the newest each time
   D. In .python-version

   **Answer: A.** The lock file pins exact versions and hashes of every package, so uv sync reproduces the same environment everywhere. .venv is a disposable local cache.

3. Why does the src/ layout help?

   A. It's required by Python
   B. It makes imports shorter
   C. Code can't be imported accidentally from the working folder, so tests run against the installed package
   D. It hides code from linters

   **Answer: C.** Without src/, Python can import your package from the current directory even when it isn't installed correctly, hiding packaging bugs that users would hit.

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

   A. pytest
   B. uv
   C. The Python interpreter
   D. A separate formatter and linter, such as Black and Flake8

   **Answer: D.** Ruff formats and lints. Tests still need pytest, and uv still manages environments and dependencies.
