Emmanuel Akinfulubi

Modules and environments all 3 written

How a program becomes more than one file, how Python finds it, and why every project gets its own environment.

19 Modules, imports and __name__

A module is a .py file. Importing one runs it, once, and gives you a name through which to reach what it defined. Everything else about imports — packages, __init__.py, relative imports — is built on those two facts.

A real module, written and imported

Every panel in this course runs in its own directory. So this one can write a file, import it, and show you what happened.

Writing a module and importing it

ran at build time
from pathlib import Path

Path("geometry.py").write_text('''"""Area calculations."""

PI = 3.14159

def circle(radius):
    return PI * radius * radius

def rectangle(width, height):
    return width * height

print("  (geometry.py is running now, as it is imported)")
''')

print("about to import:")
import geometry
print("imported.")
print()
print("PI         =", geometry.PI)
print("circle(2)  =", round(geometry.circle(2), 4))
print("its file   =", geometry.__file__)
print("its docstring =", geometry.__doc__.strip())

test("the module's names are reachable through it", round(geometry.circle(2), 4), 12.5664)
test("a module has a __name__", geometry.__name__, "geometry")
test("and its docstring is the file's first string", geometry.__doc__.strip(), "Area calculations.")

What it printed, when the build ran it

about to import:
  (geometry.py is running now, as it is imported)
imported.

PI         = 3.14159
circle(2)  = 12.5664
its file   = geometry.py
its docstring = Area calculations.
ok   the module's names are reachable through it
ok   a module has a __name__
ok   and its docstring is the file's first string

Notice that the module's print ran during the import. Importing a module executes it, top to bottom. That is why anything expensive or side-effecting at module level is a problem: it happens on import, whether the importer wanted it or not.

A module is executed once, however many times it is imported

Imports are cached in sys.modules

ran at build time
from pathlib import Path

Path("counter_mod.py").write_text('''print("  counter_mod body running")
loaded_times = 1
''')

print("first import:")
import counter_mod
print("second import:")
import counter_mod
print("third, under a different name:")
import counter_mod as alias

print()
print("same object each time:", counter_mod is alias)

import sys
print("it is cached in sys.modules:", "counter_mod" in sys.modules)

test("the body ran only once", counter_mod.loaded_times, 1)
test("an alias is the same module object", counter_mod is alias, True)
test("the cache is what makes that true", sys.modules["counter_mod"] is counter_mod, True)

What it printed, when the build ran it

first import:
  counter_mod body running
second import:
third, under a different name:

same object each time: True
it is cached in sys.modules: True
ok   the body ran only once
ok   an alias is the same module object
ok   the cache is what makes that true

This is why editing a module mid-session does nothing. In a long-running interpreter or a notebook, re-running import mymodule after changing the file gives you the old version, because the import system finds it already in sys.modules and stops. importlib.reload exists for this, and restarting is usually more reliable.

The four ways to import, and which to prefer

import, from-import, and renaming

ran at build time
from pathlib import Path

Path("shapes.py").write_text(
    "PI = 3.14159\n"
    "def circle(r):\n    return PI * r * r\n"
    "def square(s):\n    return s * s\n"
)

# 1. import the module, reach through it. Clearest about where things came from.
import shapes
print("1.", round(shapes.circle(1), 5))

# 2. import specific names. Shorter at the point of use.
from shapes import circle, PI
print("2.", round(circle(1), 5), "with PI =", PI)

# 3. rename on import, for long names or clashes.
import shapes as sh
print("3.", sh.square(4))

from shapes import circle as circle_area
print("4.", round(circle_area(1), 5))

test("all routes reach the same function", round(shapes.circle(1), 5), round(circle(1), 5))
test("renaming does not change behaviour", sh.square(4), shapes.square(4))

What it printed, when the build ran it

1. 3.14159
2. 3.14159 with PI = 3.14159
3. 16
4. 3.14159
ok   all routes reach the same function
ok   renaming does not change behaviour

There is a fifth form, from module import *, and it is the one to avoid: it pulls in every public name at once, so a reader cannot tell where anything came from and a new name in the module can silently shadow one of yours.

Why star imports are discouraged

ran at build time
from pathlib import Path

