2026-06-29     1899 ๅญ—  4 ๅˆ†้’Ÿ

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 of envs/<env>/lib/.../site-packages.
  • Cause. The container image exports PIP_NO_USER=1 globally. Despite the
    name, this forces user-site installs โ€” the NO_ is ignored. PIP_NO_USER
    behaves identically to PIP_USER.
  • Fix. Any one of:
    1
    2
    3
    
    unset 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:

Settingpip install target
PIP_NO_USER=1 (default in this image)~/.local โŒ
unsetconda env โœ…
PIP_NO_USER=0conda env โœ…
--no-user (CLI flag)conda env โœ…

The mechanism in one line: pip’s env-var reader does
use_user_site = strtobool("1") = 1 and assigns it without inverting for
store_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

1
2
3
4
5
# inside the activated `summary` env
(summary) > which pip
~/apps/miniconda3/envs/summary/bin/pip        # <- correct, this IS summary's pip

(summary) > pip install <pkg> -i http://<internal-pypi>/simple/ --trusted-host <internal-pypi>

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 list in env A reports a package env B actually executes,
  • a later pip install foo==X into the env gets shadowed by the stale
    ~/.local copy โ€” you silently keep the old version.

2. Ruling out the usual suspects

Before hunting exotic causes, kill the obvious mistakes:

SuspicionCheckVerdict
Env not activatedwhich pip โ†’ envs/summary/bin/pipโœ… activated
Wrong pipshebang โ†’ envs/summary/bin/python3.10โœ… correct
pip.conf forces --userpip config debug โ†’ no config fileโœ… none
pip alias / functiontype pip, declare -f pipโœ… none
PIP_USER env varecho $PIP_USERโœ… empty
env site-packages not writabletouch 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

1
export PIP_NO_USER=1

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):

1
2
3
pip install --no-deps --dry-run -vv <pkg> \
  -i http://<internal-pypi>/simple/ --trusted-host <internal-pypi> 2>&1 \
  | grep -iE 'install by explicit request'

Toggling only this variable, everything else fixed:

PIP_NO_USERpip’s verdict loginstall location
=1 (image default)User install by explicit request~/.local โŒ
unset(no such line)conda env โœ…
=0Non-user install by explicit requestconda 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:

valuePIP_NO_USERPIP_USER
1 / trueuser installuser install
0 / falsenon-user installnon-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 invert
store_false options.

4.1 Two flags share one dest

pip/_internal/commands/install.py:106-122:

1
2
3
4
5
6
self.cmd_opts.add_option(
    "--user",    dest="use_user_site", action="store_true",    # flag present -> True
    help=...)
self.cmd_opts.add_option(
    "--no-user", dest="use_user_site", action="store_false",   # flag present -> False (hidden)
    help=SUPPRESS_HELP)

Both write to one internal switch, use_user_site. pip uses optparse (not
argparse); a store_true/store_false option with no default= defaults to
None โ€” which gives pip its tri-state:

use_user_sitemeaningpip does
truthy (1/True)user explicitly wants ~/.localinstall to ~/.local
falsy (0/False)user explicitly refuses ~/.localinstall to env
Noneuser said nothingauto-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. For
store_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):

1
2
3
4
5
6
7
8
def get_environ_vars(self):
    for key, val in os.environ.items():
        if key.startswith("PIP_"):            # matches PIP_NO_USER
            name = key[4:].lower()             # "PIP_NO_USER" -> "no_user"
            yield name, val                    # yields ("no_user", "1")

def _normalize_name(name):
    return name.lower().replace("_", "-")      # "no_user" -> "no-user"

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):

1
2
3
4
5
def strtobool(val: str) -> int:               # <- returns int, not bool
    val = val.lower()
    if val in ("y","yes","t","true","on","1"):  return 1   # "1" -> 1
    elif val in ("n","no","f","false","off","0"): return 0 # "0" -> 0
    else: raise ValueError(...)

This is the num(...).

Stage 3 โ€” the int is assigned straight to the dest (parser.py:216-263):

