Have you ever watched pip print a cheerful success line, then watched the next import crash anyway? I reconstructed that mismatch as a forty-eight hour lab diary, and it was not a network problem. It was two Pythons pretending to be one toolchain, plus a distro policy I had stopped reading. This is the notebook I wish I had opened at hour zero, before another install.
Hour 0: A Script That Should Have Been Boring
I needed a tiny checker that compared pinned versions in a requirements-style line. The first import was from packaging.version import Version, which should have been a one-liner. I ran the file with python check_pin.py and got ModuleNotFoundError: No module named 'packaging'. Nothing in that sentence deserved a two-day detour, right?
The script lived in a throwaway directory beside a venv I had created two weeks earlier. I opened a fresh terminal like a person with no memory, then trusted every short command name on PATH. Locally that feels harmless. It is how the wrong installer gets a vote.
What I Tried Before I Wrote Anything Down
I did the usual local dance, in this order, without capturing a single path:
pip install packaging- rerun
python check_pin.py - stare at the same traceback
pip install --upgrade packaging- stare harder, then blame PyPI
The install line kept saying the requirement was already satisfied. Why would an installer lie like that, and why would it lie so politely? I assumed caches, then assumed a yanked wheel, then assumed I had typed the package name wrong. I had typed pip and python as if they shared a prefix. They did not share a prefix.
Hour 4: I Asked a Model, Then I Left the Laptop
I pasted the traceback into MonkeyCode because this account has free model access, and I wanted a clean second opinion. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I reran the same commands on the free server option so the failure would have a remote column, not just my shell history. The model did what most coding models do under time pressure. It suggested pip install packaging, then pip install --user packaging, then a root install I will not repeat here.
That advice is locally tempting. It is also how you spend the next day fighting the operating system instead of the import. The traceback I had pasted never included sys.executable. That omission is on me.
Hour 6: The Server Spoke PEP 668
On the disposable runner, the first pip install did not even pretend to succeed. It printed the externally-managed-environment error that Debian and Ubuntu still ship after PEP 668. The stdlib directory contained an EXTERNALLY-MANAGED marker, and pip 23+ refuses to write over a distro Python without a break-glass flag. I had been treating that message as noise. It is the distro telling you that system python3 is not a playground.
I created a virtual environment, because that is the actual fix, not a louder installer:
python3 -m venv .venv
. .venv/bin/activate
python -m pip install packaging
python check_pin.py
The import worked inside that shell. I closed the session feeling clever, which is usually the hour the bug relocates to another process.
Hour 18: Requirement Already Satisfied
Back on the laptop, pip still claimed packaging was satisfied, and python check_pin.py still crashed. I finally printed the only identity checks that matter, in one block:
which python
which pip
python -c "import sys; print(sys.executable)"
pip -V
python -m pip -V
pip -V pointed at a user-level installer hanging off a different prefix. python -m pip -V pointed at the interpreter that actually ran my script. The package was installed. It was installed for someone else. Have you checked those two -V lines on the same morning, or do you also trust the short name?
I uninstalled from the wrong place, then installed with the interpreter that would run the file:
python -m pip uninstall -y packaging
python -m pip install packaging
python -c "from packaging.version import Version; print(Version('1.2.3'))"
The version object printed, and I still did not trust the shell. I wrote a probe so the next session would start with paths, not folklore.
The Artifact I Wish I Had Run First
Save this as interp_probe.py. It needs no network, and it is meant to be boring. Run it with every launcher you currently believe is "python".
#!/usr/bin/env python3
"""Show which interpreter will own the next pip install."""
from __future__ import annotations
import sys
import sysconfig
from pathlib import Path
def main() -> None:
stdlib = Path(sysconfig.get_path("stdlib"))
marker = stdlib / "EXTERNALLY-MANAGED"
purelib = Path(sysconfig.get_path("purelib"))
in_venv = sys.prefix != sys.base_prefix
print(f"executable: {sys.executable}")
print(f"version: {sys.version.split()[0]}")
print(f"prefix: {sys.prefix}")
print(f"base_prefix: {sys.base_prefix}")
print(f"in_venv: {in_venv}")
print(f"purelib: {purelib}")
print(f"pep668_marker: {marker}")
print(f"pep668_active: {marker.is_file()}")
try:
import packaging
print(f"packaging_file: {packaging.__file__}")
except Exception as exc: # lab probe: show any import failure
print(f"packaging_import: {type(exc).__name__}: {exc}")
if __name__ == "__main__":
main()
Three launchers, one file
python interp_probe.py
python3 interp_probe.py
python -m interp_probe
If those three blocks disagree, stop installing things. You are not debugging the packaging project. You are debugging launchers that happen to share a nickname.
The checklist I now keep beside the probe
- Always call
python -m pip, never a barepip, when the next import matters. - Treat
Requirement already satisfiedas a question, not as a green check. - If PEP 668 fires, create a venv; do not reach for
--break-system-packages. - After
activate, run the probe again, because activation is per shell. - On a remote runner, activation often dies between queued commands.
A Decision Table I Now Keep in the Repo
| What you see | What it usually means | What to run next |
|---|---|---|
ModuleNotFoundError after a green pip |
pip is not that interpreter |
python -m pip -V versus pip -V
|
externally-managed-environment |
distro Python, PEP 668 marker present |
python3 -m venv .venv, then install inside it |
Requirement already satisfied plus ImportError |
another prefix owns the dist-info | python -m pip show packaging |
| Works in one SSH command, fails in the next | venv activate did not survive | print VIRTUAL_ENV and sys.executable every session |
Model suggests sudo pip or --user on a server |
it is optimizing for a laptop story | refuse, recreate the venv, rerun the probe |
None of those rows need a benchmark. They need two paths printed next to each other, on the same machine, in the same minute.
What Broke When I Trusted the Shell
The remote column made the PEP 668 failure obvious, which was useful. The laptop hid it behind a user-site pip that still had an old wheel. The model collapsed both machines into one advice string, because the traceback did not include interpreter identity. I had pasted symptoms and withheld the only fact that could choose an installer.
Non-interactive runners added a second cut that local terminals rarely teach you. I activated .venv in command one, then ran python check_pin.py in command two, and the second process was a clean shell. Does your runner inherit activation, or does it start empty every time? Mine started empty. The probe in command two showed in_venv: False, and I finally stopped blaming the index.
What I Would Repeat
I would print interpreter identity before I paste any traceback into a model. I would install with python -m pip only, even when the short name looks faster. I would create a venv on every machine that is not already a dedicated environment manager. I would run the probe on the laptop and on the disposable runner, then diff executable and pep668_active by hand.
If you like current tooling, uv can create the venv and install without teaching the shell two names:
uv venv .venv
uv pip install packaging
.venv/bin/python interp_probe.py
I still run the probe after uv, because confidence is how this bug returns. A lockfile would have helped the dependency set. It would not have helped a bare pip pointed at the wrong prefix.
Limitations, and Who Should Skip This
This notebook does not fix missing manylinux tags, broken C extensions, or a wheel that never uploaded for your platform. It also does not replace a lockfile, and it will not explain an import that fails after the probe says the file is present. If your shop standardizes on Conda and never wants a venv, the probe still helps, but the install commands on this page will not match your runbooks.
Do not paste tokens into a shared or free runner to "just test pip". The lesson is interpreter identity, not secret handling. Skip the model loop entirely when you already know pip -V and python -m pip -V disagree. The table above is faster than another generated install line. Skip --break-system-packages unless you are maintaining the distro image itself, which I was not.
I am not claiming a quota, a model name, or a hardware spec for the remote side. I am claiming that a second machine will disagree with your laptop, and that disagreement is the useful part.
Hour 48: The Note I Left for Next Time
The package was never missing. The installer I trusted was never the interpreter I ran. PEP 668 was not a pip regression; it was a boundary I had walked past while chasing a satisfied requirement. Next time I will run interp_probe.py before I ask anything, including myself, why a green install still cannot import.
If you steal one habit from this diary, steal the double -V check and the three-way probe run. Diff those blocks before you argue with a satisfied requirement.
Top comments (0)