Path("noisy.py").write_text(
    "def helper():\n    return 'from noisy'\n"
    "def sorted(x):\n    return 'this shadows the builtin!'\n"
)

# A name defined before the star import.
def helper():
    return "mine"

print("before:", helper(), "and sorted([3,1]) =", sorted([3, 1]))

from noisy import *

print("after: ", helper(), "and sorted([3,1]) =", sorted([3, 1]))
print()
print("Two things were replaced, and nothing at the call site says so.")

test("the star import overwrote the local function", helper(), "from noisy")
test("and shadowed a builtin", sorted([3, 1]), "this shadows the builtin!")

What it printed, when the build ran it

before: mine and sorted([3,1]) = [1, 3]
after:  from noisy and sorted([3,1]) = this shadows the builtin!

Two things were replaced, and nothing at the call site says so.
ok   the star import overwrote the local function
ok   and shadowed a builtin

__name__ == "__main__", demonstrated rather than described

Every module has a __name__. When a file is run it is "__main__"; when it is imported it is the module's own name. That single difference is how one file can be both a usable library and a runnable script.

The same file, run and imported

ran at build time
from pathlib import Path
import subprocess, sys

Path("tool.py").write_text('''def main():
    return "doing the work"

print("  inside tool.py, __name__ is:", __name__)

if __name__ == "__main__":
    print(" ", main())
''')

print("--- running it as a script ---")
completed = subprocess.run([sys.executable, "tool.py"], capture_output=True, text=True)
print(completed.stdout.rstrip())

print()
print("--- importing it instead ---")
import tool
print("  main() was not called; calling it by hand gives:", tool.main())

test("run as a script, __name__ is __main__", "__name__ is: __main__" in completed.stdout, True)
test("and the guarded block ran", "doing the work" in completed.stdout, True)
test("imported, __name__ is the module name", tool.__name__, "tool")
test("and main() is available but was not called", tool.main(), "doing the work")

What it printed, when the build ran it

--- running it as a script ---
  inside tool.py, __name__ is: __main__
  doing the work

--- importing it instead ---
  inside tool.py, __name__ is: tool
  main() was not called; calling it by hand gives: doing the work
ok   run as a script, __name__ is __main__
ok   and the guarded block ran
ok   imported, __name__ is the module name
ok   and main() is available but was not called

What the guard is actually for. Without it, importing your script to reuse one function would run the whole thing. With it, the file has two honest modes: import it and you get definitions, run it and you get behaviour. Put the work in functions and only the call inside the guard — a common mistake is putting logic in the guard, which makes it unreachable to anyone importing the file.

Where Python looks, and the shadowing trap

sys.path, and the order it is searched

ran at build time
import sys
from pathlib import Path

print("the first few entries of sys.path:")
for entry in sys.path[:5]:
    print("  ", repr(entry))

script_dir = str(Path(__file__).resolve().parent)
print()
print("the script's own directory is on the path:", script_dir in sys.path)
print("and it is searched before the standard library")

test("sys.path is a list of directories to search", isinstance(sys.path, list), True)
test("the script's own directory is on it", script_dir in sys.path, True)
test("the standard library is on it too", any("python3." in p for p in sys.path), True)

What it printed, when the build ran it

the first few entries of sys.path:
   '.'
   '.'
   '<python>/lib/python314.zip'
   '<python>/lib/python3.14'
   '<python>/lib/python3.14/lib-dynload'

the script's own directory is on the path: True
and it is searched before the standard library
ok   sys.path is a list of directories to search
ok   the script's own directory is on it
ok   the standard library is on it too

Because your own directory is searched first, a local file named after a standard library module wins — and then the thing you actually wanted is unreachable, often with a baffling error.

A local file shadowing a standard library module

raises AttributeError
from pathlib import Path

# A local file with the same name as a standard library module.
Path("random.py").write_text("SEED = 42\n")

import random
print(random.randint(1, 10))

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 7, in <module>
    print(random.randint(1, 10))
          ^^^^^^^^^^^^^^
AttributeError: module 'random' has no attribute 'randint' (consider renaming 'random.py' since it has the same name as the standard library module named 'random' and prevents importing that standard library module)

This error is much more confusing than it looks. The import succeeded — it just imported your file — so the failure appears later, as a missing attribute on a module you are sure has it. Check random.__file__ when a standard library module seems broken. Common collisions: random.py, string.py, json.py, email.py, test.py, and types.py.

