Skip to content
← All Python articles

Python explainer

Turn a Function into a Command

About 4 min to read. Allow extra time to work through examples.

Put a command-line tool's work in a main function you can test, publish it as a command with [project.scripts], run it with python -m, and list plugins without importing them.

This article uses Python. You can make it your practice mode to browse related content across the site.

You have a script that you run as python pricing/cli.py Ada. You want users to type pricing Ada after they install your package, and you want tests to call the code without starting a new process. Both work when the script's work lives in a main function. Put this in pricing/cli.py:

import argparse
from collections.abc import Sequence

def main(argv: Sequence[str] | None = None) -> int:
    parser = argparse.ArgumentParser(prog="pricing")
    parser.add_argument("name")
    args = parser.parse_args(argv)
    print(f"Hello, {args.name}!")
    return 0

A test calls main(["Ada"]). A call with no arguments, main(), makes argparse read the command line instead. The installed command calls main() with no arguments, which is why argv defaults to None.

Publish the function as a command

Add this table to pyproject.toml:

[project.scripts]
pricing = "pricing.cli:main"

Each line is an entry point, a name in the package's metadata that points at a function. The part before the colon is the module, and the part after it is the function. When pip installs the package, it writes a small pricing program into the environment's scripts folder, bin on Linux and macOS or Scripts on Windows. That program imports pricing.cli and calls main(). The folder must be on your shell's PATH. Activating the virtual environment puts it there.

To support python -m pricing Ada too, add pricing/__main__.py:

from pricing.cli import main

if __name__ == "__main__":
    raise SystemExit(main())

python -m pricing runs this file. The guard matters because other code can import the file as pricing.__main__, and the import alone should not start the tool. See Python's __main__ documentation.

Return an exit status

The installed command runs the equivalent of sys.exit(main()). A return value of 0 or None means success. Return a small nonzero number, such as 1 or 2, for failure.

Do not return a string. sys.exit prints a string to standard error and exits with status 1, so a return "done" turns a success into a failure. The -> int annotation lets mypy catch that mistake. See sys.exit.

List plugins without loading them

An entry point belongs to a group. [project.scripts] fills the console_scripts group, and you can define your own group for plugins:

[project.entry-points."pricing.backends"]
sqlite = "pricing.adapters.sqlite:SqliteBackend"

Any installed package can add entries to pricing.backends. Your application reads them with importlib.metadata. This snippet lists the commands installed in your current environment:

from importlib.metadata import entry_points

for ep in entry_points(group="console_scripts"):
    print(ep.name, "->", ep.value)

Listing reads only the metadata. ep.load() imports the module, which runs its top-level code, and then returns the object.

Check your understanding Why list a group's plugins first and load only the one you need? Show answer

Loading a plugin imports its module, which runs its top-level code and imports its dependencies. If the user picks one backend, loading all of them wastes startup time and can fail on a dependency that only an unused plugin needs. Two packages can also register the same name, so decide what happens then instead of letting the second entry silently replace the first.

Find a missing command

Check your understanding python -m pricing works, but the shell cannot find pricing. What should you check? Show answer

Check that pyproject.toml declares the script, and that the package is installed in the environment you think it is. Look for the pricing program in that environment's scripts folder, then check that the folder is on your PATH. Also check that python and pip belong to the same environment, with python -m pip --version.

The module path from python -c "import pricing; print(pricing.__file__)" shows which code runs, but not whether the package is installed. A checkout can be importable without an install, and an editable install points back at the checkout on purpose.

Going further

The entry points specification also defines gui_scripts. On Windows, a GUI script starts without a console window, so it has no standard streams unless the program sets them up. On other platforms, gui_scripts and console_scripts behave the same.

step 32 of 41

Modules, Packaging and Tooling

Up next Parse an Entry-Point Reference