Architecture

Best practices for Python CLI entry points

Configure Python CLI entry points in pyproject.toml using PEP 621 standards for deterministic cross-environment installation and execution.

Updated

When you pip install a tool and then type weatherctl at the shell, something has to bridge the gap between that bare command and the Python function it runs. That bridge is a console-script entry point, declared in pyproject.toml. Get the declaration right and your CLI installs cleanly into any environment — venv, pipx, CI, a colleague's machine — with the command landing on PATH automatically. Get it wrong and you ship a package nobody can launch.

TL;DR

  • A console-script entry point maps a command name to a module:callable target.
  • Declare it under PEP 621's [project.scripts] table: weatherctl = "weatherctl.cli:main".
  • Installation generates a small executable wrapper on PATH that imports the module and calls the function.
  • The target callable must accept no required arguments — it reads sys.argv itself (or your framework does).
  • python -m weatherctl is the zero-install alternative; it runs weatherctl/__main__.py.
  • Read entry points at runtime with importlib.metadata.entry_points(group=...) — the foundation for plugin systems.
How a console-script entry point resolves How a console-script entry point resolves pyproject.toml [project.scripts] mytool = "myapp.cli:main" pip install console script on PATH (mytool) runs myapp.cli:main() no-argument callable pip or uv writes the wrapper at install time; it imports the module and calls the target on first run

The [project.scripts] table

PEP 621 standardized project metadata in pyproject.toml, and console scripts live in a dedicated table. The key is the command name users will type; the value is a package.module:function string pointing at the callable to invoke.

[project]
name = "weatherctl"
version = "1.2.0"
requires-python = ">=3.10"

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

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

This block is build-backend agnostic: hatchling, setuptools (≥61), flit, and pdm all read [project.scripts] the same way because it is part of the interoperable standard, not a backend extension. If you use Poetry, the modern poetry-core backend reads [project.scripts] too — see Poetry workflows for CLI development for the full Poetry setup. You can declare multiple commands; each line adds another executable:

[project.scripts]
weatherctl = "weatherctl.cli:main"
weatherctl-admin = "weatherctl.admin:main"

How installation creates the wrapper

When the package is installed, the build backend reads [project.scripts] and writes a tiny launcher script into the environment's bin/ directory (Scripts\ on Windows). On POSIX systems it is a short Python file with a shebang pointing at the environment's interpreter; on Windows it is a generated .exe shim. Both do the same thing — roughly:

import sys
from weatherctl.cli import main
if __name__ == "__main__":
    sys.exit(main())

Because that script lives in the same bin/ directory as the interpreter, activating the venv (or installing via pipx, which manages PATH for you) makes weatherctl directly callable. The wrapper also captures the function's return value and passes it to sys.exit(), which is why returning an int status code from main() is the clean way to set the process exit code.

The module:callable resolution rules

The value string has two halves separated by a colon: an import path on the left, an attribute lookup on the right.

Reading a module:callable reference A table of console script target forms: a module level function, a nested attribute, a Click group object, and the forms that do not work. Reading a module:callable reference Target Means Valid? myapp.cli:main function main in myapp/cli.py yes myapp.cli:app a Typer/Click object that is callable yes myapp.cli:Admin.run an attribute chain after the colon yes myapp/cli.py:main a file path, not an import path no myapp.cli.main no colon — the module is ambiguous no Everything left of the colon is imported; everything right of it is looked up with getattr and then called with no arguments.
  • Left of the colon is a dotted module path. Installation resolves it the same way import does, against the installed package — weatherctl.cli means "the cli module inside the weatherctl package."
  • Right of the colon is the attribute to fetch from that module. It is usually a function, but any callable works (a class with __call__, a click.Group, a Typer app object).
  • You can drill into nested attributes with dots: weatherctl.cli:app.run fetches app, then run from it.

The target is resolved lazily — only when the command runs, not at install time. A typo in the path therefore surfaces as an ImportError/AttributeError the first time someone runs the command, not during pip install. Test the actual installed command in CI, not just the import.

Here is a target callable that satisfies the contract. Save it as weatherctl/cli.py:

import sys

def main() -> int:
    """The console-script target: takes no required arguments."""
    args = sys.argv[1:]
    if not args:
        print("weatherctl: no city given")
        return 1
    print(f"weatherctl: forecast for {args[0]}")
    return 0

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

Running it directly behaves exactly as the installed wrapper would:

$ python cli.py Berlin
weatherctl: forecast for Berlin    # exit 0
$ python cli.py
weatherctl: no city given          # exit 1

In practice you rarely parse sys.argv by hand — you hand off to a framework. With Click, the target points at the group object, which is itself callable; see Building a CLI with subcommands in Click for the multi-command pattern. The entry-point contract is the same regardless: a no-argument callable.

python -m package as an alternative

Every entry point can be paralleled by a module runner. If your package contains a __main__.py, then python -m weatherctl executes it:

Console script or python -m? A comparison of shipping a console script entry point versus relying on python -m package, across discoverability, PATH, and which interpreter runs. Console script or python -m? Concern console script python -m mytool How users type it mytool sync python -m mytool sync Needs PATH set up yes no Which interpreter runs the install-time one whichever python you typed Works before install no yes, from the source tree Best for end users CI and debugging Ship both: __main__.py costs three lines and rescues anyone whose PATH is not set up.
# weatherctl/__main__.py
from weatherctl.cli import main
raise SystemExit(main())