Circular imports

Two modules that import each other can work or fail depending on when each name is needed. The failure is a ImportError complaining about a partially initialised module, and it is one of the few import problems with no quick fix.

Two modules importing each other at the top level

raises ImportError
from pathlib import Path

Path("alpha.py").write_text(
    "from beta import beta_value\n"
    "alpha_value = 1\n"
)
Path("beta.py").write_text(
    "from alpha import alpha_value\n"
    "beta_value = 2\n"
)

import alpha

The traceback it really produced, when the build ran it

Traceback (most recent call last):
  File "example.py", line 12, in <module>
    import alpha
  File "alpha.py", line 1, in <module>
    from beta import beta_value
  File "beta.py", line 1, in <module>
    from alpha import alpha_value
ImportError: cannot import name 'alpha_value' from 'alpha' (consider renaming 'alpha.py' if it has the same name as a library you intended to import)

The message names the mechanism precisely: a partially initialised module. alpha started, imported beta, which tried to import alpha back — and at that moment alpha had not reached its own alpha_value line yet. The fixes are to move the shared thing into a third module, or to import inside the function that needs it rather than at the top.

The same cycle, made to work by importing later

ran at build time
from pathlib import Path

# Same two modules, but importing inside the function that needs the name.
Path("alpha2.py").write_text(
    "alpha_value = 1\n"
    "\n"
    "def combined():\n"
    "    from beta2 import beta_value\n"
    "    return alpha_value + beta_value\n"
)
Path("beta2.py").write_text(
    "beta_value = 2\n"
    "\n"
    "def combined():\n"
    "    from alpha2 import alpha_value\n"
    "    return alpha_value + beta_value\n"
)

import alpha2, beta2
print("alpha2.combined() =", alpha2.combined())
print("beta2.combined()  =", beta2.combined())
print()
print("Both import each other, and neither fails, because the import happens")
print("when the function runs rather than while the module is still loading.")

test("the deferred import resolves", alpha2.combined(), 3)
test("in both directions", beta2.combined(), 3)

What it printed, when the build ran it

alpha2.combined() = 3
beta2.combined()  = 3

Both import each other, and neither fails, because the import happens
when the function runs rather than while the module is still loading.
ok   the deferred import resolves
ok   in both directions

What to take from this lesson

  • A module is a file. Importing it executes it, once, and caches it in sys.modules.
  • Prefer import module or from module import name. Avoid import *.
  • __name__ is "__main__" when run and the module name when imported — put the work in functions and only the call inside the guard.
  • Your own directory is searched first, so never name a file after a standard library module.
  • Circular imports fail on partially initialised modules; import inside the function, or extract the shared part.

20 Packages, and how Python actually finds them

A package is a directory of modules that can be imported as one unit. The mechanics are simple; the part that causes real trouble is running a file that lives inside a package, which fails for a reason that has nothing to do with where the file is.

A real package

A package, its submodules, and what import does not do

ran at build time
from pathlib import Path

Path("shapes").mkdir()
Path("shapes/__init__.py").write_text("")
Path("shapes/circle.py").write_text(
    "PI = 3.14159\n\ndef area(r):\n    return PI * r * r\n"
)
Path("shapes/rect.py").write_text(
    "def area(w, h):\n    return w * h\n"
)

# Importing the package alone does NOT import its submodules.
import shapes
print("package imported:", shapes.__name__)
print("does it have .circle yet?", hasattr(shapes, "circle"))

# The submodule has to be imported explicitly.
import shapes.circle
print("after importing shapes.circle:", hasattr(shapes, "circle"))
print("area of a unit circle:", round(shapes.circle.area(1), 5))

# from-import reaches a submodule's contents directly.
from shapes.rect import area as rect_area
print("rectangle 3x4:", rect_area(3, 4))

test("the package is a module too", shapes.__name__, "shapes")
test("importing the package does not pull in submodules automatically",
     round(shapes.circle.area(1), 5), 3.14159)
test("from-import reaches into a submodule", rect_area(3, 4), 12)

What it printed, when the build ran it

