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.
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
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
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.
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.
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
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.
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.
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.
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 moduleorfrom module import name. Avoidimport *. __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
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.
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.
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.
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
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.
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__.pyto 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.modulesfirst, 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.
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.
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 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
# 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.
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, orrequirements.txt— and a lock file if your tool makes one. - Never commit
.venv/,__pycache__/, or anythingpipdownloaded. - 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/pythondirectly 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.