Packaging Concepts & Standards

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the Python Reference bibliography.

This page explains the standard pieces that both pip and Poetry are built from. It was written against pip 26.2.2, Poetry 2.5.1 with poetry-core 2.5.0, build 1.6.1 and hatchling 1.32.4, and every command below was run on CPython 3.14.6 on 2026-10-22. Paths under the scratch directory used for the runs are shortened to … in the output.

Python has no single build tool. Packaging is a chain of small, standardised jobs: a build frontend asks a build backend to turn a source tree into distribution files, an index serves those files, and an installer unpacks one of them into an environment. pip is a frontend and an installer; Poetry is a frontend, an installer, a resolver and a project manager, and poetry-core is its backend. The roles are introduced on the section landing page; this page goes through the artefacts and the specifications that connect them. How an installed package is then found by import is covered in Modules and Packages.

The birdtally source tree is built by a frontend calling a backend into an sdist and a wheel, uploaded to an index and installed into an environment, with the files inside each artefact

Distribution packages and import packages

The word package means two different things in Python, and most naming confusion comes from mixing them up.

  • A distribution package is something you install: the name you type after pip install or put in dependencies. On PyPI it is usually the same as the project. It may also mean one file of one version of the project (an sdist or a wheel).

  • An import package is a Python module that can contain submodules: the name you type after import. It is normally a directory with .py files.

The two names are linked only by convention. pip install birdtally gives you import birdtally, but pip install Pillow gives you import PIL, and attrs ships both attrs and attr. Indexes do not enforce any link between them, which is why the Packaging User Guide warns that seeing import foo is no proof that the PyPI project foo is the right one: installing it blindly can pull in an unrelated or malicious package. PEP 794 (Accepted) adds the optional Import-Name and Import-Namespace core-metadata fields so that a distribution can declare which import names it provides; see the import-names key.

Name normalization

Import names are case-sensitive Python identifiers and use underscores. Distribution names are compared after normalization: the name is lower-cased and every run of -, _ and . becomes a single -. So Birdtally, birdtally and BIRDTALLY are one project, and bird_tally, Bird.Tally and BIRD--tally are another (bird-tally). The regular expression from the specification is re.sub(r"[-_.]+", "-", name).lower(); the packaging library implements it as canonicalize_name:

python -c "
from packaging.utils import canonicalize_name
for n in ['Birdtally','birdtally','bird_tally','Bird.Tally','BIRD--tally']: print(f'{n!r:16} -> {canonicalize_name(n)!r}')"
'Birdtally'      -> 'birdtally'
'birdtally'      -> 'birdtally'
'bird_tally'     -> 'bird-tally'
'Bird.Tally'     -> 'bird-tally'
'BIRD--tally'    -> 'bird-tally'

Normalization is applied everywhere a name is compared: PyPI refuses a new project whose normalized name collides with an existing one, pip and Poetry treat Bird_Tally in a requirement as bird-tally, and the simple API URL is always the normalized form (/simple/bird-tally/). In file names the rule is different: sdist and wheel file names, and .dist-info directory names, replace - with _ so that the name contains exactly one dash between name and version (bird_tally-0.1.0-py3-none-any.whl). Extra names are normalized the same way since PEP 685, so birdtally[Pretty] and birdtally[pretty] select the same extra; see Versions & Dependency Specifiers.

Source distributions, wheels and eggs

Format What it is What an installer does with it

sdist (birdtally-0.1.0.tar.gz)

A gzipped tar archive of the source tree plus a PKG-INFO metadata file, named {name}-{version}.tar.gz (PEP 625). It is what you publish so that anyone can rebuild the project, and what downstream packagers (Linux distributions, conda-forge) start from.

It cannot be installed directly. The installer creates a build environment, runs the backend named in pyproject.toml and installs the wheel that comes out.

wheel (birdtally-0.1.0-py3-none-any.whl)

A zip archive in the binary distribution format (PEP 427): the files already in their final layout, plus a {name}-{version}.dist-info/ directory. The file name carries compatibility tags (Python, ABI, platform).

It is unpacked into the environment, with no code from the project run at install time. Choosing a wheel is therefore faster and safer than building from an sdist.

egg (.egg, .egg-info)

The setuptools format from 2004, never standardised. It had no file name tags and supported importing from a zip.

Obsolete. pip never installed eggs; PyPI stopped accepting egg uploads on 2023-08-01. Do not produce them.

Wheels are not only for compiled code. A pure-Python project like birdtally still ships a py3-none-any wheel, because installing a wheel never runs the build backend and so never depends on the backend being installable. Publish both an sdist and at least one wheel: the Building sdists & Wheels page explains the tags in the file name, and Binary Wheels & Extension Modules covers the platform-specific ones.

Building both with python -m build

