Chapter 17 — Environments and Dependencies: One Python Room per Project

Series I1 — Tidy and Maintainable Code

NoteIn this chapter, you will:
  • Understand why each project needs its own environment
  • Make and use a venv, install packages with pip, and keep the list in requirements.txt
  • Read and write pyproject.toml, the project’s “identity card”
  • Meet uv (the new-generation project manager), poetry, and Anaconda/conda, including the Anaconda licence note
  • Install the inventory package of the running project in its own environment

All scripts for this chapter are in the kode/ch17/ folder. On the web version, you can also download them all at once (zip).

In Chapter 16, the inventory project was born as a set of classes. From Chapter 18 on, it will use outside libraries (pytest, ruff). Here the classic problem appears. Project A needs an old pandas, project B needs a new one. Install both into one Python and one of them breaks. It is like two farmers sharing one fertiliser store: the moment one changes the contents, the other is disturbed.

The solution is an environment: a separate Python “room” for each project, with its own packages. A dependency is an outside package your project needs.

One computer, many environments: each project carries its own packages.

venv: making a new room

venv is Python’s built-in module for making environments. The terminal command: python -m venv .venv (the name .venv is only a habit). The script below makes one through code and inspects it.

01_make_venv.py
# Chapter 17 - Making a venv: a Python "room" just for one project
import os
import shutil
import subprocess
import sys
import venv
from pathlib import Path

work = Path(__file__).parent / "_tmp"
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
location = work / "sample_venv"

venv.create(location, with_pip=True)          # same as: python -m venv sample_venv

bin_folder = location / ("Scripts" if os.name == "nt" else "bin")
print("Contents of the venv folder:", sorted(p.name for p in location.iterdir()))
print("Important files present:", [
    name for name in ("python", "pip", "activate")
    if any(p.stem == name for p in bin_folder.iterdir())
])

cfg = (location / "pyvenv.cfg").read_text(encoding="utf-8").splitlines()
print([line for line in cfg if line.startswith("include-system-site-packages")])

venv_python = bin_folder / ("python.exe" if os.name == "nt" else "python")
code = "import sys; print(sys.prefix != sys.base_prefix)"
inside = subprocess.run([venv_python, "-c", code], capture_output=True, text=True).stdout.strip()
system_python = Path(sys.base_prefix) / ("python.exe" if os.name == "nt" else "bin/python3")
outside = subprocess.run([system_python, "-c", code], capture_output=True, text=True).stdout.strip()
print("Python inside the venv is separate from the system?", inside)
print("System Python (parent of the venv) is separate?", outside)
Contents of the venv folder: ['Include', 'Lib', 'Scripts', 'pyvenv.cfg']
Important files present: ['python', 'pip', 'activate']
['include-system-site-packages = false']
Python inside the venv is separate from the system? True
System Python (parent of the venv) is separate? False
  • The new folder holds Scripts (named bin on Linux/macOS), Lib (where packages are kept), Include, and pyvenv.cfg. Inside Scripts sit python, pip, and the activate scripts.
  • include-system-site-packages = false means packages in the system Python are not visible. This room is truly clean.
  • The Python inside the venv reports sys.prefix != sys.base_prefix = True: it knows it lives in a venv. The system Python reports False.

To use it in a terminal, activate it first. Activation puts the Scripts folder at the front of the command search path, so the python and pip that run are the venv’s. According to the official Python documentation (read 2026-10-10):

Terminal Activation command
Windows, cmd .venv\Scripts\activate.bat
Windows, PowerShell .venv\Scripts\Activate.ps1
Linux/macOS, bash/zsh source .venv/bin/activate

To leave, type deactivate. Activation is optional: you can also call .venv\Scripts\python.exe directly, which is what the scripts in this chapter do.

WarningWARNING

Do not move or copy a venv folder elsewhere, and do not keep it in git. The Python documentation explains that scripts inside hold absolute paths, so a moved venv may break. The right way: throw it away and rebuild it from the package list (requirements.txt or pyproject.toml). Python 3.13 and later even put a .gitignore inside the venv automatically.

