skip to content

Distribution and Library Layout

Turning source files into an installable artifact: project metadata, build backends, wheels, console scripts, and the public surface a library commits to. It separates library authors from script writers.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

page 1 of 2

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

open as a page

What does `pip install mypkg[redis]` do, and where is that extra declared?

level: juniorimportance: must knowfreq 55%

basics

~20 s

It installs mypkg itself plus the optional dependency set named redis. That set is declared in the project's pyproject.toml under the [project.optional-dependencies] table, where each key is an extra name and its value is a list of requirement strings.

open as a page

Why does pip compile a package with a C extension from source, and what does that build need?

level: juniorimportance: must knowfreq 55%

basics

~20 s

Because no prebuilt wheel matched this machine's interpreter version, ABI and platform, pip fell back to the source archive. Compiling it needs a C compiler and linker, CPython's development headers (Python.h), and the project's declared build backend.

open as a page

How do you ship a JSON schema file inside your Python package's wheel?

level: juniorimportance: must knowfreq 55%

basics

~20 s

Put the file inside the importable package directory and declare it as package data in your build backend's configuration. Setuptools needs a package-data entry, or include-package-data together with MANIFEST.in; hatchling and flit ship non-code files under the package by default.

open as a page

What does a single leading underscore on a Python module or class attribute name mean?

level: juniorimportance: must knowfreq 62%

basics

~20 s

A single leading underscore marks a name as internal: not part of the public API and free to change without notice. It is a convention, not enforcement — outside code can still import and call it.

open as a page

How do you publish a Python package to PyPI using python -m build and twine?

level: juniorimportance: must knowfreq 45%

basics

~20 s

