Skip to content
← All articles

Entry points, console_scripts and __main__.py

A CLI whose logic lives inside `if __name__ == "__main__":` cannot be unit-tested, reused or exposed as a console script — and returning a str from main() exits non-zero on success, which every CI system reads as failure.

if __name__ == "__main__":
    args = parse_args(sys.argv[1:])
    config = load(args.config)
    for row in read(config):
        process(row)
    print("done")

Everything this program does lives in a block that only executes when the file is run directly. So: no test can call it, no other module can reuse it, and there is nothing for a [project.scripts] entry to point at. The refactor is one line — move the body into def main(argv: Sequence[str]) -> int: — and it unlocks all three.

The entry-point model

An entry point is a (group, name, object reference) triple, recorded in your distribution’s metadata at build time and readable by anything at runtime.

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

[project.gui-scripts]
pricing-ui = "pricing.ui:launch"

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

The object reference is importable.module:object.attr — a module path, an optional colon, then a dotted attribute path within it. pricing.cli:main is the function main in module pricing.cli; pkg.mod:Class.method reaches a method; a bare pkg.mod refers to the module itself.

[project.scripts] becomes the console_scripts group. The installer generates a small wrapper executable in the environment’s bin/ (or Scripts/) which imports your module, calls the object, and exits with its return value. There is no shebang in your source, no chmod +x, and no python -m for the user to remember.

[project.gui-scripts] exists for one platform difference: on Windows, a console_scripts wrapper is built against python.exe and opens a console window; a gui_scripts wrapper is built against pythonw.exe and does not. On Linux and macOS the two are effectively the same.

Custom groups are the plugin mechanism. Your application reads them back at runtime:

from importlib.metadata import entry_points

for ep in entry_points(group="pricing.backends"):
    backend_class = ep.load()

Use importlib.metadata. pkg_resources — the old way — is removed, and it was also slow, because it scanned and parsed metadata for every installed distribution at import.

💡Your plugin system does for ep in entry_points(group="myapp.plugins"): ep.load() at startup. What is the cost, and when would you avoid .load()? click to reveal

.load() imports the module the entry point names. So a startup loop that loads every plugin pays the full import cost of every installed plugin — including the ones this run will never use — plus each plugin’s own dependencies.

The entry-point metadata is cheap: ep.name, ep.group and ep.value are read from the distribution’s metadata files without importing anything. So the pattern that scales is to enumerate names eagerly and load lazily:

plugins = {ep.name: ep for ep in entry_points(group="myapp.plugins")}
backend = plugins[config.backend].load()      # exactly one import

A CLI that lists available backends can now do so without importing any of them, and starting the app costs one plugin import rather than nine. That difference is the whole reason --help in some tools takes two seconds.

What main() must return

def main(argv: Sequence[str] | None = None) -> int:
    ...
    return 0

The generated wrapper does the equivalent of sys.exit(main()), and sys.exit has a specific contract:

  • None or 0 → exit status 0, success.
  • any other int → that exit status.
  • any other object, including a str → the object is printed to stderr and the exit status is 1.

So return "done" prints done to stderr and exits 1. Every CI system, every shell &&, every Kubernetes readiness check reads that as failure. The bug is invisible locally, because the message looks like success.

Annotate main as returning int (or int | None) and let mypy hold you to it. This is a genuine case where the annotation prevents a production defect rather than documenting one.

__main__.py

A package with __main__.py can be run as python -m yourpkg:

# yourpkg/__main__.py
import sys

from yourpkg.cli import main

sys.exit(main(sys.argv[1:]))

Note what is not there: an if __name__ == "__main__": guard. __main__.py exists precisely to be run as __main__, and it is not meaningfully importable under any other name — so the guard is noise that suggests a second mode of use that does not exist.

Ship both a [project.scripts] entry and a __main__.py, both delegating to the same main(). The console script is what users type; python -m is what works when bin/ is not on PATH, which is more often than you would think.

💡python -m yourpkg works and the installed yourpkg command is not found. What are the two likely causes? click to reveal

The environment’s script directory is not on PATH. python -m needs only an importable package; the console script needs the generated wrapper in bin/ (or Scripts/) to be findable. A user-site install, an unactivated virtualenv, or a pip install --user on a system whose ~/.local/bin is not on PATH all produce exactly this.

The package is importable without being installed. If you are sitting in the project root with a flat layout, python -m yourpkg resolves through the working directory — no installation required, no wrapper generated. The command does not exist because the distribution was never installed at all.

Distinguish them in one step: python -c "import yourpkg; print(yourpkg.__file__)". A path inside site-packages means it is installed and the problem is PATH; a path inside your working tree means it is not installed and python -m was reading your source directory.