Add a [project.scripts] table to pyproject.toml that maps a command name to a function in your package. When someone installs your project, the installer creates a command by that name. Use [project.gui-scripts] instead for graphical apps on Windows, where a terminal window would otherwise appear.
The declaration
[project.scripts]
spam-cli = "spam:main_cli"
The Python Packaging User Guide explains the effect. After installing, a spam-cli command is available, and running it does the equivalent of importing main_cli from the spam module and exiting with its return value.
So your function should return an exit code or none, and your command should not depend on being run from the source tree.
Scripts versus gui-scripts
| Table | Windows behaviour |
|---|---|
| project.scripts | Needs a terminal, so a window pops up if launched from a graphical app |
| project.gui-scripts | Does not open a terminal, control returns immediately |
The guide says the difference is only relevant on Windows.
What happens behind the scenes
Scripts are one use of entry points. The entry points specification says console scripts entry points refer to functions, and when pip or another aware installer installs the distribution, it creates a command line wrapper for each one. Entry points live in an entry_points.txt file inside the dist-info directory of the distribution.
An entry point has three required parts: a group, a name and an object reference. For console scripts, the name is the command that launches it. The reference takes the form of an importable module, optionally followed by a colon and an object path.
Plugins use the same mechanism
If your tool can be extended, the guide says to declare plugins in a subtable of [project.entry-points].
[project.entry-points."spam.magical"]
tomatoes = "spam:main_tomatoes"
The spec advises consumers that define a new group to start the name with a PyPI name they own followed by a dot, to avoid clashes.
Packaging checklist for a CLI
- Pick a command name unlikely to collide with common commands.
- Keep the function small and import heavy modules inside it, so help output is fast.
- Add a
__main__.pysopython -m yourpackageworks too. The packaging guide's CLI example does this. - Use the src layout, then test the installed command, not the source tree. See src layout versus flat layout.
- Build, install the wheel in a clean environment and run the command.
- Document the install command. The packaging guide's example uses pipx, which creates an isolated environment for command line tools.
Security points
- Name collisions. Two distributions can provide the same command name. The entry points spec says the consumer decides how to handle conflicts, so choose a distinctive name.
- Install time code. The wrapper runs your function with the user's privileges. Treat anything it reads from the network or files as untrusted.
- Extras on entry points are no longer recommended, so avoid them.
Safeguard's SBOM and dependency scanning list what your tool pulls in. A free scan can show it.
Quick check
Do I need setup.py for console scripts?
No. The project.scripts table in pyproject.toml is enough, and the installer generates the wrapper.
When do I use gui-scripts?
For graphical applications on Windows, so no terminal window opens. On other platforms the two tables behave alike.
What if two packages define the same command?
The entry points specification leaves conflict handling to the consumer. Pick a distinctive command name.
Sources
- Writing your pyproject.toml, Python Packaging User Guide
- Entry points specification, Python Packaging User Guide
- Creating and packaging command-line tools, Python Packaging User Guide
Last reviewed: 19 October 2026.