package imported: shapes
does it have .circle yet? False
after importing shapes.circle: True
area of a unit circle: 3.14159
rectangle 3x4: 12
ok   the package is a module too
ok   importing the package does not pull in submodules automatically
ok   from-import reaches into a submodule

__init__.py can be empty, and usually should be nearly so. Its presence marks the directory as a package; its contents run when the package is imported. Since 3.3 a directory without one can still be imported as a "namespace package", but an explicit empty __init__.py is clearer and avoids surprises.

__init__.py as the package's public face

The common real use is to re-export the handful of names you want people to use, so callers write from mypackage import thing instead of knowing your internal file layout.

Re-exporting with relative imports in __init__.py

ran at build time
from pathlib import Path

Path("toolkit").mkdir()
Path("toolkit/_internal.py").write_text(
    "def _helper():\n    return 'private'\n\n"
    "def compute(x):\n    return x * 2\n"
)
Path("toolkit/format.py").write_text(
    "def pretty(x):\n    return f'<{x}>'\n"
)
# The __init__ chooses what the package exposes, using relative imports.
Path("toolkit/__init__.py").write_text(
    "from ._internal import compute\n"
    "from .format import pretty\n"
    "\n"
    '__all__ = ["compute", "pretty"]\n'
)

import toolkit
print("compute(21) =", toolkit.compute(21))
print("pretty(1)   =", toolkit.pretty(1))
print("__all__     =", toolkit.__all__)
print()
print("callers never need to know it lives in _internal.py")

test("the package re-exports its chosen names", toolkit.compute(21), 42)
test("from a second module too", toolkit.pretty(1), "<1>")
test("__all__ documents the public surface", toolkit.__all__, ["compute", "pretty"])

What it printed, when the build ran it

compute(21) = 42
pretty(1)   = <1>
__all__     = ['compute', 'pretty']

callers never need to know it lives in _internal.py
ok   the package re-exports its chosen names
ok   from a second module too
ok   __all__ documents the public surface

Relative imports, and the dot notation

Inside a package, one dot means "this package" and two means "the package above". Relative imports keep a package's internal references working even if the whole package is renamed or moved.

One dot and two dots

ran at build time
from pathlib import Path

Path("app").mkdir()
Path("app/__init__.py").write_text("")
Path("app/config.py").write_text("SETTING = 'from config'\n")

Path("app/models").mkdir()
Path("app/models/__init__.py").write_text("")
Path("app/models/user.py").write_text(
    "from ..config import SETTING       # two dots: up to 'app'\n"
    "from .base import Base             # one dot: alongside, in 'app.models'\n"
    "\n"
    "class User(Base):\n"
    "    source = SETTING\n"
)
Path("app/models/base.py").write_text("class Base:\n    pass\n")

from app.models.user import User
print("User.source     =", User.source)
print("its base class  =", User.__bases__[0].__name__)
print()
print("one dot = same package, two dots = the package above")

test("a one-dot import found the sibling module", User.__bases__[0].__name__, "Base")
test("a two-dot import reached the parent package", User.source, "from config")

What it printed, when the build ran it

User.source     = from config
its base class  = Base

one dot = same package, two dots = the package above
ok   a one-dot import found the sibling module
ok   a two-dot import reached the parent package

The error everyone hits: running a file inside a package

Relative imports depend on the importing module knowing which package it belongs to. A file that is run as a script has no parent package — its __name__ is "__main__", which says nothing about a package — so every relative import in it fails, no matter which directory it sits in.

The panel below runs the same file both ways in one go. The message on the failing route is exact: attempted relative import with no known parent package. The fix is to run it as a module with -m, which imports it properly and therefore gives it a package to be relative to.

python -m package.module is the fix

ran at build time
from pathlib import Path
import subprocess, sys

Path("pkg").mkdir()
Path("pkg/__init__.py").write_text("")
Path("pkg/helper.py").write_text("VALUE = 7\n")
Path("pkg/main.py").write_text(
    "from .helper import VALUE\n"
    "\n"
    "def main():\n"
    "    print('value is', VALUE)\n"
    "\n"
    "if __name__ == '__main__':\n"
    "    main()\n"
)

# Wrong: run the file by path.
by_path = subprocess.run([sys.executable, "pkg/main.py"], capture_output=True, text=True)
print("python pkg/main.py     ->", by_path.stderr.strip().splitlines()[-1])

