pip says it’s installed, but Python can’t find it
See the problem
Section titled “See the problem”Your report script needs the requests package. You install it, and pip says it worked:
$ pip install requestsSuccessfully installed requests-2.32.3$ python report.pyModuleNotFoundError: No module named 'requests'A friend’s laptop is worse. Their game has a file called random.py, and now import random gives AttributeError: module 'random' has no attribute 'randint'.
Both times, Python looked for a module somewhere other than where you think. Where does import look?
Define the task
Section titled “Define the task”- Goal: set up a project so that
import requestsfinds the package you installed, every time, on any computer. - Rules: another project on the same computer may need a different version of
requests. A teammate must be able to make the same setup with one command. - The hard part: a computer can have many Pythons, and each has its own place for packages.
Example: after the setup, python report.py runs, and python -m pip list shows requests in the project’s own folder.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
pip and python are two separate commands. On PATH, the first pip may belong to another Python.
Hint 2 of 3
python -m pip runs the pip that belongs to that exact python.
Hint 3 of 3
A virtual environment is a folder with its own python and its own packages, one for each project.
Understand the idea
Section titled “Understand the idea”Modules and packages. Any .py file is a module. import prices runs prices.py once and gives you its names: prices.total(). A folder with an __init__.py file is a package: from shop.prices import total.
Where import looks. Python keeps a list of folders in sys.path and tries them in order:
| Order | Folder | Holds |
|---|---|---|
| 1 | The folder of the script you ran | Your own modules, like prices.py, or a stray random.py |
| 2 | The standard library | random, json, pathlib… |
| 3 | site-packages | Packages pip installed for this Python |
The first match wins. In each folder, a package folder comes before a .py file of the same name. No match anywhere gives ModuleNotFoundError. That’s why your random.py hides the real random: your folder comes first.
Each Python has its own site-packages. A computer often has two or three Pythons. A plain pip may belong to a different one than python. So pip install can put the package where your python never looks.
A virtual environment (a venv) is a folder, usually .venv, with its own python and its own site-packages. Make one per project. Activate it, and python and pip in that terminal mean the project’s own.
Climb the ladder
Section titled “Climb the ladder”All three install one package. What changes is whether it lands in the Python you run, and whether projects stay apart.
pip install
The idea: Run pip install and hope pip and python match.
On a computer with one Python, pip and python belong together, and it works.
# Install with whichever pip comes first on PATHpip install requestspython report.py# ModuleNotFoundError: No module named 'requests'- Works for
One Python on the computer- Weak spot
The wrong Python- Why
- pip is whichever comes first on PATH.
Where it breaks: with two Pythons, pip may be the other one’s. The package lands in a site-packages your python never searches.
python -m pip
The idea: Run pip through the same python that runs the script.
The waste in Okay: it trusts pip and python to match, when one command can make sure.
python -m pip means “run the pip module of this python”. The package goes where this python looks.
Highlighted lines are new in this rung.
# Install with the pip that belongs to this pythonpython -m pip install requestspython report.py# Works, but every project on this computer now shares this requests- Works for
One project- Weak spot
Projects share packages- Why
- Every project uses this Python’s one site-packages.
What changed from the previous rung
# Install with whichever pip comes first on PATHpip install requests# Install with the pip that belongs to this pythonpython -m pip install requestspython report.py# ModuleNotFoundError: No module named 'requests'# Works, but every project on this computer now shares this requestsWhere it breaks: every project on the computer shares one site-packages. Project A needs requests 2.32 and project B needs 2.31: installing one breaks the other. And nothing records what the project needs.
A venv per project
The idea: Make .venv in the project, activate it, and install from requirements.txt.
The waste in Better: all projects share one set of packages, and none of them says what it needs.
requirements.txt lists each package with its version, like requests==2.32.3. A teammate runs the same four lines and gets the same setup. Add .venv/ to .gitignore; share requirements.txt instead.
Highlighted lines are new in this rung.
# One virtual environment per project, in a .venv folderpython -m venv .venvsource .venv/bin/activate # Windows: .venv\Scripts\activatepython -m pip install -r requirements.txtpython report.py- Works for
Any number of projects- Weak spot
Remember to activate- Why
- Each project has its own python and site-packages.
What changed from the previous rung
# Install with the pip that belongs to this pythonpython -m pip install requests# One virtual environment per project, in a .venv folderpython -m venv .venvsource .venv/bin/activate # Windows: .venv\Scripts\activatepython -m pip install -r requirements.txtpython report.py# Works, but every project on this computer now shares this requestsWhen Best isn’t best: tools like uv or Poetry make the venv, lock exact versions and install in one step. Teams often use them for bigger projects. A one-file script that uses only the standard library needs no venv at all.
Write it yourself
Section titled “Write it yourself”Now write the search that import does. Given a module name, the folders in sys.path order, and the files that exist, return the file import would load. Return None when it would raise ModuleNotFoundError.
In each folder, check folder/name/__init__.py first, then folder/name.py. Press Run tests to check your code.
Where import looks
Ctrl + Enter runs- Not run yet:
find_module('random', ['/proj', '/usr/lib/python3.12'], ['/proj/game.py', '/usr/lib/python3.12/random.py'])→'/usr/lib/python3.12/random.py' - Not run yet:
find_module('random', ['/proj', '/usr/lib/python3.12'], ['/proj/random.py', '/usr/lib/python3.12/random.py'])→'/proj/random.py' - Not run yet:
find_module('requests', ['/proj', '/proj/.venv/site-packages'], ['/proj/.venv/site-packages/requests/__init__.py'])→'/proj/.venv/site-packages/requests/__init__.py' - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Lands in the Python you run | Projects stay apart | Teammates can copy it | Use it when |
|---|---|---|---|---|
Okay: pip install | Only with one Python | No | No | Never, if you can type python -m |
Better: python -m pip | Yes | No | No | A quick tool you install once for yourself |
Best: a venv and requirements.txt | Yes | Yes | Yes | Every project |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- Forgetting to activate: a new terminal starts outside the venv. Check with
python -c "import sys; print(sys.prefix)", or look for(.venv)before the prompt. - Committing
.venv: it holds thousands of files built for your computer. Sharerequirements.txtinstead. pip freeze > requirements.txtwrites every installed package with its exact version. That includes packages your packages need.- Circular imports:
a.pyimportsb.py, which importsa.py. One of them sees a half-loaded module. Move the shared code into a third module. - Importing runs the file: top-level code runs on the first import. Guard scripts with
if __name__ == "__main__":.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- How many Pythons are on the machine, and which one runs the app?
- Does the team use a lock file, or a tool like uv or Poetry?
A 60-second walkthrough
“
importsearchessys.pathin order: the script’s folder, the standard library, then site-packages, and the first match wins. Every Python has its own site-packages, so a barepipcan install into a different interpreter. I always usepython -m pip, inside a virtual environment per project. That keeps versions apart between projects, andrequirements.txtor a lock file lets anyone rebuild the same setup.”
Likely follow-ups
- What is
__init__.pyfor? It marks a folder as a regular package and runs when the package is imported. - What does a venv contain? A link to a Python, its own
site-packages, and scripts to activate it. Nothing else is copied. import xorfrom x import y? Both load the whole module once.fromonly changes which name you get in your file.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.
1. From path to module name (easy)
Section titled “1. From path to module name (easy)”Write module_name(path). Turn a file path inside a project into the name you would import. "app/utils/text.py" gives "app.utils.text". A package’s __init__.py is imported by the folder’s name, so "app/utils/__init__.py" gives "app.utils". Windows paths use \, and must work too.
Hint 1 of 3
Drop the ".py" at the end with removesuffix(".py").
Hint 2 of 3
Split the path into parts, and join them with dots.
Hint 3 of 3
Turn every "\" into "/" first, so one split works for both.
From path to module name
Ctrl + Enter runs- Not run yet:
module_name('app/utils/text.py')→'app.utils.text' - Not run yet:
module_name('app/utils/__init__.py')→'app.utils' - Not run yet:
module_name('main.py')→'main' - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Two replaces
The idea: Turn / into . and remove .py.
"app/utils/text.py" gives "app.utils.text".
def module_name(path: str) -> str: return path.replace("/", ".").replace(".py", "")- Time
O(n)- Space
O(n)- Why
- Each replace makes a new string of the path’s length.
Where it breaks: "app/utils/__init__.py" gives "app.utils.__init__", which isn’t how you import a package.
Drop __init__ too
The idea: Remove .py from the end only, turn / into ., and remove a final .__init__.
The waste in Okay: it treats a package’s __init__.py like any other file.
Highlighted lines are new in this rung.
def module_name(path: str) -> str: name = path.removesuffix(".py").replace("/", ".") return name.removesuffix(".__init__")- Time
O(n)- Space
O(n)- Why
- A few passes over the path.
What changed from the previous rung
def module_name(path: str) -> str: return path.replace("/", ".").replace(".py", "") name = path.removesuffix(".py").replace("/", ".") return name.removesuffix(".__init__")Where it breaks: a Windows path keeps its \: "app\utils\text.py" gives "app\utils\text".
Split into parts
The idea: Turn \ into /, drop .py, split on /, drop a last __init__, and join with dots.
The waste in Better: it works on one long string, so each new case needs another replace.
Highlighted lines are new in this rung.
def module_name(path: str) -> str: parts = path.replace("\\", "/").removesuffix(".py").split("/") if parts[-1] == "__init__": parts.pop() return ".".join(parts)- Time
O(n)- Space
O(n)- Why
- A few passes over the path.
What changed from the previous rung
def module_name(path: str) -> str: name = path.removesuffix(".py").replace("/", ".") return name.removesuffix(".__init__") parts = path.replace("\\", "/").removesuffix(".py").split("/") if parts[-1] == "__init__": parts.pop() return ".".join(parts)When Best isn’t best: for real paths, pathlib.PurePath(path).with_suffix("").parts splits on the right separator for you.
2. Read a requirements file (medium)
Section titled “2. Read a requirements file (medium)”Write parse_requirements(text). Each line of a requirements.txt can pin a package, like requests==2.32.3. Return a dict of name to version. Skip blank lines and comments, which start with #, even after a pin. Names are not case-sensitive, and _ means the same as -, so store them in lower case with -.
Hint 1 of 3
text.splitlines() gives one line at a time.
Hint 2 of 3
line.split("#")[0] keeps only the part before a comment.
Hint 3 of 3
Spaces can sit round == too: strip both halves.
Read a requirements file
Ctrl + Enter runs- Not run yet:
parse_requirements('requests==2.32.3')→{'requests': '2.32.3'} - Not run yet:
parse_requirements('# web app\nDjango==5.2.19\n\nrequests==2.32.3')→{'django': '5.2.19', 'requests': '2.32.3'} - Not run yet:
parse_requirements('requests==2.32.3 # for the reports')→{'requests': '2.32.3'} - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Split every line on ==
The idea: For each line, split on == into name and version.
A file with only pins works.
def parse_requirements(text: str) -> dict[str, str]: pins = {} for line in text.splitlines(): name, version = line.split("==") pins[name] = version return pins- Time
O(n)- Space
O(p)- Why
- One pass over the n characters; one entry per pin.
Where it breaks: a comment or blank line has no ==, so name, version = … raises ValueError.
Skip blanks and comment lines
The idea: Strip the line, and skip it if it’s empty or starts with #.
The waste in Okay: it expects every line to be a pin.
Highlighted lines are new in this rung.
def parse_requirements(text: str) -> dict[str, str]: pins = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue name, version = line.split("==") pins[name] = version return pins- Time
O(n)- Space
O(p)- Why
- The same pass.
What changed from the previous rung
def parse_requirements(text: str) -> dict[str, str]: pins = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue name, version = line.split("==") pins[name] = version return pinsWhere it breaks: a comment after a pin stays in the version: "2.32.3 # for the reports". And Django and django would be two packages.
Cut comments, tidy names
The idea: Cut everything after #, then store the name in lower case with - and the version stripped.
The waste in Better: it trusts names and comments to be written one way.
Highlighted lines are new in this rung.
def parse_requirements(text: str) -> dict[str, str]: pins = {} for line in text.splitlines(): line = line.split("#")[0].strip() # drop a comment, even after a pin if not line: continue name, version = line.split("==") pins[name.strip().lower().replace("_", "-")] = version.strip() return pins- Time
O(n)- Space
O(p)- Why
- The same pass.
What changed from the previous rung
def parse_requirements(text: str) -> dict[str, str]: pins = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): line = line.split("#")[0].strip() # drop a comment, even after a pin if not line: continue name, version = line.split("==") pins[name] = version pins[name.strip().lower().replace("_", "-")] = version.strip() return pinsWhen Best isn’t best: real files also use >=, extras like requests[socks], and markers like ; python_version < "3.12". Use the packaging library’s Requirement class for those.
3. Can two projects share one environment? (medium)
Section titled “3. Can two projects share one environment? (medium)”Write clashes(a, b). a and b are two projects’ pins, as dicts of name to version. Return, in A to Z order, the names both projects pin to different versions. Those are the packages that make one shared site-packages break.
Example: clashes({"requests": "2.32.3", "pytest": "9.2.0"}, {"requests": "2.31.0", "pytest": "9.2.0"}) gives ["requests"].
Hint 1 of 3
Checking every pair of names is slow for big lists.
Hint 2 of 3
name in b is a fast dict lookup.
Hint 3 of 3
a.keys() & b.keys() gives the names both have.
Can two projects share one environment?
Ctrl + Enter runs- Not run yet:
clashes({'django': '5.2.19', 'requests': '2.32.3', 'pytest': '9.2.0'}, {'requests': '2.31.0', 'pytest': '9.2.0', 'numpy': '2.3.1'})→['requests'] - Not run yet:
clashes({}, {'requests': '2.31.0'})→[] - Not run yet:
clashes({'b': '1', 'a': '1'}, {'a': '2', 'b': '2'})→['a', 'b'] - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Compare every pair
The idea: For each name in a, look through every name in b.
Small projects come out right.
def clashes(a: dict[str, str], b: dict[str, str]) -> list[str]: found = [] for name_a, version_a in a.items(): for name_b, version_b in b.items(): if name_a == name_b and version_a != version_b: found.append(name_a) return found- Time
O(a × b)- Space
O(k)- Why
- Every pin in a meets every pin in b; k clashes are kept.
Where it breaks: 6,000 pins each means 36 million pairs, which is too slow. And the order follows a, not A to Z.
Look each name up
The idea: For each name in a, check name in b, which is one dict lookup.
The waste in Okay: it walks all of b to find one name, when a dict finds it at once.
Highlighted lines are new in this rung.
def clashes(a: dict[str, str], b: dict[str, str]) -> list[str]: found = [] for name, version in a.items(): if name in b and b[name] != version: found.append(name) return found- Time
O(a)was O(a × b)- Space
O(k)- Why
- One lookup per pin in a.
What changed from the previous rung
def clashes(a: dict[str, str], b: dict[str, str]) -> list[str]: found = [] for name_a, version_a in a.items(): for name_b, version_b in b.items(): if name_a == name_b and version_a != version_b: found.append(name_a) for name, version in a.items(): if name in b and b[name] != version: found.append(name) return foundWhere it breaks: the names come out in a’s order, not A to Z.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”importsearchessys.pathin order: the script’s folder, the standard library, thensite-packages. The first match wins.- Never name a file after a module you use:
random.py,json.py,requests.py. - Install with
python -m pip install …, so the package goes to the Python you run. python -m venv .venv, then activate it:source .venv/bin/activate, or.venv\Scripts\activateon Windows.- Record packages in
requirements.txtand commit it; never commit.venv.
Go deeper
Section titled “Go deeper”- Modules, in the Python tutorial: packages and the module search path.
- Virtual environments and packages, in the Python tutorial.
- Installing packages, from the Python Packaging User Guide.
- Next on the Python roadmap: Git for everyday work.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started