Safeguard
Developer Tools•

How do you ship a command line tool with project.scripts in pyproject?

Declare a command under project.scripts, point it at a function, and installers create the wrapper. Covers gui-scripts, plugins and a release checklist.

Safeguard Security Team
4 min read

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

TableWindows behaviour
project.scriptsNeeds a terminal, so a window pops up if launched from a graphical app
project.gui-scriptsDoes 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

  1. Pick a command name unlikely to collide with common commands.
  2. Keep the function small and import heavy modules inside it, so help output is fast.
  3. Add a __main__.py so python -m yourpackage works too. The packaging guide's CLI example does this.
  4. Use the src layout, then test the installed command, not the source tree. See src layout versus flat layout.
  5. Build, install the wheel in a clean environment and run the command.
  6. 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

Last reviewed: 19 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.