# Right: run it as a module, with dots and no .py
as_module = subprocess.run([sys.executable, "-m", "pkg.main"], capture_output=True, text=True)
print("python -m pkg.main     ->", as_module.stdout.strip())

test("running by path fails on the relative import",
     "no known parent package" in by_path.stderr, True)
test("running with -m works", as_module.stdout.strip(), "value is 7")
test("and exits cleanly", as_module.returncode, 0)

What it printed, when the build ran it

python pkg/main.py     -> ImportError: attempted relative import with no known parent package
python -m pkg.main     -> value is 7
ok   running by path fails on the relative import
ok   running with -m works
ok   and exits cleanly

The rule to remember. If a file uses relative imports, it must be run with python -m package.module from the directory above the package — not with python package/module.py. This is behind a large share of "it works when I import it but not when I run it" confusion, and no amount of moving the file around fixes it.

What an import actually does, step by step

The import cache, and what clearing it does

ran at build time
import sys
from pathlib import Path

Path("demo_mod.py").write_text("VALUE = 1\n")

print("before the import, in sys.modules?", "demo_mod" in sys.modules)

import demo_mod

print("after the import, in sys.modules? ", "demo_mod" in sys.modules)
print("the cached object is the module:   ", sys.modules["demo_mod"] is demo_mod)

# Removing it from the cache makes the next import re-execute the file.
Path("demo_mod.py").write_text("VALUE = 2\n")

import demo_mod
print()
print("re-importing after an edit gives:", demo_mod.VALUE, "<- still the cached version")

del sys.modules["demo_mod"]
import demo_mod
print("after clearing the cache:        ", demo_mod.VALUE, "<- re-read from disk")

test("the module is cached on first import", isinstance(sys.modules.get("demo_mod"), type(sys)), True)
test("clearing the cache forces a re-read", demo_mod.VALUE, 2)

What it printed, when the build ran it

before the import, in sys.modules? False
after the import, in sys.modules?  True
the cached object is the module:    True

re-importing after an edit gives: 1 <- still the cached version
after clearing the cache:         2 <- re-read from disk
ok   the module is cached on first import
ok   clearing the cache forces a re-read

So an import is: check sys.modules and stop if it is there; otherwise search sys.path for a matching module or package; execute it; store the result in sys.modules; bind the name locally.

Laying out a project

The shape below is the current convention, and the one lesson 49 packages up. The key idea is that your importable code lives in one directory named after the project, and everything else — tests, configuration, documentation — sits beside it rather than inside it.

A conventional project layout

ran at build time
from pathlib import Path

for path in [
    "myproject/src/myproject/__init__.py",
    "myproject/src/myproject/core.py",
    "myproject/src/myproject/cli.py",
    "myproject/tests/test_core.py",
    "myproject/pyproject.toml",
    "myproject/README.md",
]:
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text("")

def tree(root, prefix=""):
    lines = []
    for item in sorted(Path(root).iterdir()):
        lines.append(prefix + item.name + ("/" if item.is_dir() else ""))
        if item.is_dir():
            lines.extend(tree(item, prefix + "    "))
    return lines

print("myproject/")
for line in tree("myproject", "    "):
    print(line)

test("the importable package sits under src/", Path("myproject/src/myproject/__init__.py").exists(), True)
test("tests live outside it", Path("myproject/tests/test_core.py").exists(), True)
test("and the project is described by pyproject.toml", Path("myproject/pyproject.toml").exists(), True)

What it printed, when the build ran it

myproject/
    README.md
    pyproject.toml
    src/
        myproject/
            __init__.py
            cli.py
            core.py
    tests/
        test_core.py
ok   the importable package sits under src/
ok   tests live outside it
ok   and the project is described by pyproject.toml

What to take from this lesson

  • A package is a directory with __init__.py. Importing the package does not import its submodules.
  • Use __init__.py to re-export a small public surface, so callers do not depend on your file layout.
  • One dot is the current package, two is the one above.
  • A file using relative imports must be run with python -m package.module, because a script has no parent package.
  • An import checks sys.modules first, which is why edits do not take effect in a running interpreter.

21 Virtual environments and pip, done properly