This route needs no installed wrapper and no PATH entry — it works from a source checkout, inside Docker before the package is "installed," and pins execution to a specific interpreter (python3.12 -m weatherctl). Offering both a console script and __main__.py is a low-cost belt-and-braces move: the script for everyday use, the module form for reproducible or ambiguous-PATH situations.

Plugin/group entry points

[project.scripts] is sugar over a more general mechanism: entry points belong to named groups, and console_scripts is just one well-known group the installer treats specially. You can publish your own groups so that other packages can register extensions against your CLI.

[project.entry-points."weatherctl.plugins"]
celsius = "weatherctl_celsius.plugin:register"
fahrenheit = "weatherctl_fahrenheit.plugin:register"

A third-party package adds the same table for its own name, and your application discovers all of them at runtime — no central registry, no import-time coupling. This is the backbone of an extensible CLI; the full pattern (loading, validation, version skew) lives in Plugin architectures for extensible CLIs.

Reading entry points at runtime

To discover registered plugins, query the group by name with importlib.metadata from the standard library. Each EntryPoint exposes .name, .value, .group, and a .load() method that performs the module:callable import and returns the object:

from importlib.metadata import entry_points

# Your own plugin group — returns an EntryPoints collection.
plugins = entry_points(group="weatherctl.plugins")
for ep in plugins:
    register = ep.load()   # imports the module, returns the callable
    register(app)          # hand your app object to the plugin

# Console scripts use the special "console_scripts" group.
for ep in entry_points(group="console_scripts"):
    print(ep.name, "->", ep.value)

The entry_points(group=...) keyword form is the modern, fast API (Python 3.10+). It avoids loading every distribution's metadata when you only care about one group. The collection returned is lazy and filterable, so iterating a single group is cheap even in a crowded environment.

Validated against the real interpreter, listing the console_scripts group and loading one entry shows the round-trip working:

>>> from importlib.metadata import entry_points
>>> scripts = list(entry_points(group="console_scripts"))
>>> [ep.name for ep in scripts]
['markdown-it', 'py.test', 'pytest', 'pygmentize', 'typer']
>>> ep = scripts[0]
>>> ep.name, ep.value, ep.group
('markdown-it', 'markdown_it.cli.parse:main', 'console_scripts')
>>> fn = ep.load(); callable(fn)
True

ep.value is the verbatim module:callable string you wrote in pyproject.toml, and ep.load() returns the imported callable — confirming the on-disk metadata is exactly your declaration.

Common pitfalls

The target must take no required arguments. The generated wrapper calls main() with nothing. If your function signature is def main(config_path), the command crashes with a TypeError the moment it runs. Read inputs from sys.argv, environment, or let Click/Typer parse them — never from positional parameters on the target.

src layout discovery. If you use the recommended src/ layout, the package lives at src/weatherctl/, but the entry-point value is still weatherctl.cli:main — the value references the importable name, not the on-disk path. Make sure the backend is configured to find it (hatchling auto-detects src/; setuptools may need [tool.setuptools.packages.find] with where = ["src"]). A missing discovery config produces a package that installs but whose entry point fails to import. The large-project structure guide covers the layout in depth.

Editable installs need a re-install when scripts change. pip install -e . links your source so code edits take effect immediately — but the wrapper script itself is generated at install time. If you add, rename, or remove a [project.scripts] entry, the existing wrapper won't update; re-run pip install -e . to regenerate it. Likewise, entry-point metadata for a new plugin group only becomes visible after the providing package is (re)installed, because importlib.metadata reads the distribution's recorded metadata, not your live source.

Don't put logic in the wrapper. Keep main() thin and importable. Tests should import and call main() (or your Click/Typer object) directly; only the end-to-end smoke test should shell out to the installed command.

Frequently asked questions

Why does my command disappear after I edit pyproject.toml?

Because the console script is generated at install time, not read at run time. The installer reads project.scripts once and writes a small launcher into the environment's bin directory; editing the table afterwards changes nothing until you re-install. During development an editable install means you rarely notice, right up until you rename a command.

Should the entry point target a function or the Typer/Click object?

Either works, as long as the target is callable with no arguments. Pointing at the app object (myapp.cli:app) is the shortest path and is what Typer's own documentation shows; pointing at a main() wrapper gives you somewhere to put the error boundary, the logging setup and the sys.exit call, which is usually worth the extra three lines.

Do I still need a main.py if I ship a console script?

It is three lines and it rescues anyone whose PATH is not set up, which on Windows and in some CI images is a surprising number of people. python -m mytool also removes all doubt about which interpreter is running your code, so it is the form worth putting in bug-report instructions.

Why does my entry point work locally but not after installing the wheel?

Almost always because the module it names is not actually in the wheel. A src layout with a mis-declared package discovery, a missing init.py, or a package excluded by the build backend all produce the same symptom: an install that succeeds and a command that dies with ModuleNotFoundError. Install the built wheel into an empty virtual environment and run the command once — that single smoke test catches it every time.

Can two packages provide the same command name?

Nothing stops them, and the result is whichever was installed last winning the file on PATH. This is a good argument for installing end-user tools with pipx or uv tool, which give each tool its own environment and its own shim, and for choosing a command name distinctive enough that the collision never comes up.