build is the reference PEP 517 frontend. With no options it builds the sdist first and then the wheel from the sdist, which proves that the sdist contains everything the wheel needs:

cd birdtally          # the pip flavour, hatchling backend
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.27
* Getting build dependencies for sdist...
* Installed build dependency versions:
  - hatchling==1.32.4
* Building sdist...
* Building wheel from sdist
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - hatchling>=1.27
* Getting build dependencies for wheel...
* Installed build dependency versions:
  - hatchling==1.32.4
* Building wheel...
Successfully built birdtally-0.1.0.tar.gz and birdtally-0.1.0-py3-none-any.whl

The two archives contain different things. The sdist keeps the source layout and adds PKG-INFO:

python -m tarfile -l dist/birdtally-0.1.0.tar.gz
birdtally-0.1.0/CHANGELOG.md
birdtally-0.1.0/examples/survey.csv
birdtally-0.1.0/src/birdtally/__init__.py
birdtally-0.1.0/src/birdtally/cli.py
birdtally-0.1.0/src/birdtally/py.typed
birdtally-0.1.0/src/birdtally/tally.py
birdtally-0.1.0/tests/test_tally.py
birdtally-0.1.0/LICENSE
birdtally-0.1.0/README.md
birdtally-0.1.0/pyproject.toml
birdtally-0.1.0/PKG-INFO

The wheel has only the import package and its .dist-info directory:

python -m zipfile -l dist/birdtally-0.1.0-py3-none-any.whl
File Name                                             Modified             Size
birdtally/__init__.py                          2020-02-02 00:00:00          223
birdtally/cli.py                               2020-02-02 00:00:00         1089
birdtally/py.typed                             2020-02-02 00:00:00            0
birdtally/tally.py                             2020-02-02 00:00:00          979
birdtally-0.1.0.dist-info/METADATA             2020-02-02 00:00:00          714
birdtally-0.1.0.dist-info/WHEEL                2020-02-02 00:00:00           87
birdtally-0.1.0.dist-info/entry_points.txt     2020-02-02 00:00:00           49
birdtally-0.1.0.dist-info/licenses/LICENSE     2020-02-02 00:00:00          819
birdtally-0.1.0.dist-info/RECORD               2020-02-02 00:00:00          702

The fixed 2020-02-02 time stamp is hatchling’s reproducible-build default (it honours SOURCE_DATE_EPOCH when set), so two builds of the same tree produce byte-identical wheels. The WHEEL file records the format version, the backend and the tags:

unzip -p dist/birdtally-0.1.0-py3-none-any.whl birdtally-0.1.0.dist-info/WHEEL
Wheel-Version: 1.0
Generator: hatchling 1.32.4
Root-Is-Purelib: true
Tag: py3-none-any

Build frontends, build backends and build isolation

Before 2017 every build ran python setup.py …, which meant that setuptools had to be installed first and that nothing else could build a project. Two PEPs split that monolith:

  • PEP 518 added pyproject.toml and its [build-system] table. requires lists what must be installed to run the build, so a frontend can install it before the build starts.

  • PEP 517 defined the interface between the tool that starts a build (the frontend: pip, build, Poetry, uv) and the library that does it (the backend: setuptools, hatchling, poetry-core, flit-core, …). The backend is a Python object named by build-backend, with mandatory hooks build_wheel and build_sdist and optional ones such as get_requires_for_build_wheel and prepare_metadata_for_build_wheel.

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

The frontend decides where the backend runs. By default it creates a fresh, isolated environment for every build, installs requires into it (and whatever get_requires_for_build_* returns), and calls the hooks in a subprocess. Isolation means a build cannot accidentally depend on whatever happens to be installed in your environment, and that two projects can need different versions of the same backend. The cost is a download per build, which is why pip caches wheels and why unpinned build requirements can make an old release fail to build years later. pip 25.3 made PEP 517 the only build path (the --no-use-pep517 option was removed); the details, including --no-build-isolation and --config-settings, are on pip: Build System Interface. Poetry builds its own projects in process with poetry-core and isolates the builds of dependencies it installs from sdists, which is configurable with installer.build-config-settings; see Poetry: Configuration.

Source: PEP 517, PEP 518.

Editable installs

During development you want import birdtally to load the files you are editing, not a copy. An editable install does that. Until PEP 660 the only way was setuptools' setup.py develop; PEP 660 (2021) standardised it as two more backend hooks, build_editable and get_requires_for_build_editable. The backend builds a small wheel whose content points back at the source tree, usually a .pth file, and the installer installs that wheel like any other. pip supports PEP 660 since 21.3 and removed the setup.py develop fallback in 25.3, so every backend in use today has to implement the hooks (setuptools does since 64). Poetry installs the root project in editable mode by default when you run poetry install.