pip and requirements.txt

pip is Python’s built-in package installer; packages are downloaded from PyPI (the Python Package Index, the official package store). The script below proves venv isolation: the package requests is installed in one venv, its list is frozen (pip freeze) into requirements.txt, and that file builds a second venv.

02_package_isolation.py
# Chapter 17 - pip inside a venv: install, freeze, reinstall from requirements.txt
import os
import shutil
import subprocess
import venv
from pathlib import Path

work = Path(__file__).parent / "_tmp"
shutil.rmtree(work, ignore_errors=True)
work.mkdir()


def python_of(location):
    return location / ("Scripts/python.exe" if os.name == "nt" else "bin/python")


def run(location, *arguments):
    return subprocess.run([python_of(location), *arguments], capture_output=True, text=True)


venv_a = work / "venv_a"
venv.create(venv_a, with_pip=True)

result = run(venv_a, "-c", "import requests")
print("Before installing:", result.stderr.strip().splitlines()[-1])

run(venv_a, "-m", "pip", "install", "--quiet", "--disable-pip-version-check", "requests")
result = run(venv_a, "-c", "import requests; print('imported successfully')")
print("After installing:", result.stdout.strip())

frozen = run(venv_a, "-m", "pip", "freeze").stdout
(work / "requirements.txt").write_text(frozen, encoding="utf-8")
print("--- requirements.txt ---")
print(frozen.strip())

venv_b = work / "venv_b"
venv.create(venv_b, with_pip=True)
run(venv_b, "-m", "pip", "install", "--quiet", "--disable-pip-version-check",
    "-r", str(work / "requirements.txt"))
frozen_b = run(venv_b, "-m", "pip", "freeze").stdout
print("venv_b identical to venv_a?", frozen_b == frozen)
Before installing: ModuleNotFoundError: No module named 'requests'
After installing: imported successfully
--- requirements.txt ---
certifi==2026.7.22
charset-normalizer==3.5.2
idna==3.20
requests==2.34.2
urllib3==2.8.0
venv_b identical to venv_a? True

This code was really run once by the author and the output saved, because it needs the internet and contains version numbers that keep changing. So the version numbers above are the state at the time of writing; on your computer they will differ.

Trace the result:

  • Before installing, import requests fails with ModuleNotFoundError. A new room is indeed empty.
  • After pip install requests, the import works.
  • pip freeze records five packages, not one: requests brought four other dependencies (certifi, charset-normalizer, idna, urllib3). Each is pinned to an exact version (==).
  • The second venv, built from requirements.txt, is identical to the first (True). That is the point of this file: a colleague or a server can build the same room.
TipTIP

Install packages with python -m pip install ..., not just pip install .... The first form makes sure the pip used belongs to the Python you are calling. That avoids the “installed into the wrong Python” mistake.

pyproject.toml: the project’s identity card

requirements.txt is only a package list. Modern projects keep everything in one file, pyproject.toml: name, version, required Python, dependencies, and tool settings (pytest, ruff). The format is TOML: sections are written [name], contents key = value. This is the file of the inventory package:

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

[project]
name = "inventory"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []

[project.optional-dependencies]
dev = ["pytest", "ruff"]

[tool.ruff]
line-length = 88
  • [build-system] tells the installer how to “build” the package (here with setuptools).
  • [project] holds the project identity. dependencies are the required packages (empty: inventory only uses the standard library). optional-dependencies holds extra groups, here dev for developer tools.
  • [tool.xxx] is a settings space for tools, such as [tool.ruff] and [tool.pytest.ini_options] (Chapter 18).

Python 3.11 and later has the built-in module tomllib to read it:

03_read_pyproject.py
# Chapter 17 - Reading pyproject.toml with tomllib (built into Python 3.11+)
import tomllib
from pathlib import Path

file = Path(__file__).parent / "project" / "pyproject.toml"
with open(file, "rb") as f:             # tomllib requires binary mode
    data = tomllib.load(f)