The moment a project needs a package that does not ship with Python, you need somewhere for it to live. Installing into the system Python works exactly until two projects want different versions of the same thing, and then it fails in a way that is genuinely unpleasant to untangle. A virtual environment is the fix, and it takes one command.

The problem, stated precisely

One Python installation has one set of installed packages. Project A needs a library at version 1.0; project B needs the same library at 2.0. Both cannot be installed at once. Worse, upgrading for B silently breaks A, and nothing tells you until A next runs.

A virtual environment is a directory containing its own site-packages and its own interpreter link. Activating one changes which Python — and therefore which packages — your shell finds. Projects stop being able to interfere with each other because they no longer share anything.

Creating one, for real

venv is part of the standard library, so this needs no network and is run here properly rather than described.

A real virtual environment, created by the standard library

ran at build time
import subprocess, sys, venv
from pathlib import Path

# The library way; the command line equivalent is: python3 -m venv .venv
venv.create(".venv", with_pip=False)

print("created .venv, and it contains:")
for item in sorted(Path(".venv").iterdir()):
    print("   ", item.name + ("/" if item.is_dir() else ""))

# The pieces that matter.
cfg = Path(".venv/pyvenv.cfg").read_text()
print()
print("pyvenv.cfg records which interpreter it was built from:")
for line in cfg.strip().splitlines():
    if line.startswith(("home", "version", "include-system-site-packages")):
        print("   ", line)

test("the environment was created", Path(".venv/pyvenv.cfg").exists(), True)
test("it has its own bin (or Scripts) directory",
     Path(".venv/bin").exists() or Path(".venv/Scripts").exists(), True)
test("and it does not share the system site-packages by default",
     "include-system-site-packages = false" in cfg, True)

What it printed, when the build ran it

created .venv, and it contains:
    bin/
    include/
    lib/
    pyvenv.cfg

pyvenv.cfg records which interpreter it was built from:
    home = <python>/bin
    include-system-site-packages = false
    version = 3.14.7
ok   the environment was created
ok   it has its own bin (or Scripts) directory
ok   and it does not share the system site-packages by default

Its interpreter is a different interpreter

This is the part worth seeing rather than accepting: the Python inside the environment reports a different sys.prefix, which is exactly how it finds a different set of packages.

sys.prefix is how an environment is detected

ran at build time
import subprocess, sys, venv
from pathlib import Path

venv.create(".venv", with_pip=False)

exe = Path(".venv/bin/python") if Path(".venv/bin/python").exists() else Path(".venv/Scripts/python.exe")

probe = (
    "import sys;"
    "print('prefix     :', sys.prefix);"
    "print('base_prefix:', sys.base_prefix);"
    "print('in a venv  :', sys.prefix != sys.base_prefix)"
)

print("--- the interpreter running this course ---")
print("in a venv  :", sys.prefix != sys.base_prefix)

print()
print("--- the interpreter inside .venv ---")
result = subprocess.run([str(exe), "-c", probe], capture_output=True, text=True)
print(result.stdout.strip())

test("the venv interpreter exists and runs", result.returncode, 0)
test("and it knows it is inside a virtual environment",
     "in a venv  : True" in result.stdout, True)

What it printed, when the build ran it

--- the interpreter running this course ---
in a venv  : False

--- the interpreter inside .venv ---
prefix     : .venv
base_prefix: <python>
in a venv  : True
ok   the venv interpreter exists and runs
ok   and it knows it is inside a virtual environment

That comparison is the whole mechanism. sys.prefix is where this interpreter looks for packages; sys.base_prefix is the installation it was created from. When they differ, you are in a virtual environment. It is also how you check from inside a script that you are not about to install into the system Python by accident.

Activating it, which is the part that is just shell

Activation only edits your PATH so that python and pip resolve inside the environment. There is nothing clever about it, and you can skip it entirely by calling the environment's interpreter by path.

Create, activate, deactivate

shown, not run
# Create it. Do this once per project, in the project directory.
python3 -m venv .venv

# Activate it: macOS and Linux
source .venv/bin/activate

# Activate it: Windows PowerShell
.venv\Scripts\Activate.ps1

# Your prompt now shows (.venv). Check you got what you expected:
which python          # .../myproject/.venv/bin/python
python -V

# Leave it again
deactivate

