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:
-
Noneor0→ 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.