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:callabletarget. - Declare it under PEP 621's
[project.scripts]table:weatherctl = "weatherctl.cli:main". - Installation generates a small executable wrapper on
PATHthat imports the module and calls the function. - The target callable must accept no required arguments — it reads
sys.argvitself (or your framework does). python -m weatherctlis the zero-install alternative; it runsweatherctl/__main__.py.- Read entry points at runtime with
importlib.metadata.entry_points(group=...)— the foundation for plugin systems.
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.
- Left of the colon is a dotted module path. Installation resolves it the same way
importdoes, against the installed package —weatherctl.climeans "theclimodule inside theweatherctlpackage." - 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__, aclick.Group, a Typer app object). - You can drill into nested attributes with dots:
weatherctl.cli:app.runfetchesapp, thenrunfrom 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:
# 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.
Related
- Structuring multi-command Python CLIs — the sub-hub for this topic
- Plugin architectures for extensible CLIs — group entry points in depth
- Poetry workflows for CLI development — declaring scripts under Poetry
- Building a CLI with subcommands in Click — the callable your entry point targets