# Or skip activation entirely, which is what scripts and CI should do:
.venv/bin/python -m pip install requests
.venv/bin/python myscript.py

Shell commands, not Python, and activation changes the environment of an interactive shell — which a build step cannot meaningfully demonstrate. The environment creation itself is run for real in the panels above and below; only these shell interactions are shown rather than executed.

Add .venv/ to .gitignore. It contains platform-specific binaries and is rebuilt from your requirements in one command. Committing it bloats the repository and breaks for anyone on a different operating system. What you commit is the list of dependencies, never the installed copies.

Installing things, and the one flag that matters

pip, and pinning versions

shown, not run
# Inside an activated environment:
pip install requests

# A specific version, which is what you want in anything shared:
pip install "requests==2.32.3"

# A safe range: any 2.32.x, but not 2.33
pip install "requests~=2.32.3"

# What is installed right now
pip list

# Write the exact versions to a file
pip freeze > requirements.txt

# Recreate the same set somewhere else
pip install -r requirements.txt

# Safer than a bare 'pip' if you have several Pythons: use the one you mean
python -m pip install requests

These need the network and a package index, and this course installs nothing — the audit that checks these pages actively fails the build if an executed panel runs pip install. Shown as the real commands, not executed here.

Prefer python -m pip to pip. A bare pip is whichever one the shell finds first, which on a machine with several Pythons is frequently not the one you are about to run your code with — and that is the single most common cause of "I installed it and it still says ModuleNotFoundError". python -m pip installs into the interpreter you just named, with no ambiguity.

The error this lesson exists to explain

From the traceback catalogue: ModuleNotFoundError. Once environments are in the picture, the question is never only "is it installed" but "is it installed where this interpreter looks". The panel below shows how to answer that from inside Python.

Answering "installed where?" from inside Python

ran at build time
import sys, site
from pathlib import Path

print("this interpreter's prefix:", "<inside a venv>" if sys.prefix != sys.base_prefix else "<the base installation>")
print()
print("it looks for installed packages in:")
for path in site.getsitepackages()[:2]:
    print("   ", Path(path).name, "(under the prefix above)")

print()
print("so the diagnostic sequence for ModuleNotFoundError is:")
for i, step in enumerate([
    "which interpreter am I running?  (sys.executable)",
    "is it the venv's one?            (sys.prefix != sys.base_prefix)",
    "where does it look?              (site.getsitepackages())",
    "is the package there?            (pip list, with the SAME interpreter)",
], start=1):
    print(f"   {i}. {step}")

test("site.getsitepackages() reports real directories", isinstance(site.getsitepackages(), list), True)
test("and the prefix check is a simple comparison",
     isinstance(sys.prefix != sys.base_prefix, bool), True)

What it printed, when the build ran it

this interpreter's prefix: <the base installation>

it looks for installed packages in:
    site-packages (under the prefix above)

so the diagnostic sequence for ModuleNotFoundError is:
   1. which interpreter am I running?  (sys.executable)
   2. is it the venv's one?            (sys.prefix != sys.base_prefix)
   3. where does it look?              (site.getsitepackages())
   4. is the package there?            (pip list, with the SAME interpreter)
ok   site.getsitepackages() reports real directories
ok   and the prefix check is a simple comparison

What to commit, and the tools that came after pip

  • Commit your dependency list — pyproject.toml, or requirements.txt — and a lock file if your tool makes one.
  • Never commit .venv/, __pycache__/, or anything pip downloaded.
  • One environment per project. They are disposable; delete and recreate rather than repairing.
  • Pin versions in anything shared. An unpinned dependency means your project can break without you changing a line.

venv and pip ship with Python and are always available, which is why this lesson teaches them. You will also meet uv, poetry and pipenv, which wrap the same ideas with faster resolution and better lock files. They are worth using, and all of them make more sense once you know what they are automating.

What to take from this lesson

  • One environment per project: python3 -m venv .venv.
  • An environment is detected by sys.prefix != sys.base_prefix, and that is also how you check you are not about to install into the system Python.
  • Activation only changes PATH; calling .venv/bin/python directly is equivalent and better in scripts.
  • Use python -m pip, pin versions in anything shared, and never commit .venv/.
  • Most ModuleNotFoundErrors are an environment mismatch, not a missing package.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.