The note is generated by GLM5.2.
A pip install run inside an activated conda env kept landing in ~/.local
instead of the env’s site-packages. This is what was going on.
TL;DR โ everything you need is here
- Symptom. Inside an activated conda env,
pip install <pkg>installs to~/.local/lib/python3.10/site-packages(shared across every py3.10 env)
instead ofenvs/<env>/lib/.../site-packages. - Cause. The container image exports
PIP_NO_USER=1globally. Despite the
name, this forces user-site installs โ theNO_is ignored.PIP_NO_USER
behaves identically toPIP_USER. - Fix. Any one of:
1 2 3unset PIP_NO_USER # in ~/.bashrc (permanent, interactive shells) PIP_NO_USER=0 pip install โฆ # per-command pip install --no-user โฆ # per-command (CLI flag inverts correctly)
Quick reference:
| Setting | pip install target |
|---|---|
PIP_NO_USER=1 (default in this image) | ~/.local โ |
| unset | conda env โ |
PIP_NO_USER=0 | conda env โ |
--no-user (CLI flag) | conda env โ |
The mechanism in one line: pip’s env-var reader doesuse_user_site = strtobool("1") = 1 and assigns it without inverting forstore_false, so the option’s name (no-user) is meaningless on the env-var
path. Only the value (1 vs 0 vs absent) matters.
Everything below is supporting detail. If the table and the one-liner above
are enough, you can stop reading.
1. Symptom
| |
pip show <pkg> afterward:
Name: <pkg>
Version: 3.6.8
Location: ~/.local/lib/python3.10/site-packages <- wrong place!
which pip was right and the env was properly activated โ yet the package went
to ~/.local.
Why that’s bad: ~/.local is the user site-packages, shared across every
Python 3.10 conda env. Installing there means
- env isolation breaks (all py3.10 envs import the same file),
pip listin env A reports a package env B actually executes,- a later
pip install foo==Xinto the env gets shadowed by the stale~/.localcopy โ you silently keep the old version.
2. Ruling out the usual suspects
Before hunting exotic causes, kill the obvious mistakes:
| Suspicion | Check | Verdict |
|---|---|---|
| Env not activated | which pip โ envs/summary/bin/pip | โ activated |
| Wrong pip | shebang โ envs/summary/bin/python3.10 | โ correct |
pip.conf forces --user | pip config debug โ no config file | โ none |
| pip alias / function | type pip, declare -f pip | โ none |
PIP_USER env var | echo $PIP_USER | โ empty |
| env site-packages not writable | touch test | โ writable |
has pyvenv.cfg (venv marker) | none; sys.prefix == sys.base_prefix | โ ๏ธ pip ignores it |
The last row is the foreshadowing: conda envs have no pyvenv.cfg, so pip
does not treat them as virtualenvs. A bogus user-site install therefore
won’t error (a real venv would raise “User site-packages are not visible in
this virtualenv”) โ it silently lands in ~/.local.
3. Pinpointing PIP_NO_USER=1
pip config debug showed:
env_var:
PIP_NO_USER='1'
Source: /etc/profile.d/platform-env.sh:36
| |
First reaction: NO_USER=1 should mean “disable user-site install.” So why
did it install to ~/.local?
pip’s own verdict (-vv)
pip logs which scheme it picked in verbose mode. Observe with --dry-run
(no changes, just the decision):
| |
Toggling only this variable, everything else fixed:
PIP_NO_USER | pip’s verdict log | install location |
|---|---|---|
=1 (image default) | User install by explicit request | ~/.local โ |
| unset | (no such line) | conda env โ |
=0 | Non-user install by explicit request | conda env โ |
Conclusive: =1 forces user-site; unset / =0 installs into the env. The
name is inverted.
And PIP_NO_USER vs PIP_USER are exact aliases โ the NO_ does nothing:
| value | PIP_NO_USER | PIP_USER |
|---|---|---|
1 / true | user install | user install |
0 / false | non-user install | non-user install |
4. Mechanism (source-level, pip 24.0)
Why is the name inverted? Because pip reads CLI flags and env vars
through two different code paths, and the env-var path does not invertstore_false options.
4.1 Two flags share one dest
pip/_internal/commands/install.py:106-122:
| |
Both write to one internal switch, use_user_site. pip uses optparse (not
argparse); a store_true/store_false option with no default= defaults toNone โ which gives pip its tri-state:
use_user_site | meaning | pip does |
|---|---|---|
truthy (1/True) | user explicitly wants ~/.local | install to ~/.local |
falsy (0/False) | user explicitly refuses ~/.local | install to env |
None | user said nothing | auto-decide (env writable โ env) |
4.2 CLI flag path โ the action decides the value
A CLI flag is valueless; its presence fires optparse’s take_action. Forstore_false, presence hardcodes dest = False โ that inversion is baked into
the action, and the user types no value. So --no-user genuinely means “no user
install.” Pure-optparse reproduction of pip’s option def:
CLI argv use_user_site type
-----------------------------------------
['--no-user'] False bool <- not 1; it's False
['--user'] True bool
[] None NoneType
4.3 Env-var path โ the value string decides the value (no inversion)
Env vars go through _update_defaults() (pip/_internal/cli/parser.py), which
bypasses optparse’s take_action entirely.
Stage 1 โ read & normalize the key (configuration.py:318):
| |
So PIP_NO_USER=1 becomes config entry key="no-user" (the option’s long
name), val="1" (raw string).
Stage 2 โ strtobool turns the string into an int (utils/misc.py:260):
| |
This is the num(...).
Stage 3 โ the int is assigned straight to the dest (parser.py:216-263):
| |
The whole thing collapses to:
| |
4.4 Why no inversion โ the two key lines
parser.py:226โif option.action in ("store_true", "store_false"):
groups both actions; there is no separatestore_falsebranch and nonotanywhere.parser.py:227โval = strtobool(val). Theactionis only used to
decide whether to callstrtobool, never to invert the result.
So --no-user being store_false is meaningless on this path. strtobool("1")
returns 1, and 1 is stored. The name no-user is honored only by optparse’s
flag-presence handler (store_false โ False), which the env-var path
skips.
One line: CLI flags decide value by action (name matters, value is a
bool); env vars decide value by value string (name ignored, value is an
int).--no-userkeeps its promise;PIP_NO_USERdoes not.
4.5 Corroboration: the awkward guard
strtobool returns an int, not a bool. The first line ofdecide_user_install (install.py:676) is written specifically for that:
| |
Why not the clean if use_user_site is False:? Because in Python 0 is False
is False (different objects). With is False, use_user_site=0 would
slip through, then past if use_user_site: (since if 0: is false), and crash
on assert use_user_site is None. The guard’s existence is direct evidence the
env-var path delivers an int. False and 0 are different types but both
falsy, and the guard treats them alike.
5. End-to-end trace of the three cases
PIP_NO_USER=1 โ use_user_site = 1
(1 is not None) and (not 1)โTrue and Falseโ skipif use_user_site:โif 1:โ True โUser install by explicit requestโ~/.localโ
PIP_NO_USER=0 โ use_user_site = 0
(0 is not None) and (not 0)โTrue and TrueโNon-user install by explicit requestโ env โ
unset โ use_user_site = None (optparse default)
- skips both branches โ
assert use_user_site is Noneโ โ auto-decide:
no--prefix/--target,ENABLE_USER_SITEis True, env site-packages is
writable โNon-user install because site-packages writeableโ env โ
6. The fix
| |
Verify in a fresh interactive shell:
| |
Scope: ~/.bashrc covers interactive shells only. PIP_NO_USER=1 still
survives in non-interactive scripts (bash script.sh) and some login shells
that don’t read ~/.bashrc. For those, use PIP_NO_USER=0 / --no-user, or
also unset in a login-shell file like ~/.profile.
7. Why the image sets it (speculation)
platform-env.sh:36 is a bare export PIP_NO_USER=1 โ no comment, and no
custom pip wrapper reads it (confirmed: the pip entry points are stock pip;
the rest of the env script only sets up Java/Spark/Kerberos). Intent is
inference, but the name betrays the goal: force packages into the active
conda env and avoid ~/.local cross-env contamination โ a reasonable aim on
a shared ML platform. Most likely it went wrong one of two ways:
- Assumed the env var mirrors the
--no-userflag (it doesn’t โ pip’s env-var
layer ignoresstore_false). - Confused it with
PYTHONNOUSERSITE=1, a real CPython var that genuinely
disables user site-packages โ similar name, opposite effect.
The irony: a setting meant to prevent ~/.local pollution instead causes it.
8. Takeaways
PIP_NO_USER==PIP_USERas far as pip is concerned;NO_is dead
weight on the env-var path. The value (1/0/unset) decides, not the
name.- CLI flags and env vars are asymmetric: CLI โ value by action (name
matters); env โ value by string (name ignored). pip config treats everyPIP_<opt>as “assign value to dest” โ fine forstore_true,
counterintuitive forstore_false. - Conda envs lack
pyvenv.cfg, so pip doesn’t treat them as venvs; a
bogus user-site install won’t error, just silently lands in~/.local. ~/.localshadows the env (earlier onsys.path) โ “installed the new
version but still get the old one.”- Fastest diagnosis:
pip install --dry-run -vv+ grepinstall by explicit request/install because site-packages writeable. strtoboolreturns an int โ that’s whydecide_user_installhas the
awkward(x is not None) and (not x)guard, and it’s the key to the whole
chain.
Appendix โ key source locations (pip 24.0)
| File:line | Role |
|---|---|
commands/install.py:106-122 | --user / --no-user defs, shared dest=use_user_site |
commands/install.py:659-712 | decide_user_install() tri-state |
commands/install.py:676 | int-tolerant guard (x is not None) and (not x) |
configuration.py:318 | get_environ_vars() reads PIP_* |
configuration.py:51 | _normalize_name(): no_user โ no-user |
cli/parser.py:226-227 | strtobool(val) for both actions โ no inversion |
cli/parser.py:263 | defaults[option.dest] = val โ the assignment |
utils/misc.py:260 | strtobool(): "1"โ1, "0"โ0 (returns int) |
/etc/profile.d/platform-env.sh:36 | export PIP_NO_USER=1 โ root cause |
Environment: shared ML container ยท pip 24.0 / Python 3.10 ยท 2026-06-29