1
2
3
4
5
6
7
8
9
for key, val in self._get_ordered_configuration_items():   # key="no-user", val="1"
    option = self.get_option("--" + key)                    # finds "--no-user"
    assert option.dest is not None                          # dest == "use_user_site"

    if option.action in ("store_true", "store_false"):      # <- BOTH, grouped
        val = strtobool(val)                                # "1" -> 1, NOT inverted
    elif option.action == "count":  ...
    ...
    defaults[option.dest] = val                             # use_user_site = 1

The whole thing collapses to:

1
use_user_site = strtobool(os.environ["PIP_NO_USER"])   # == 1

4.4 Why no inversion โ€” the two key lines

  1. parser.py:226 โ€” if option.action in ("store_true", "store_false"):
    groups both actions; there is no separate store_false branch and no
    not
    anywhere.
  2. parser.py:227 โ€” val = strtobool(val). The action is only used to
    decide whether to call strtobool, 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-user keeps its promise; PIP_NO_USER does not.

4.5 Corroboration: the awkward guard

strtobool returns an int, not a bool. The first line of
decide_user_install (install.py:676) is written specifically for that:

1
2
3
4
5
# In some cases (config from tox), use_user_site can be set to an integer
# rather than a bool, which 'use_user_site is False' wouldn't catch.
if (use_user_site is not None) and (not use_user_site):
    logger.debug("Non-user install by explicit request")
    return False

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 โ†’ skip
  • if 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_SITE is True, env site-packages is
    writable โ†’ Non-user install because site-packages writeable โ†’ env โœ…

6. The fix

1
2
3
4
5
6
# one-shot
pip install --no-user <pkg> โ€ฆ            # CLI flag inverts correctly, wins over env
PIP_NO_USER=0 pip install <pkg> โ€ฆ        # set value to false

# permanent โ€” append to ~/.bashrc (at the END, after /etc/bashrc is sourced)
unset PIP_NO_USER

Verify in a fresh interactive shell:

1
2
3
bash -ic 'echo "PIP_NO_USER=[$PIP_NO_USER]"'           # -> []
pip install --no-deps --dry-run -vv <pkg> โ€ฆ 2>&1 | grep -iE 'install by'
# -> Non-user install because site-packages writeable   โœ…

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:

  1. Assumed the env var mirrors the --no-user flag (it doesn’t โ€” pip’s env-var
    layer ignores store_false).
  2. 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

  1. PIP_NO_USER == PIP_USER as far as pip is concerned; NO_ is dead
    weight on the env-var path. The value (1/0/unset) decides, not the
    name.
  2. CLI flags and env vars are asymmetric: CLI โ†’ value by action (name
    matters); env โ†’ value by string (name ignored). pip config treats every
    PIP_<opt> as “assign value to dest” โ€” fine for store_true,
    counterintuitive for store_false.
  3. 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.
  4. ~/.local shadows the env (earlier on sys.path) โ†’ “installed the new
    version but still get the old one.”
  5. Fastest diagnosis: pip install --dry-run -vv + grep
    install by explicit request / install because site-packages writeable.
  6. strtobool returns an int โ€” that’s why decide_user_install has 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:lineRole
commands/install.py:106-122--user / --no-user defs, shared dest=use_user_site
commands/install.py:659-712decide_user_install() tri-state
commands/install.py:676int-tolerant guard (x is not None) and (not x)
configuration.py:318get_environ_vars() reads PIP_*
configuration.py:51_normalize_name(): no_user โ†’ no-user
cli/parser.py:226-227strtobool(val) for both actions โ€” no inversion
cli/parser.py:263defaults[option.dest] = val โ€” the assignment
utils/misc.py:260strtobool(): "1"โ†’1, "0"โ†’0 (returns int)
/etc/profile.d/platform-env.sh:36export PIP_NO_USER=1 โ€” root cause

Environment: shared ML container ยท pip 24.0 / Python 3.10 ยท 2026-06-29