userpath
A small cross-platform tool for adding a directory to the user's PATH permanently. On Unix it appends an export line to the config files of the shells it detects, covering sh, bash, zsh, fish and xonsh, and knows the per-shell details such as zsh needing both .zshrc and .zprofile. On Windows it writes the user Environment key in the registry and broadcasts a settings change message so already-open programs pick it up. It exists so installers do not each reimplement the same platform quirks, and it is used through either a userpath command or four functions in Python.
A narrow tool that does one annoying job correctly across three operating systems, and its 6.4M weekly downloads come from installers depending on it rather than from anyone choosing it. Use it if you are writing one of those installers, and write your own removal path, because it has none.
Use it if
- You are writing an installer or a bootstrap script that drops an executable somewhere and needs it on the user's PATH afterwards, without asking them to edit dotfiles
- You need this to work the same way on Linux, macOS and Windows, since the Windows path is a registry write plus a WM_SETTINGCHANGE broadcast rather than anything shell-shaped
- You want to check before you change: in_current_path, in_new_path and need_shell_restart answer the three states a user can be in without modifying anything
- You want the operation to be idempotent, because it reads the target config file and skips writing when the same line is already there
- You need to remove a directory again. There is no remove or undo, in either the API or the CLI, so anything you add stays in the user's config files until they delete the line by hand. For an installer that also has to uninstall, this is a real gap
- You are uncomfortable writing to files you do not own. It appends to .profile, .bash_profile, .zshrc, .zprofile, the fish config and the xonsh config, and a script that runs twice under different shells leaves lines in several of them
- You expect all shells to be covered by default. Only bash and sh are the default set, plus whatever shell it can detect from the parent process, BASH_VERSION or SHELL. A user in an unusual environment gets a partial update and no warning
- You want a maintained dependency: 1.9.2 was released on 2024-02-29 and the repository was last pushed on 2024-06-10, with 18 open issues against 166 stars. It is single-maintainer software in maintenance mode
- Your process is not the user's login session. Nothing here changes the PATH of the running process, and the CLI says so directly: the shell must be restarted for the update to take effect
- You are on a system with a shell it does not know, such as nushell, elvish or a restricted corporate shell, where it will fall back to bash and sh and silently do the wrong thing
Setup reality
pip install userpath is small (click is the only dependency) and the API is four functions, so nothing about getting started is hard. The complexity is in what it does to a machine. On Unix it picks shells in this order: the ones you pass, otherwise the shell it detects from the parent process name, the BASH_VERSION environment variable or SHELL, otherwise bash and sh. Detection through the parent process fails whenever your installer is invoked from a script, a package manager or CI, so passing shells explicitly is the reliable option. Each shell writes different files, and zsh gets both .zshrc and .zprofile, so a single append can touch several files in one call. The write is an append with a comment header, guarded by a check for the same content already being present, which is what makes reruns safe. Verification is not a string check: location_in_new_path actually spawns the shell and reads back the PATH it produces, so passing check=True gives you a real failure instead of a false success, and that also means a slow or interactive shell config makes the call slow. On Windows it reads the user PATH from HKEY_CURRENT_USER Environment, prepends or appends, writes it back as REG_EXPAND_SZ, then broadcasts WM_SETTINGCHANGE, and the shells and all_shells arguments are ignored because the setting is already global. Nothing updates os.environ for the current process, so any code that continues after the call still cannot find the binary. Plan the uninstall story yourself, because the library has none.
Patterns
Append a directory to the user PATHadd-to-path
import userpath
userpath.append('/home/user/.local/bin')The change lands in shell config files and takes effect on the next shell. Nothing about the current process changes, so a subsequent subprocess call still will not find the binary.
Put your directory firstprepend-for-priority
userpath.prepend('/opt/mytool/bin')Prepending wins against a system copy of the same command, which is usually what an installer wants. It also means your directory shadows system binaries with matching names.
Find out which of the three states you are incheck-before-writing
location = '/home/user/.local/bin'
if userpath.in_current_path(location):
print('already active in this shell')
elif userpath.in_new_path(location):
print('configured, needs a shell restart')
else:
userpath.append(location)in_current_path reads the running process PATH, in_new_path spawns the shell and reads what it would produce. They answer different questions and both are needed for a correct message.
Tell the user when a restart is requiredprompt-shell-restart
userpath.append(location, app_name='mytool')
if userpath.need_shell_restart(location):
print('Restart your shell, or run: exec $SHELL')need_shell_restart is true exactly when the config is updated but the current session has not picked it up. Skipping this message is why installers get bug reports saying the command is not found.
Raise instead of returning Falsefail-loudly
try:
userpath.append(location, check=True)
except Exception as exc:
print(f'could not update PATH: {exc}')
raise SystemExit(1)Without check=True the functions return a boolean and a silent False is easy to ignore. With it, the exception message includes the shell command that was run and the PATH it produced.
Say which shells to updatetarget-specific-shells
userpath.append(location, shells=['zsh', 'fish'])The keyword is shells, plural, even though the CLI flag is --shell. Passing it explicitly is safer than relying on detection whenever your installer is not launched directly from the user's shell.
Cover all supported shells at onceupdate-every-shell
userpath.append(location, all_shells=True)Supported shells are sh, bash, zsh, fish and xonsh, so this writes several config files. It is ignored on Windows, where the environment setting is already global.
Add more than one locationadd-multiple-directories
import os
userpath.append(os.pathsep.join(['/opt/mytool/bin', '/opt/mytool/libexec']))The location argument is split on os.pathsep, so several directories go in one call. Each is verified separately when check=True.
Name your application in the config filelabel-the-change
userpath.append('/opt/mytool/bin', app_name='mytool')app_name goes into the comment written above the export line, which is the only trace a user has of who added it. It defaults to userpath, which tells them nothing.
Do it from the command lineuse-cli
userpath append ~/.local/bin
userpath prepend /opt/mytool/bin --shell zsh --shell fish
userpath verify ~/.local/binappend and prepend exit with code 2 when the directory is already present unless you pass -f/--force, so a naive installer script that treats any non-zero exit as failure will report a false error.
Write config for a different home directorytarget-explicit-home
userpath.append('/opt/mytool/bin', shells=['bash'], home='/home/deploy')Useful when a privileged installer sets up PATH for another account. It changes where config files are written but not the ownership of what it creates, so fix permissions afterwards.
Record what you added so you can undo itplan-for-removal
marker = f'# added by mytool: {location}'
state_file.write_text(json.dumps({'path': location, 'marker': marker}))
userpath.append(location, app_name='mytool')There is no remove function anywhere in this library. If your tool has an uninstall command, you have to find and strip the lines yourself, which is why recording the marker at install time matters.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| hatch | PyPI | You want a full Python project and environment manager rather than one PATH helper, and this is one of the pieces underneath it |
| pipx | PyPI | The real goal is installing Python applications onto the user's PATH, which pipx handles end to end including removal |
| shellingham | PyPI | You only need to know which shell the user is running and want to make the PATH decision yourself |