How to Package a Python CLI and Install It With pipx
Package a Python command line tool with pyproject.toml and a console script entry point, install it with pipx or uv tool, upgrade it and build for others.

A script in a folder works for its author. A command that others can install, upgrade and remove needs a package. The modern recipe is short: a pyproject.toml, a function to call, and an installer that keeps each tool in its own environment so that two tools never fight over versions. This is the next step after an executable script. The commands assume Python 3.13, pipx 1.7 and hatchling.
The layout
greet/
pyproject.toml
src/greet/__init__.py
src/greet/cli.py
import argparse
def main() -> int:
parser = argparse.ArgumentParser(prog="greet")
parser.add_argument("name")
args = parser.parse_args()
print(f"hello {args.name}")
return 0[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "greet"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
greet = "greet.cli:main"The src layout keeps the package from being imported accidentally from the working directory, which hides packaging mistakes. The entry point greet = "greet.cli:main" means "create a command named greet that calls main in greet.cli and exits with its return value". See argparse for the parsing side.
Install with pipx
pipx install --editable .
greet world
pipx listhello world
package greet 0.1.0, installed using Python 3.13.0
- greet
pipx creates a private virtual environment under ~/.local/share/pipx/venvs/greet and places a launcher in ~/.local/bin. If the command is not found, run pipx ensurepath and open a new shell. With --editable, edits to the source take effect immediately, so you can iterate on the real installed command. Drop the flag for a normal install.
The uv equivalent
uv tool install .
uv tool upgrade greet
uv tool uninstall greetIt does the same job with the same isolation, and installs faster. Choose either; they place launchers in the same directory and can coexist. Installing from a Git URL also works: pipx install git+https://github.com/example/greet.
Build and publish
python3 -m pip install build
python3 -m build
ls dist/greet-0.1.0-py3-none-any.whl greet-0.1.0.tar.gz
The wheel is the installable artifact. Anyone can run pipx install ./dist/greet-0.1.0-py3-none-any.whl, or you can upload it with twine to an index. Bump the version for each release and install from the wheel on a clean machine as a final test, since an editable install can hide files that are missing from the build.
Where this does not apply
A one-file internal script does not need packaging; the inline dependency approach in PEP 723 scripts is lighter. For users without Python, a compiled tool such as one written in Go avoids the interpreter entirely; see replacing a shell script with Go. Tools with system dependencies need documentation for those, since pip cannot install them.
Before announcing the tool, run it from a fresh shell with env -i and a minimal PATH to confirm that nothing depends on your own environment. A command that works only because of an exported variable in your profile is not yet a command someone else can install.
How an entry point becomes a command
The [project.scripts] table in pyproject.toml maps a command name to a function, written as name = "package.module:function". When the project is installed, the installer writes a small launcher script into the environment's bin directory, or a Scripts executable on Windows, that imports the module and calls the function. The launcher carries a shebang pointing at the environment's own interpreter, so the command runs with that environment's packages whatever the user's PATH says. This is the reason to use entry points and not a loose script in a folder: they behave the same on Linux, macOS and Windows, they find the right interpreter, and they are the standard that every installer understands. The function's return value becomes the exit status through sys.exit, so return an integer or call sys.exit yourself for a nonzero code on failure.
Keep the entry point function thin. Parse arguments, call a function that does the work and return a status. That keeps the logic testable without launching a process, and lets other code import it.
Why pipx and uv tool exist
Installing a command-line tool into the system or user site with pip mixes its dependencies with everything else, and two tools that need different versions of the same library will fight. pipx and uv tool install each application into its own virtual environment and link only the commands onto your PATH. Upgrades and removals are per tool, and one tool's dependencies cannot break another. Both also offer a run mode, pipx run and uvx, which fetches the tool into a temporary environment, runs it and keeps it cached for next time, which suits occasional tools. Run pipx ensurepath once, so the directory with the links is on your PATH, and open a new shell. Distributions that block system-wide pip installs, under the rule known as an externally managed environment, point to pipx for the same reason.
Know the limit: these tools are for applications, not libraries. If you need to import a package in your own code, install it into the project's environment instead.
Building and publishing without surprises
Build a source distribution and a wheel with python -m build or uv build, then install the wheel in a clean environment and run the command before you publish anything. That test catches missing files, a wrong entry point string and undeclared dependencies, because a clean environment has nothing extra to hide them. Declare dependencies with sensible lower bounds in dependencies, and avoid tight upper bounds unless you know of a break, since they make your tool impossible to install next to others. Publish to the index with a token scoped to the project, or with trusted publishing from CI, which uses short-lived credentials and avoids storing a token. Test the release first on the test index. A published version cannot be replaced, only yanked, so the name and version are permanent.
Testing the installed command
A command that works from the source tree can fail once installed, because the source tree hides packaging mistakes. Install the built wheel into a fresh environment with pipx install dist/name-1.0-py3-none-any.whl and run the command from a different directory. Check the version output, the help text and one real operation. Look at the installed files with pip show -f, which lists what the wheel put on disk, and confirm that data files such as templates are present, since files not declared in the build configuration are left out of the wheel. Add a smoke test to CI that does the same on every change, on each operating system you support. The first time you meet a missing data file should not be in a bug report from a user.
Plan for upgrades. Use pipx upgrade or uv tool upgrade to move to a new release, and follow semantic versioning so users can tell a patch from a breaking change.
Document the install line at the top of the readme, with the exact command, and keep a changelog entry per release. Users decide whether to upgrade by reading it.
See the pyproject.toml guide and the pipx documentation.