Build the artifacts from a clean tree with python -m build, check them with twine check dist/*, rehearse on TestPyPI, then run twine upload dist/* against PyPI, authenticating with an API token or, from CI, trusted publishing.

open as a page

What does the [project] table in a pyproject.toml file declare?

level: juniorimportance: must knowfreq 62%

basics

~20 s

The [project] table holds a package's standardised static metadata: distribution name, version, requires-python, runtime dependencies, readme, license, classifiers and URLs. The build backend copies it into the built artifact's metadata. It says what the package is, not how it is built.

open as a page

What does the py.typed marker file in a Python package do?

level: juniorimportance: must knowfreq 45%

basics

~20 s

py.typed is an empty marker file shipped inside a package directory. Under PEP 561 it tells type checkers the package's inline annotations are meant to be used; without it, a checker ignores them and treats the package as untyped.

open as a page

What is the difference between a Python sdist (.tar.gz) and a wheel (.whl)?

level: juniorimportance: must knowfreq 65%

basics

~20 s

An sdist is a source tarball that has to be built on the installing machine; a wheel is a pre-built zip that the installer only unpacks into site-packages. Wheels install in seconds and need no compiler.

open as a page

How does importlib.metadata.entry_points() let one distribution discover another's plugins?

level: middleimportance: must knowfreq 40%

basics

~10 s

Each installed distribution can advertise name-to-module:object records under a group name in its metadata. entry_points(group="yourapp.plugins") reads those records from every installed distribution without importing them, and EntryPoint.load() imports one on demand.

open as a page

Why does a data-file path built from __file__ break under a zip import?

level: middleimportance: must knowfreq 50%

basics

~20 s

Under a zip import, file points inside the archive, not at a real file, so opening a path derived from it fails with an OS error. Read the resource through importlib.resources.files(), which asks the module's loader instead of the filesystem.

open as a page

In the wheel filename app-2.1-cp314-cp314-manylinux_2_28_x86_64.whl, what do the last three fields mean?

level: middleimportance: must knowfreq 55%

basics

~20 s

They are the wheel's compatibility tags: cp314 is the interpreter tag (CPython 3.14), the second cp314 is the ABI it was compiled against, and manylinux_2_28_x86_64 is the platform. An installer takes the wheel only when all three match.

open as a page

What does a module's `__all__` list actually control in Python?

level: middleimportance: must knowfreq 55%

basics

~20 s

__all__ is a list of strings naming what from module import * binds. It hides nothing: direct attribute access and explicit imports still reach every name. Its real job is declaring the module's intended public API.

open as a page

What does the [build-system] table in pyproject.toml tell a build frontend?

level: middleimportance: must knowfreq 55%

basics

~10 s

It names who builds the project. requires lists build-time dependencies a frontend installs into a temporary environment; build-backend is the importable object the frontend calls to produce a wheel or a source distribution.

open as a page

In a flat-layout Python repo, why does an import at the repo root find the working copy instead of the installed distribution?

level: middleimportance: must knowfreq 48%

basics

~20 s

CPython prepends one entry to sys.path at start-up: the script's directory, or the working directory for python -m and python -c. In a flat layout the package sits there, so it is found before site-packages and shadows the installed copy.

open as a page

How do you choose between a zipapp, an interpreter-carrying bundle, and an image for a Python service?

level: principalimportance: must knowfreq 45%

basics

~20 s

Start from what the target machine guarantees. If a compatible Python is guaranteed, a zipapp or vendored directory is the smallest thing that works. If it is not, the interpreter must travel — in a bundle or an image carrying the environment.

open as a page

Why does pip sometimes compile a package from source instead of installing a prebuilt wheel?

level: juniorimportance: should knowfreq 50%

basics

~20 s

Because no published wheel's tags match this interpreter, ABI and platform, so pip falls back to the source distribution and builds it. Usual causes: a brand-new Python release, an unusual CPU or C library, or a project shipping only an sdist.

open as a page

What is the difference between a src layout and a flat layout in a Python project?

level: juniorimportance: should knowfreq 40%

basics

~20 s

A flat layout keeps the importable package directory at the repository root, beside pyproject.toml. A src layout moves that directory under src/, so nothing at the root is importable and the code must be installed before it can be imported.

open as a page

What does pip install --target do, and how do those packages end up on sys.path?

level: middleimportance: should knowfreq 30%

basics

~10 s

pip install --target DIR unpacks distributions into DIR instead of an environment's site-packages. Nothing adds DIR to sys.path for you: the program supplies it through PYTHONPATH or an explicit sys.path insertion.

open as a page

Why must a C extension's compile-time dependencies be listed in [build-system] requires in pyproject.toml?

level: middleimportance: should knowfreq 35%

basics

~20 s

A PEP 517 build runs in an isolated environment holding only what the build-system requires list names, so anything the compile step needs — the backend, a distribution shipping C headers, a code generator — must be listed there. Runtime dependencies are absent.

open as a page

Why does a file listed in MANIFEST.in appear in the sdist but not the wheel?

level: middleimportance: should knowfreq 35%

basics

~20 s

MANIFEST.in tells setuptools which extra files to add to the source distribution. The wheel is built from package-data rules instead, so unless include-package-data is enabled and the file sits inside a package directory, the wheel leaves it out.

open as a page

Why read a package version with `importlib.metadata.version()` rather than `__version__`?

level: middleimportance: should knowfreq 40%

basics

~20 s

importlib.metadata.version() reads the version recorded in the installed distribution's metadata — the same number the installer and resolvers see. A __version__ attribute is only a convention: it may be missing, and it can drift from what is actually installed.

open as a page

Why is PyPI trusted publishing preferred over a long-lived API token in CI?

level: middleimportance: should knowfreq 35%

basics

~20 s

Trusted publishing has the CI job prove its identity with a short-lived OIDC token and exchange it for an upload token valid for minutes and scoped to one project. No long-lived secret is stored, so nothing can leak or go unrotated.

open as a page

How do you migrate a legacy setup.py/setup.cfg project to pyproject.toml?

level: middleimportance: should knowfreq 38%

basics

~20 s

Add a [build-system] table, move setup.cfg metadata and options into [project] (install_requires becomes dependencies, python_requires becomes requires-python), move package discovery into the backend's tool table, and delete setup.py unless you still need imperative build logic.

open as a page

Under a src layout, how does automatic package discovery differ from a flat layout's?

level: middleimportance: should knowfreq 26%

basics

~20 s

Under a src layout the backend has one unambiguous root: everything under src/ is package content. A flat layout makes it guess among the repository's top-level directories, excluding tests and docs by name, and fail when several look equally plausible.

open as a page

Why does a built wheel sometimes omit the py.typed marker file?

level: middleimportance: should knowfreq 35%

basics

~20 s

A build backend decides which non-Python files enter the wheel, and some copy only .py files unless the rest are declared as package data. The marker then exists in your repo but not in the installed package, so consumers see an untyped dependency.

open as a page

Should a project publish an sdist alongside its wheels, or are wheels enough?

level: middleimportance: should knowfreq 30%

basics

~20 s

Publish both. Wheels cover the targets you actually built for; the sdist is the fallback for everything else and the source of record for auditors, distribution packagers and anyone who needs to patch or rebuild your code.

open as a page

What does `python -m build` produce in dist/, and how is the wheel built?

level: middleimportance: should knowfreq 40%

basics

~20 s

It writes two artefacts into dist/: a source tarball and a wheel. By default it builds the sdist first, unpacks it, and builds the wheel from that unpacked copy rather than from your working tree.

open as a page

An image-thumbnail worker ships as a self-contained bundle carrying the interpreter; it runs on the build machine but dies on the deploy host. How do you diagnose it?

level: seniorimportance: should knowfreq 32%

basics

~20 s

Run the artifact in the foreground on the failing host and read the real error first. The usual causes are a module the build's static scan never saw, uncollected data or metadata, a C library or architecture mismatch, and processes racing over one unpack directory.

open as a page

Your webhook receiver calls entry_points(group=...) at startup and one plugin's import raises — how do you keep discovery robust?

level: seniorimportance: should knowfreq 28%

basics

~20 s

Split discovery from activation: reading entry-point metadata is safe, EntryPoint.load() runs third-party code. Load each entry point in its own try/except, log the name and value, decide per group whether a failure degrades or stops the service, and prefer loading lazily.

open as a page

showing 1–30 of 47