python -m pip install -e .
Obtaining file://…/birdtally
  Installing build dependencies: started
  Installing build dependencies: finished with status 'done'
  Checking if build backend supports build_editable: started
  Checking if build backend supports build_editable: finished with status 'done'
  Getting requirements to build editable: started
  Getting requirements to build editable: finished with status 'done'
  Installing backend dependencies: started
  Installing backend dependencies: finished with status 'done'
  Preparing editable metadata (pyproject.toml): started
  Preparing editable metadata (pyproject.toml): finished with status 'done'
Building wheels for collected packages: birdtally
  Building editable for birdtally (pyproject.toml): started
  Building editable for birdtally (pyproject.toml): finished with status 'done'
  Created wheel for birdtally: filename=birdtally-0.1.0-py3-none-any.whl size=2342 sha256=10ddfa7ec26eb643ad4b1ee1057939cd9a031aedefc06898128ac3ee0c023011
  Stored in directory: /tmp/pip-ephem-wheel-cache-lmmpnzfa/wheels/f6/db/62/bd98a71f246925c56d66642eb828e549b9be695d806c52a168
Successfully built birdtally
Installing collected packages: birdtally
Successfully installed birdtally-0.1.0

hatchling implemented the editable wheel with a single path file, _editable_impl_birdtally.pth, whose only line is the src directory, so the interpreter adds …/birdtally/src to sys.path at start-up:

python -c "import birdtally; print(birdtally.__file__)"
…/birdtally/src/birdtally/__init__.py

What an installer leaves behind: .dist-info

Installing a wheel copies its files into the environment’s site-packages and turns its .dist-info directory into a record of the installation. That record is what pip list, pip show, pip uninstall, importlib.metadata and Poetry’s installer all read. After installing the birdtally wheel:

python -m pip install dist/birdtally-0.1.0-py3-none-any.whl
ls .venv/lib/python3.14/site-packages/birdtally-0.1.0.dist-info .venv/lib/python3.14/site-packages/birdtally-0.1.0.dist-info/licenses
cat .venv/lib/python3.14/site-packages/birdtally-0.1.0.dist-info/INSTALLER
Processing ./dist/birdtally-0.1.0-py3-none-any.whl
Installing collected packages: birdtally
Successfully installed birdtally-0.1.0
.venv/lib/python3.14/site-packages/birdtally-0.1.0.dist-info:
INSTALLER
METADATA
RECORD
REQUESTED
WHEEL
direct_url.json
entry_points.txt
licenses

.venv/lib/python3.14/site-packages/birdtally-0.1.0.dist-info/licenses:
LICENSE
pip
File or directory Meaning

METADATA

The core metadata (name, version, Requires-Dist, License-Expression, …). The only mandatory file; an installer must fail if it cannot write it.

RECORD

A CSV file with one line per installed file: path, sha256= digest in URL-safe base64 without padding, and size. Uninstalling removes exactly these files (plus the matching .pyc files). If RECORD is missing, tools must not uninstall or upgrade the project, which is the root cause of pip’s "Cannot uninstall X. It is a distutils installed project" error.

INSTALLER

The name of the tool that installed the project (pip, poetry, uv). Linux distributions write their own name so that pip can tell their packages apart.

REQUESTED

pip’s marker that the project was asked for directly rather than pulled in as a dependency. It is a tool-specific extension now; pip list --not-required and some uninstall tools use it.

direct_url.json

PEP 610: written when the project came from a URL or a local path instead of an index, so tools can reproduce the install. A local directory gives {"dir_info": {}, "url": "file:///…/birdtally"}, an editable install adds "editable": true, a VCS install records vcs_info with the commit id. pip freeze uses it to print birdtally @ file:///… lines.

entry_points.txt

The [console_scripts] and other entry-point groups. The installer generates bin/birdtally from it.

licenses/

The licence files named by License-File, at their path relative to the project root (PEP 639).

sboms/

Software bills of materials in a standard format (PEP 770), for projects that vendor or statically link other software. birdtally has none. pip itself ships a CycloneDX SBOM of its vendored libraries since 26.2, at pip/_vendor/bom.cdx.json.

The local install from a directory records where it came from:

python -m pip install --force-reinstall .
cat .venv/lib/python3.14/site-packages/birdtally-0.1.0.dist-info/direct_url.json
{"dir_info": {}, "url": "file:///…/birdtally"}

The simple repository API

An installer finds files through an index’s simple API: one page per project at /simple/<normalized-name>/ that lists every file with its URL and hash. Three PEPs shape it:

  • PEP 503 defined the HTML form: one <a> per file, a #sha256= fragment on the URL, data-requires-python and, since PEP 592, data-yanked. PEP 833 (Final, 2026) freezes the HTML form: new features are added only to the JSON form, and HTML stays as it is for existing clients.

  • PEP 691 added a JSON form of the same data, chosen by content negotiation with Accept: application/vnd.pypi.simple.v1+json. pip asks for JSON first since 22.2.

  • PEP 658 (with the rename in PEP 714) lets the index serve a wheel’s METADATA as a separate <file>.metadata file, flagged by core-metadata in JSON and data-core-metadata in HTML. The resolver can read dependencies without downloading whole wheels, which is why pip install --dry-run is fast. pip uses it since 22.3.