project = data["project"]
print("Project name     :", project["name"])
print("Version          :", project["version"])
print("Needs Python     :", project["requires-python"])
print("Dependencies     :", project["dependencies"])
print("Dev dependencies :", project["optional-dependencies"]["dev"])
print("Build tool       :", data["build-system"]["build-backend"])
print("[tool.*] sections:", sorted(data["tool"]))
print("Ruff line length :", data["tool"]["ruff"]["line-length"])
Project name     : inventory
Version          : 0.1.0
Needs Python     : >=3.12
Dependencies     : []
Dev dependencies : ['pytest', 'ruff']
Build tool       : setuptools.build_meta
[tool.*] sections: ['ruff', 'setuptools']
Ruff line length : 88

Remember: TOML is read as an ordinary dict. data["project"]["name"] is a dict inside a dict (Chapter 9).

uv: the new-generation package manager

uv is a Python package and project manager made by Astral (the maker of ruff, which we use in Chapter 19). It does the work of venv + pip + version locking in one command, and is very fast. Based on the official uv documentation (read 2026-10-10) and tries on the author’s computer:

Command Function
uv init makes a new project (pyproject.toml, .python-version, main.py)
uv add package adds a dependency to pyproject.toml, makes .venv and uv.lock
uv run command runs a command in the project environment, making sure it is up to date
uv lock / uv sync updates the lock file / aligns the environment with the lock
uv tree shows the dependency tree
uv venv, uv pip ... compatible replacements for python -m venv and pip
04_uv_new_project.py
# Chapter 17 - uv: a new project from scratch (init, add, run, tree)
import shutil
import subprocess
from pathlib import Path

work = Path(__file__).parent / "_tmp" / "uv_demo"
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)


def uv(*arguments):
    result = subprocess.run(["uv", *arguments], cwd=work, capture_output=True, text=True)
    return result.stdout.strip()


uv("init", "--name", "inventory-demo", "--no-readme", "--vcs", "none", "--python", "3.12")
print("Files after uv init:", sorted(p.name for p in work.iterdir()))

uv("add", "requests")
print("Files after uv add :", sorted(p.name for p in work.iterdir()))

lines = (work / "pyproject.toml").read_text(encoding="utf-8").splitlines()
print("--- pyproject.toml (dependencies) ---")
print("\n".join(lines[lines.index("dependencies = [") : lines.index("]")+1]))

print("--- uv run ---")
print(uv("run", "python", "-c", "import requests; print('requests is ready to use')"))

print("--- uv tree ---")
print(uv("tree"))
Files after uv init: ['.python-version', 'main.py', 'pyproject.toml']
Files after uv add : ['.python-version', '.venv', 'main.py', 'pyproject.toml', 'uv.lock']
--- pyproject.toml (dependencies) ---
dependencies = [
    "requests>=2.34.2",
]
--- uv run ---
requests is ready to use
--- uv tree ---
inventory-demo v0.1.0
└── requests v2.34.2
    ├── certifi v2026.7.22
    ├── charset-normalizer v3.5.2
    ├── idna v3.20
    └── urllib3 v2.8.0

This too was really run and the output saved (it needs the internet and contains version numbers). Notice:

  • uv init makes only three files. .venv and uv.lock are born only when uv add runs. uv.lock is a lock file holding the exact versions of all packages, managed by uv and not to be edited by hand (uv documentation).
  • uv add requests writes "requests>=2.34.2" into pyproject.toml: a lower bound, not a fixed price. The exact version lives in uv.lock.
  • uv tree shows the tree: requests on top, four dependencies beneath it, the same as the pip freeze result earlier.
NoteREMEMBER

Two kinds of list have different jobs. pyproject.toml says “which packages I need” (loose: >=2.34.2). A lock file (uv.lock, a frozen requirements.txt) says “the exact versions installed” so everyone gets the same result.

Running Project I1: installing inventory

Now the inventory package from Chapter 16 is installed in its own environment with uv. The -e option (editable) makes Python read code directly from the project folder, so code changes show up at once without reinstalling.

05_install_project.py
# Chapter 17 - Running project I1: installing the inventory package in its own environment
import os
import shutil
import subprocess
from pathlib import Path

