mrkeyoor.com_
Sat 08 Aug 20:58 UTC
PyPIUtilsupdated 08 Aug 2026

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.

Verdict

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.

API stability5/5Four public functions (append, prepend, in_current_path, in_new_path, plus need_shell_restart) and three CLI subcommands, unchanged across the 1.x line. The one keyword worth noting is that the parameter is shells, plural, taking a sequence, even though the CLI flag is -s/--shell. Code written against 1.8 runs unchanged on 1.9.2.
Docs2/5The README is a short install section, a paste of the CLI help output and a five-line Python session. Nothing documents which files each shell writes, what the shells and home arguments do, why check=True matters, or that removal does not exist. Anything beyond the first call means reading interface.py and shells.py.
Maintenance2/5Version 1.9.2 was released on 2024-02-29 and the repository was last pushed on 2024-06-10, so it has been quiet for over two years, with 18 open issues and pull requests across 14 releases. It is a single-maintainer utility that mostly works, but shell landscapes do change and there is nobody actively tracking that.
Ecosystem3/5Roughly 6,405,357 weekly downloads against only 166 stars, which is the clearest possible sign that it arrives as a transitive dependency of installer tooling rather than being chosen. That gives it real deployment coverage and testing by accident, but there is no plugin surface, no shell contribution process and almost no community around it.

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
Skip it if

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/bin

append 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

PackageRegistryPick it when
hatchPyPIYou want a full Python project and environment manager rather than one PATH helper, and this is one of the pieces underneath it
pipxPyPIThe real goal is installing Python applications onto the user's PATH, which pipx handles end to end including removal
shellinghamPyPIYou only need to know which shell the user is running and want to make the PATH decision yourself