Python and virtual environments on Ubuntu 26.04
python3, python3-venv, PEP 668 and running a venv from cron or systemd.
tar -xzf ps-setup.tar.gz, which creates ps-setup/. SHA-256: dbd930cb29558827af4700d27b09c6da9b52d0a8058e09088c76170b612b6c92This lesson sets up the Python every later lesson uses. You will see what a fresh Ubuntu 26.04 server gives you, install the one missing package, create a virtual environment, install a pinned library into it, understand why pip refuses to touch the system Python, and run a script from cron without depending on anything your interactive shell set up. It takes about twenty minutes, and it prevents the most common failure of Python automation on Linux: a job that quietly runs with different library versions from the ones you tested. The lab works in ~/ps-setup; unpack the lesson files in your home directory, or create the directory and the two files as they appear.
What a fresh Ubuntu 26.04 server gives you
Ubuntu Server 26.04 ships Python 3.14 as python3, because parts of the operating system are written in it. That is where its Python support for you stops. This terminal is an untouched install with only updates applied:
Read the four results. python3 is CPython 3.14.4. There is no command called python: Ubuntu installs the interpreter only as python3, and the bare name comes from an optional package, python-is-python3. A crontab line or an old script that calls python fails with exit status 127, the Bash code for "command not found". There is no pip command either, and python3 -m pip says the module does not exist. Ubuntu does not ship pip for its own Python.
In an interactive terminal Ubuntu's command-not-found helper adds a hint under those errors. For pip the hint is apt install python3-pip. Hold off on that one; the PEP 668 section below shows what it leads to.
A virtual environment (venv) is a directory with its own python and its own place for third-party packages, so a library you install for one tool cannot change another tool or the operating system. The standard library module venv creates one. On the fresh server it fails:
venv works in two stages. It builds the directory, then runs ensurepip to put a copy of pip inside it. Debian and Ubuntu move ensurepip out of the base Python package into python3-venv, which a server install does not include, so the second stage fails and the command exits 1. apt-cache policy confirms it: Installed: (none), available from the universe component.
Two details in that output matter. The failed run still left a .venv directory behind with no pip in it, so delete it before you try again. And the message suggests python3.14-venv; install python3-venv instead. It pulls in the versioned package for whatever the default python3 is, so it keeps working when a later release moves the default on. Run this once on your machine: sudo apt install python3-venv. The rest of this lesson runs on a machine where that is done.
Create a venv and install into it
python3 -m venv .venv prints nothing when it works. .venv is the usual name, and the leading dot keeps it out of a plain ls. The listing shows what a venv really is: .venv/bin/python3 is a symbolic link to /usr/bin/python3, not a copy. The interpreter is the system one. The difference is pyvenv.cfg. When Python starts, it looks for that file next to the executable and in the directory above it, and finds .venv/pyvenv.cfg. home tells it where the base installation lives, so the standard library comes from there, and include-system-site-packages = false hides the third-party packages installed system-wide. Packages you install go into .venv/lib/python3.14/site-packages.
source .venv/bin/activate changes only the shell you type it in. It puts .venv/bin first on PATH and adds (.venv) to your prompt (the recorded terminal does not show prompts). python and pip now resolve inside the venv, deactivate undoes it, and python is gone again. Activation is a convenience for interactive work and nothing depends on it: .venv/bin/python called by its path is the same interpreter with the same packages. Scripts, cron and systemd should always use that form.
Install with python -m pip rather than pip. The -m form runs the pip that belongs to that exact interpreter, so a package cannot land in some other Python that happens to be earlier on PATH. Pin the version with == so the next install gets the same code. Locking every dependency with hashes comes in "Packaging, pinning and auditing your tool". The venv's pip is 25.1.1, the version Ubuntu bundles for ensurepip.
To see which interpreter and which library a script really gets, ask it. This is the script the rest of the lesson runs:
import sysimport urllib3print("interpreter:", sys.executable)print("inside a venv:", sys.prefix != sys.base_prefix)print("urllib3", urllib3.__version__, "from", urllib3.__file__)
sys.executable is the interpreter that is running. sys.prefix differs from sys.base_prefix only inside a venv. urllib3.__file__ is where the import was found. Here all three point into .venv.
Why pip refuses the system Python
Here is what happens when someone follows the apt install python3-pip hint. The lab installed python3-pip for this demonstration and removed it afterwards:
Both forms stop with error: externally-managed-environment and exit status 1, and nothing is installed. This is PEP 668. Ubuntu puts a marker file called EXTERNALLY-MANAGED in the standard library directory, /usr/lib/python3.14/, and pip refuses to install into any interpreter that has one. The reason is ownership: /usr/lib/python3/dist-packages belongs to apt, and system tools import the versions apt put there. A pip upgrade of one of those libraries can break them, and apt has no record that pip changed its files.
The message lists the ways out: apt install python3-xyz when the operating system packages the library, a venv for everything else, and pipx for command-line applications (covered in the packaging lesson). The last line offers --break-system-packages. Treat that flag, and sudo pip install, as mistakes in a review: they overwrite files apt manages, and sudo runs pip, and any build code a package brings, as root. You do not need python3-pip at all. With python3-venv installed, every venv already has its own pip.
The marker is Ubuntu's decision, not a property of Python. The upstream CPython 3.14.7 this course cross-checks on, compiled from python.org source, has no marker:
Run as a normal user, pip on a Python like that falls back to installing into your home directory without complaint. Use a venv there too: the reason for a venv is keeping each tool's libraries separate, and the error message is only Ubuntu enforcing it.
Run it the way cron and systemd will
Activation lives in your interactive shell, and scheduled jobs never see it. The environment cron does give a job (/bin/sh, HOME, a short system PATH, nothing from your .bashrc) is covered in the Linux essentials lesson "Scheduling: cron and systemd timers", and auditing a host's scheduled jobs in the Linux hardening lesson "Scheduled jobs: cron and timers". The question here is narrower: which Python and which libraries does a job get? Let cron run two entries once: one runs the script with bare python3, one runs it with the venv's interpreter by absolute path. Every line carries ps-setup, so the test lines are easy to find and remove again.
# ps-setup test, every minute: the same script under the system python3 and under the venv.# ps-setup: cron runs each line with /bin/sh and sets HOME to your home directory.* * * * * cd "$HOME/ps-setup" && python3 whereami.py > cron-system.out 2>&1* * * * * cd "$HOME/ps-setup" && "$HOME/ps-setup/.venv/bin/python" whereami.py > cron-venv.out 2>&1
crontab crontab.txt would replace your whole crontab with that file, and crontab -r would delete it, so do what the Bash capstone does: save a copy, add the test lines to what is already there, and wait for the next full minute, when cron runs them (at most 90 seconds here):
The two outputs are the lesson. Bare python3 ran /usr/bin/python3, and it did not fail. It imported urllib3 2.6.3 from Debian's python3-urllib3 package instead of the 2.8.0 you pinned. That is the dangerous version of this mistake: a job that half-works, against library versions you never tested, with nothing in the log to say so. The venv entry got the right interpreter and the right library without any activation.
Now take the test lines out again, keeping everything else. diff compares the result with the copy you saved and finds no difference, so nothing else in your crontab changed:
You do not have to wait for cron to test an entry. env -i clears the whole environment, and PATH=/usr/bin:/bin sets a PATH even shorter than cron's:
The middle command shows the other trap: python does not exist, so the job fails with 127. The second line of that error is a hint from the uutils env that Ubuntu 26.04 uses and has nothing to do with the problem. The last command is the form to put in a crontab or a systemd unit: absolute paths for both the interpreter and the script, for example ExecStart=/home/deploy/ps-setup/.venv/bin/python /home/deploy/ps-setup/whereami.py (the lab user is deploy; use your own home directory). The capstone schedules a real job this way; the Linux course covers systemd timers.
What a venv is not
A venv is not a sandbox. Code inside it runs as your user, can read everything you can read (~/.ssh, tokens in your environment), and can open network connections. It separates library versions, not privileges. Untrusted code needs a container, a virtual machine or a separate account.
A venv also does not move. The scripts pip installs into bin/ start with a #! line holding the venv's absolute path at the time it was created. Rename the directory and see:
Bash reports bad interpreter: No such file or directory with exit status 126. The file moved-venv/bin/pip exists; the interpreter named in its first line, /home/deploy/ps-setup/.venv/bin/python3, does not. The same happens when a venv is copied to another host, restored from a backup into a different path or copied into a container image. The fix is to rebuild it where it will run, which takes seconds, as the last command shows. Keep what you need to rebuild (the pinned package list) in version control, and add .venv/ to .gitignore.
pip install --break-system-packages, sudo pip install and apt install python3-pip all try to solve the wrong problem. Install python3-venv once, then give every tool its own venv.Try this
In ~/ps-setup, build a second venv called try-env and install urllib3==2.8.0 into it with try-env/bin/python -m pip install. Break it: run whereami.py the way a careless crontab would, env -i PATH=/usr/bin:/bin python3 whereami.py, and note the urllib3 version it reports. Diagnose it from the interpreter: line. Fix it by calling "$HOME/ps-setup/try-env/bin/python" with the absolute path of the script, still under env -i PATH=/usr/bin:/bin (your shell expands $HOME before env -i clears the environment). Verify: the broken run reports inside a venv: False and urllib3 2.6.3, the fixed run reports inside a venv: True and urllib3 2.8.0 from try-env. Then delete try-env.
Takeaway
Give every tool its own venv, install into it with <venv>/bin/python -m pip install name==version, and make cron and systemd call that interpreter by absolute path. If pip says externally-managed-environment, you are about to install into the wrong Python.
python3 /opt/audit/report.py. You installed urllib3==2.8.0 into /opt/audit/.venv, the job logs no errors, yet a debug line in its output says urllib3 2.6.3. What is going on?python3 is the system interpreter, and Ubuntu ships its own urllib3 for it, so the job runs without an error against a version you never tested. Use /opt/audit/.venv/bin/python.<venv>/bin/python -m pip install installs into the venv's own site-packages, not into ~/.local. The version difference comes from which interpreter ran.python3 -m venv .venv prints "ensurepip is not available" and exits 1. What should you do?venv needs ensurepip, which Ubuntu ships in python3-venv. The failed run leaves a directory without pip, so start clean.venv runs ensurepip, which is not part of python3-pip. That package only adds a system pip, and pip then refuses the system Python with the PEP 668 error.python3 to run, and --user would bypass the venv entirely. The directory has an interpreter but no way to install into it./opt/scan/.venv to /opt/scan/env to match a naming standard. /opt/scan/env/bin/pip --version now fails with "bad interpreter: No such file or directory" and exit status 126. What happened?mv keeps file modes.home names the base installation, which did not move. It is still correct after the rename.PATH. This command was given as a path, so nothing was looked up.