parent = Path(__file__).parent
work = parent / "_tmp" / "install"
shutil.rmtree(work, ignore_errors=True)
work.mkdir(parents=True)
copy = work / "project"
shutil.copytree(parent / "project", copy)

environment = work / "venv_inventory"
subprocess.run(["uv", "venv", str(environment), "--quiet"], check=True)
python = environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")

# -e = "editable": code in the project folder is read directly, no reinstall needed
subprocess.run(["uv", "pip", "install", "--quiet", "--python", str(python), "-e", str(copy)],
               check=True)

code = """
from importlib.metadata import version
from pathlib import Path

import inventory
from inventory.sample import make_sample

print("Installed: inventory", version("inventory"))
print("Code read from the project folder?", Path(inventory.__file__).parent.parent.name == "project")
for unit in make_sample():
    print(unit.summary())
"""
result = subprocess.run([python, "-c", code], capture_output=True, text=True)
print(result.stdout.strip())
Installed: inventory 0.1.0
Code read from the project folder? True
Forest plot A: 3 record(s), 2.0 healthy trees
Garden Coffee: 2 record(s), 555.5 kg
Rice field Lowland: 1 record(s), 1250.0 kg (2500 kg/ha)
Pond A: 1 record(s), 432.4 kg biomass
Pen 1: 3 record(s), 1151.5 kg weight

The storyline: a copy of the project is placed in _tmp/, uv venv makes an empty room, uv pip install -e installs the package (uv downloads the setuptools build tool, which is why it needs the internet, and the output is saved from one run). Then inside that room inventory can be imported from anywhere, and the result matches Chapter 16 exactly: forest plot A, coffee garden, lowland rice field, pond, and pen.

Poetry: a rival to uv

poetry is an older, popular Python project manager; it also uses pyproject.toml and makes a lock file, poetry.lock. From the official poetry documentation (read 2026-10-10): poetry new makes a project folder, poetry add adds a dependency and installs it, poetry install installs all dependencies, and poetry run runs a command in the project environment. Dependencies are written in project.dependencies (the PEP 621 standard); older poetry used tool.poetry.dependencies.

07_poetry.py
# Chapter 17 - The basic poetry flow (an example only; not run when the book is built)
import subprocess

commands = [
    ["poetry", "new", "inventory-poetry"],    # makes a new project folder
    ["poetry", "add", "requests"],            # adds a dependency + locks versions (poetry.lock)
    ["poetry", "install"],                    # installs all project dependencies
    ["poetry", "run", "pytest"],              # runs a command in the project environment
]
for c in commands:
    print("$", " ".join(c))
    subprocess.run(c, check=True)

This code was not run when the book was built because poetry is not installed on the author’s computer (see the result of 06_check_tools.py below), so no output is shown. The commands were matched against the official documentation but not tested directly.

Anaconda and conda

Anaconda is another way to install and manage Python, popular in data science. Three terms are often mixed up:

  • conda: a command-line tool to make environments and install packages. Its documentation says conda provides “package, dependency, and environment management for any language”, so not only Python packages.
  • Anaconda Distribution: an installer bundle that brings conda, Python, Anaconda Navigator (a windowed app to make environments and open tools without typing commands), and 600+ ready-made data science packages, about 9.7 GB.
  • Miniconda: the slim version: conda, Python, 130+ packages, no Navigator, about 900 MB.

(Size figures from the official Anaconda pages, read 2026-10-10.)

How it differs from python.org: the python.org installer gives only Python; the rest you handle with venv and pip. Anaconda gives Python plus an environment manager and many packages at once. Its packages come from conda channels, not from PyPI.

The flow in a terminal (from the official conda documentation, read 2026-10-10):

conda create --name inventory python numpy    # make an environment holding Python and numpy
conda activate inventory                      # enter the environment
conda install --name inventory matplotlib     # install another package
conda info --envs                             # list environments
conda deactivate                              # leave
08_conda.py
# Note: "conda activate inventory" is typed directly in the terminal, not through a script.
# Chapter 17 - The basic conda flow (an example only; not run when the book is built)
import subprocess

