How do you ship a Python CLI that never imports from the user's site directory or PYTHONPATH?
answer
- It has to be true before your code runs
- A flag beats a variable that can be unset
- Generated entry points carry no options
- The shebang cannot portably carry a flag
- Check sys.flags.isolated, then re-exec with -I
basics
~20 sMake isolation part of how the interpreter starts, not a request. Launch with -I, and where the command line is not yours, have the entry point re-exec itself with -I and assert sys.flags.isolated before importing anything of substance.
solid answer
~50 sIsolation is a startup property, so it has to be established before your code runs. The reliable form is `python -I -m yourtool`, because command-line options cannot be overridden by anything in the user's environment. The problem in practice is that installed console scripts get a plain shebang with no flags, and a shebang line cannot portably carry one - on Linux the kernel hands `env` the whole rest of the line as a single argument, so `#!/usr/bin/env python3 -I` fails outright unless you use `env -S`. The workable pattern is a tiny entry point that checks `sys.flags.isolated` and, if it is `0`, re-execs the same interpreter with `-I` before importing the application. Environment variables such as `PYTHONNOUSERSITE` and `PYTHONSAFEPATH` are useful for children you launch yourself, but they are advisory: `-E` discards them, and so does anyone who unsets them.
code
python · 7 linesimport os
import sys
if not sys.flags.isolated:
os.execv(sys.executable, [sys.executable, '-I', __file__, *sys.argv[1:]])
print('isolated:', sys.flags.isolated, 'no_user_site:', sys.flags.no_user_site)go deeper
Know that python -I starts an interpreter that ignores PYTHON* variables and the per-user install directory, and that a tool behaving differently on one machine often means that machine contributed something to the search path.
Be able to explain why a command-line flag is stronger than an environment variable here, and to name the variables that approximate it - PYTHONNOUSERSITE and PYTHONSAFEPATH - along with their limits.
Show the mechanism end to end: why a generated console script cannot carry the flag, the check-and-re-exec entry point, passing isolation to child processes, and the startup assertion and logging that make a mis-launch visible instead of silent.
Own the guarantee and its cost. Decide how far the tool goes - flag, re-exec, or a shipped interpreter - what extensibility you give up when the working directory stops being importable, and how support diagnoses a machine you cannot log into.
### Why this comes up The failure this defends against is not exotic. A log-ingest pipeline ships a collector CLI that runs on machines the team does not own. On most of them it behaves; on a handful it imports a helper module from the operator's per-user install directory instead of the copy shipped alongside the tool. The version there is older, its batching behaves differently, and the ingest worker settles at a 2.4 GB working set instead of the few hundred megabytes it was sized for. Nobody notices for a week, because the plugin loader wraps its imports in a broad `except Exception` and logs at debug level - a swallowed exception is what turns a loud `ImportError` into a slow memory problem on one machine in ten. Every ingredient of that story is startup state: `PYTHONPATH` from a shell profile, a per-user install, a working directory that happened to contain a file with the same name. None of it is visible from inside the application once the process is running, which is why the fix belongs at the launch, not in the code. ### Isolation has to be established before your first import `-I` is the switch, and command-line options are the only form of it that nothing in the environment can countermand: `-E` disables `PYTHONSAFEPATH` and `PYTHONNOUSERSITE`, and a user can unset any variable, but no variable can cancel a flag. So the goal reduces to a mechanical one: make sure the interpreter that ends up running your code was started with `-I`. **When you control the invocation**, that is the end of it - `python -I -m yourtool`, or a wrapper script or unit file that spells out the flag. Container images and service definitions fall in this bucket, and they are the easy case. **When the installer generates the entry point**, you usually do not control it. An installed console script is a small generated file whose shebang names the interpreter with no options, and a shebang cannot portably carry one: on Linux the kernel passes everything after the interpreter path as a *single* argument, so `#!/usr/bin/env python3 -I` asks `env` to find a program literally named `python3 -I`. Some systems provide `env -S`, which splits that argument, but relying on it narrows your platform support. **So the portable pattern is a re-exec.** The entry point does almost nothing: it checks `sys.flags.isolated`, and if the flag is `0` it replaces the process with the same interpreter plus `-I`, using `sys.executable` as the path. On the second pass the flag is `1` and the real application gets imported. It costs one extra interpreter startup and it makes the guarantee unconditional. The check-then-`exec` structure also gives you the place to fail loudly instead: some tools prefer to raise at startup, so that a mis-launched process dies immediately rather than running with a search path nobody vetted. **When the interpreter is embedded in your own binary**, the isolation switch lives in the embedding configuration the host program fills in before initializing the runtime. It is the same set of decisions - environment variables, user site directory, prepended path entry - expressed as fields instead of flags. ### Passing it on, and proving it Flags are per process. A child launched from an isolated parent inherits the environment but not the command line, so anything you spawn has to be given the flag explicitly - build the argument list starting from `sys.executable` and `-I`. Where you cannot inject a flag into someone else's launcher, the environment variables are the fallback: `PYTHONNOUSERSITE=1` and `PYTHONSAFEPATH=1` set `sys.flags.no_user_site` and `sys.flags.safe_path` in the child. Treat them as best-effort, and remember they vanish under `-E`. Proof matters as much as the mechanism, because the whole class of bug is silent. Three habits pay for themselves: - **Assert the launch conditions.** A startup check on `sys.flags.isolated` turns a mis-launched process into an immediate, obvious failure. - **Log the search path once at startup.** Recording `sys.executable`, `sys.prefix` and `sys.path` in the first log line makes the difference between two machines a diff rather than an investigation. - **Audit from outside.** `python -I -m site` prints the resolved search path and the state of the user site directory for a given interpreter, which is the fastest way to confirm what a customer's machine actually resolves. ### What isolation is not Isolation controls what the *invoking user* contributes. It does not pin your dependencies - `-I` still uses the interpreter's own site-packages, so the versions installed next to your tool are the versions you get, and keeping those correct is a packaging job. Nor is it a substitute for shipping the tool with its own interpreter when the guarantee has to survive users who edit the installation itself. And it is not free of consequences: a tool launched with `-I` cannot be extended by dropping a module into the working directory, which is occasionally a feature people were relying on without saying so.
- Why not just delete the offending entries from sys.path at the top of your main module?Because by then it is too late and it is incomplete. Anything imported before your edit - including whatever the startup machinery already ran - is already bound in `sys.modules`, and you are guessing at which entries came from the user rather than removing them at the source. Establishing the flag before the interpreter builds the path is a guarantee; editing the list afterwards is a patch you have to keep correct as launch conditions change.
- Your tool must also run in an environment where you can only set variables, not arguments. What do you do?Set `PYTHONNOUSERSITE=1` and `PYTHONSAFEPATH=1`, which cover the per-user directory and the prepended entry, and accept that `PYTHONPATH` is still in play unless you can clear it too. Then assert the resulting `sys.flags` at startup and log the resolved path, so a machine where the variables were not applied fails visibly instead of quietly importing something else.
- What does isolated mode not protect you from?Anything installed into the interpreter's own site-packages, since `-I` deliberately does not imply `-S` and your tool needs its dependencies. It also does not protect against someone modifying the installation itself. Pinning versions is a packaging concern, and a guarantee that has to survive an edited install means shipping your own interpreter rather than adding another flag.
saying these in an interview costs you the question
- Puts -I in a shebang line and assumes it works everywhere
- Relies on PYTHONNOUSERSITE alone, which -E discards
- Edits sys.path in main and calls it isolation
- Assumes child processes inherit the parent's flags
- Thinks -I also pins dependency versions
- Never verifies sys.flags.isolated at startup