The JSON entry for one wheel of a small project on PyPI shows all of them at once (the hash values are shortened here):

curl -s -H 'Accept: application/vnd.pypi.simple.v1+json' https://pypi.org/simple/tomli-w/ \
  | python -c "import json,sys; d=json.load(sys.stdin); print(json.dumps([f for f in d['files'] if f['filename'].endswith('.whl')][-1], indent=2))"
{
  "core-metadata": {
    "sha256": "66e81ac2…a9c8de"
  },
  "data-dist-info-metadata": {
    "sha256": "66e81ac2…a9c8de"
  },
  "filename": "tomli_w-1.2.0-py3-none-any.whl",
  "hashes": {
    "sha256": "18830609…83cf90"
  },
  "provenance": null,
  "requires-python": ">=3.9",
  "size": 6675,
  "upload-time": "2025-01-15T12:07:22.074224Z",
  "url": "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl",
  "yanked": false
}

upload-time is what pip’s --uploaded-prior-to filter and Poetry’s solver.min-release-age compare against, provenance is where PEP 740 attestations are linked (see Trusted Publishing & Attestations), and yanked implements PEP 592. PEP 792 (Final) adds project status markers (active, archived, quarantined, deprecated) at the project level. The meta.api-version of PyPI’s response was 1.4 on the day of the check.

Which PEP introduced which piece

PEP Title Status Where it shows up in this section

427

The Wheel Binary Package Format 1.0

Final

wheel file names and contents

440

Version Identification and Dependency Specification

Final

version numbers and specifiers

503

Simple Repository API

Final

the HTML index pages pip reads

508

Dependency specification for Python Software Packages

Final

requirement strings, extras and markers

517

A build-system independent format for source trees

Final

backend hooks, pip’s build interface

518

Specifying Minimum Build System Requirements

Final

pyproject.toml and [build-system]

592

Adding "Yank" Support to the Simple API

Final

yanking a release

610

Recording the Direct URL Origin of installed distributions

Final

direct_url.json, pip freeze of VCS and local installs

621

Storing project metadata in pyproject.toml

Final

the [project] table, Poetry 2’s metadata

625

Filename of a Source Distribution

Final

birdtally-0.1.0.tar.gz

639

Improving License Clarity with Better Package Metadata

Final

license = "MIT", license-files, licenses/

643

Metadata for Package Source Distributions

Final

trustworthy PKG-INFO in sdists, Dynamic

658

Serve Distribution Metadata in the Simple Repository API

Accepted

.metadata files, fast resolution

660

Editable installs for pyproject.toml based builds

Final

pip install -e, Poetry’s root install

668

Marking Python base environments as "externally managed"

Final

EXTERNALLY-MANAGED

685

Comparison of extra names

Final

normalized extras

691

JSON-based Simple API

Final

the JSON form above

723

Inline script metadata

Final

--requirements-from-script

735

Dependency Groups in pyproject.toml

Final

[dependency-groups], pip install --group, Poetry groups

740

Index support for digital attestations

Final

attestations and provenance

751

A file format to record Python dependencies

Final

pylock.toml

770

Improving measurability of Python packages with SBOMs

Final

.dist-info/sboms/

792

Project status markers in the simple index

Final

archived and quarantined projects

794

Import Name Metadata

Accepted

import-names, import-namespaces

808

Including static values in dynamic metadata

Accepted

Core Metadata 2.6, dynamic that may extend static values

833

Freezing the HTML simple repository API

Final

HTML API frozen, JSON is where new features go

Statuses are as shown on peps.python.org on 2026-10-22. The specifications themselves are maintained on packaging.python.org, which is the normative text once a PEP is accepted; read the PEP for the rationale and the specification page for the current rules.

Common mistakes

  • Assuming the import name is the project name. Check the project page before installing a name you saw in an import line.

  • Uploading only an sdist. Every user then has to build it, which needs the backend and, for compiled code, a compiler.

  • Expecting an sdist to be the Git tree. The backend decides what goes in (hatchling follows .gitignore, setuptools uses MANIFEST.in and its file finders, poetry-core uses include/exclude and the VCS ignore file). A file that is ignored by Git can silently go missing.

  • Deleting RECORD or installing with tools that do not write it. The project can then no longer be uninstalled cleanly.

  • Using setup.py install or eggs. Both are long obsolete; pip install . and wheels replace them.