skip to content

What does a `[project.scripts]` entry in pyproject.toml create when the package is installed?

level: juniorimportance: must knowfreq 60%

answer

  1. Something new appears on your PATH
  2. The installer writes it, not the wheel
  3. Left side is typed, right side imported
  4. name = module colon callable
  5. Return value goes through sys.exit

basics

~20 s

It declares a console command. At install time the installer generates a small wrapper in the environment's script directory that imports the named module and calls the named function, so typing the command runs your code.

solid answer

~40 s

You write `command = "package.module:function"` under `[project.scripts]`. The build backend records it in the wheel's metadata as an entry point in the reserved `console_scripts` group; the wheel holds no executable. The installer then generates a wrapper into the environment's `bin/` (or `Scripts\` on Windows), which is why activating that environment puts the command on `PATH`. The wrapper calls your function **with no arguments** — read `sys.argv` yourself — and wraps the call in `sys.exit`, so the return value becomes the exit status. It imports your module under its real dotted name, so an `if __name__ == "__main__":` block in that module does not run. Because generation happens at install time, adding a new command needs a reinstall even in an editable install.

code

python · 13 lines
python
import sys


def main() -> int:
    if len(sys.argv) < 2:
        print("usage: hooksrv <config>", file=sys.stderr)
        return 2
    print(f"serving with {sys.argv[1]}, name={__name__}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

go deeper

for a junior

Be ready to write the two lines from memory and read them aloud: command name on the left, module:function on the right. Know that the command appears only after installing into the active environment.

for a middle

Explain the mechanics: metadata in the wheel, wrapper generated by the installer, no arguments passed to the target, return value used as the exit status, module not run as __main__.

for a senior

Show you have debugged it in production — a command missing because the wrong environment is active, a stale command after an editable install, a broken shebang in a copied or relocated environment.

for a principal

Own the interface decision: which commands a distribution should expose at all, how their names are namespaced against other tools on a shared PATH, and whether the target stays a thin shim so the real logic remains importable and testable.

### What you are declaring `[project.scripts]` is a table in `pyproject.toml` that maps a **command name** to a **target** written as `import.path:callable`: ```toml [project.scripts] hooksrv = "hooksrv.cli:main" hooksrv-replay = "hooksrv.tools.replay:run" ``` The left side is the name you will type in a shell. The right side is an import path, a colon, and the name of an object inside that module. Nothing on the right is a file path: `hooksrv.cli` must be an importable module of the installed distribution, not `src/hooksrv/cli.py`. ### What actually creates the command The build backend copies this declaration into the wheel's metadata as an ordinary **entry point** in the reserved group `console_scripts`, stored in `<name>-<version>.dist-info/entry_points.txt`. The wheel itself contains **no executable**. The *installer* — pip, uv, or whatever put the wheel into the environment — reads that metadata at install time and generates a small wrapper into the environment's script directory (`bin/` on Unix, `Scripts\` on Windows), then records it so that uninstalling the distribution removes the command again. On Unix the generated wrapper is a tiny Python file whose shebang is an **absolute path to the interpreter of the environment it was installed into**, and whose body is equivalent to: ```python import sys from hooksrv.cli import main if __name__ == "__main__": sys.exit(main()) ``` On Windows the installer writes `hooksrv.exe` — a launcher stub with the same payload appended — because Windows will not honour a shebang. ### The four consequences interviewers probe **1. The target is called with no arguments.** Nothing is passed to `main()`; if you want the command line you read `sys.argv` yourself, usually by handing it to an `argparse` parser. A target declared as `main(argv)` fails at runtime with a `TypeError`, not at install time. **2. The return value becomes the exit status.** The wrapper wraps the call in `sys.exit`, so returning `None` exits 0, returning an int exits with that code, and returning a string prints it to standard error and exits 1. Returning `False` from a "success" path exits 0 by accident, which is a real bug class. **3. The module is imported under its real name, not as `__main__`.** A `if __name__ == "__main__":` block inside `hooksrv/cli.py` therefore does **not** run when the console command is invoked, which surprises people migrating from a module executed with the interpreter's `-m` switch (that execution model belongs to its own topic). Put the work in the callable, and keep the `__main__` guard as a thin extra door. **4. The wrapper is generated at install time and pins one environment.** Because the shebang names an absolute interpreter path, copying or renaming a virtual environment leaves a wrapper pointing at an interpreter that no longer exists; the fix is to recreate and reinstall, not to hand-edit the script. And because generation happens at install, **adding a new command to `pyproject.toml` requires a reinstall** even under an editable install — an editable install makes your *source* edits live, not your *metadata* edits. ### Why this beats the alternatives Before PEP 621 the same thing was written as `entry_points={"console_scripts": [...]}` in `setup.py`; the metadata produced is identical, and the modern table is just the declarative spelling of it. Compared to shipping a hand-written shell script, the generated wrapper is cross-platform, gets the right interpreter automatically, is uninstalled cleanly, and does not require the user to know where your code landed. The related `[project.gui-scripts]` table produces the same kind of command with a windowless launcher on Windows. Finally, note that `console_scripts` is only a *reserved group name* in a general mechanism: the same `entry_points.txt` file can carry arbitrary groups that a host application discovers at runtime for plugins. A console command is simply the case where the "host" is the installer itself. ### The usual failure report "It installed but the command is not found" is almost never a packaging bug. It is the environment: a different virtual environment is active, the install was a user-site install whose script directory is not on `PATH`, or the shell has cached an old lookup. Confirm by looking for the command inside the environment's script directory before touching `pyproject.toml`.

  • Your `[project.scripts]` target is declared as `main(argv)`. When does that break?
    At runtime, on the first invocation, with a `TypeError` for a missing positional argument. The generated wrapper always calls the target with no arguments, and nothing validates the signature at build or install time. Either give the parameter a default and read `sys.argv` when it is absent, or make the entry point a zero-argument shim that parses `sys.argv` and delegates to your testable `main(argv)` function.
  • You edited pyproject.toml to add a second command, but it is still not on PATH after an editable install. Why?
    The wrapper is generated from metadata at install time, and an editable install only keeps your *source* live — the `.dist-info` written when you installed still lists the old command set. Reinstall the project so the backend regenerates the metadata and the installer writes the new wrapper. Source edits to the target function itself take effect immediately; metadata edits never do.
  • Why does copying a virtual environment to another path break its console commands?
    On Unix each generated wrapper begins with a shebang naming the absolute path of the interpreter it was installed against. Copy or rename the environment and that path no longer resolves, so the shell reports a bad interpreter. The supported fix is to recreate the environment and reinstall rather than rewriting shebangs by hand; on Windows the launcher stub embeds the interpreter path the same way.

The table is a work order, not the tool: the wheel carries the instructions, and the installer is what stamps out the little launcher your shell finds.

saying these in an interview costs you the question

  • Thinks the wheel itself contains the executable command
  • Says the right-hand side is a path to a .py file
  • Expects the module's `if __name__ == "__main__"` block to run
  • Expects command-line arguments to be passed into the function
  • Believes editing pyproject.toml adds the command without reinstalling
  • Confuses the command name with the importable package name

context