commands = [
    ["conda", "create", "--name", "inventory", "python", "numpy"],   # make an environment + contents
    ["conda", "install", "--name", "inventory", "numpy"],            # install a package
    ["conda", "info", "--envs"],                                     # list environments
]
for c in commands:
    print("$", " ".join(c))
    subprocess.run(c, check=True)

This code was not run when the book was built because conda is not installed on the author’s computer, so no output is shown and the whole conda part of this chapter has not been tested directly. (conda activate is deliberately absent from the script: it is typed in the terminal.)

06_check_tools.py
# Chapter 17 - Checking which tools exist on the computer
import shutil

for tool in ["python", "pip", "uv", "git", "poetry", "conda"]:
    path = shutil.which(tool)
    print(f"{tool:7}", "present" if path else "NOT present")
python  present
pip     present
uv      present
git     present
poetry  NOT present
conda   NOT present

This output was taken once on the author’s computer and differs if run on yours. Here uv and git are present, poetry and conda are not.

conda vs pip: pip installs from PyPI, conda from conda channels. The conda documentation advises: if you need both in one environment, install as much as possible with conda first, then pip (“Use pip only after conda”). conda-forge is a community-run channel, separate from the defaults channel of Anaconda, Inc. The conda-forge documentation suggests Miniforge as the easiest way to start.

Jupyter: the official Anaconda pages say Jupyter Notebook can be launched from Navigator. (They do not spell out whether Jupyter is bundled; check after installing.)

WarningWARNING

Anaconda licence. Anaconda is not free for everyone. According to the Anaconda Terms of Service (https://www.anaconda.com/legal/terms/terms-of-service, the stated date is 15 July 2025, read 2026-10-10), use of the platform is free if you are: (1) an individual for personal, non-commercial purposes, (2) acting for or with an eligible academic institution, (3) acting for or with an eligible non-profit and research organisation, or (4) acting for a for-profit organisation with 200 or fewer employees or contractors (including affiliates). A for-profit organisation with more than 200 employees or contractors that does not fall in a free category must subscribe to the Business plan. The pricing page (https://www.anaconda.com/pricing, read 2026-10-10) writes “200+” and names the Free plan for people learning Python. Anaconda reserves the right to ask for proof of eligibility. These terms may change, so read the official page yourself before using Anaconda at work. The author did not check whether these terms apply to the conda-forge channel; the terms document does not mention it.

When to choose which? The author’s recommendation for readers of this book: for general development projects (applications, automation), venv + pip or uv is enough, light, and free of licence questions. Consider Anaconda/conda if you work with scientific libraries that are hard to install with pip, or if your team already uses it. At work, check the licence terms above first.

On Windows, PowerShell may refuse Activate.ps1 because of its script execution policy. The Python documentation suggests Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser. This changes a security setting of your own account, so do it only if you understand the consequences; the alternative is to call .venv\Scripts\python.exe directly without activation.

Exercises

  1. In a terminal, make a venv practice_venv with python -m venv practice_venv, activate it, then type python -c "import sys; print(sys.prefix)". Compare with the result without activation.
  2. Install six (a small package) in that venv with python -m pip install six, then save python -m pip freeze > requirements.txt and look at its contents.
  3. Change 03_read_pyproject.py so it also prints the dev list, one package per line.
  4. Challenge: with uv, make a new project (uv init), add pytest as a development dependency (uv add --dev pytest; check uv add --help), then run uv run pytest --version.

Chapter Summary

  • Environment = a separate Python room per project; dependency = an outside package needed.
  • venv makes environments; activation is optional; do not move them or put them in git, just rebuild.
  • pip installs from PyPI; pip freeze produces requirements.txt with exact versions.
  • pyproject.toml keeps the project identity, dependencies, and tool settings; read with tomllib.
  • uv combines venv, pip, and a lock file (uv.lock) quickly. poetry is its rival (not tested here).
  • Anaconda/conda has its own uses and licence; read the official terms before using it at work. The conda part of this book has not been tested directly.

Next, in Chapter 18: basic testing with pytest, how to write code that checks your own code.