Preparing a Library

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.

Publishing starts before anything is built: the layout, the names and the metadata in pyproject.toml are what users and PyPI see, and most of them cannot be changed for a release once it is uploaded. This page prepares the birdtally library for publication. The examples ran on 2026-10-22 on Linux with CPython 3.14.6, build 1.6.1 and the backends hatchling 1.32.4, setuptools 84.2.1 and poetry-core 2.5.0. The pyproject.toml keys themselves are described on pyproject.toml; this page covers the decisions and the checks around them. A Django-specific walk-through is on Reusable Apps & Third-Party Packages.

Layout

Layout Shape Effect

src (recommended)

src/birdtally/__init__.py

The package is importable only after it is installed, so tests run against the installed code and a missing file shows up before release. Running the CLI from a checkout needs an (editable) install first.

flat

birdtally/__init__.py next to pyproject.toml

Importable from the project root without installing, which hides packaging mistakes.

poetry new uses the src layout since 2.1; hatchling and setuptools find a src/<name> package on their own. birdtally:

pyproject.toml
README.md
LICENSE
CHANGELOG.md
src/birdtally/__init__.py
src/birdtally/cli.py
src/birdtally/tally.py
src/birdtally/py.typed
tests/test_tally.py
examples/survey.csv

Namespace packages let several distributions share one top-level import name (acme.birds, acme.maps). Use native namespace packages (PEP 420): the shared directory has no __init__.py in any of the distributions. The older pkgutil and pkg_resources styles are legacy; never mix styles across distributions of one namespace.

Names

  • The distribution name (name in [project]) is what users install and what PyPI lists; the import name is what they import. Keep them the same unless there is a reason not to. PyPI and installers compare names after normalization (lowercase, runs of -, _ and . become -), so Bird_Tally, bird.tally and bird-tally are one project; see Name normalization.

  • import-names (PEP 794, Core Metadata 2.5) declares the import names a distribution provides. hatchling 1.32.4 writes it: import-names = ["birdtally"] becomes Import-Name: birdtally in a Metadata-Version: 2.5 file.

  • Choosing a name on PyPI: a name can be unavailable although no project shows it, because it clashes with the standard library, is too similar to an existing project (typosquatting protection), is prohibited (requirements.txt), or is registered without releases. Abandoned names can be requested under PEP 541’s name-retention policy, which is slow and not guaranteed. Check the name before announcing it.

The metadata users see

[project]
name = "birdtally"
version = "0.1.0"
description = "Tally bird-survey sightings from CSV files."
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "The birdtally authors" }]
keywords = ["birds", "survey", "csv"]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Programming Language :: Python :: 3",
    "Typing :: Typed",
]

[project.urls]
Homepage = "https://example.org/birdtally"
Changelog = "https://example.org/birdtally/changelog"

What the hatchling build wrote into the wheel’s METADATA:

Metadata-Version: 2.5
Name: birdtally
Version: 0.1.0
Summary: Tally bird-survey sightings from CSV files.
Project-URL: Homepage, https://example.org/birdtally
Project-URL: Changelog, https://example.org/birdtally/changelog
Author: The birdtally authors
License-Expression: MIT
License-File: LICENSE
Keywords: birds,csv,survey
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: pretty
Requires-Dist: rich>=13; extra == 'pretty'
Description-Content-Type: text/markdown

# birdtally
…

description and readme

description is the one-line summary at the top of the project page. readme becomes the long description, the body of the page; its content type comes from the file extension: .md is text/markdown (GitHub-flavoured by default), .rst is text/x-rst, anything else needs the table form with an explicit content-type. reStructuredText on PyPI is plain docutils: Sphinx roles such as :py:func: make the upload fail to render (checked on Checking & Testing Distributions). Relative links and images in the README do not work on PyPI; use absolute URLs.

authors and maintainers

Both are lists of tables with a name, an email, or both. Their meaning is deliberately loose: authors may name the original authors, the current maintainers or the owners. maintainers is for when the people who maintain the project now are not the authors; the Core Metadata Maintainer field "should be omitted if it is identical to Author". A name alone becomes Author: (or Maintainer:), as in birdtally's Author: The birdtally authors above; an entry with an email becomes Author-email: (Maintainer-email:) in the form Name <email>, several entries separated by commas. A name must not contain a comma. These fields only describe the project: they do not grant anyone rights on PyPI, where owners and maintainers are the collaborators of the project (see PyPI & TestPyPI Accounts & Tokens).

license and license-files (PEP 639)

Since PEP 639 (in the specification since December 2024), license is a single SPDX license expression ("MIT", "Apache-2.0 OR MIT", "LicenseRef-Proprietary" for one that has no SPDX identifier), and license-files lists glob patterns for the license texts to ship. Backends write License-Expression and one License-File per file, and put the files under .dist-info/licenses/. The identifiers allowed are those of the SPDX License List version 3.17 or any later compatible version (expression syntax: SPDX specification 2.2 or later), plus custom LicenseRef-… identifiers. The SPDX list never removes identifiers, but marks some deprecated: tools should warn about those and PyPI may reject them. The old table form (license = { file = "LICENSE" }) and the License :: classifiers are deprecated. Minimum backend versions: setuptools 77.0.3, hatchling 1.27.0, flit-core 3.12, pdm-backend 2.4.0, poetry-core 2.2.0, uv_build 0.7.19. flit-core does not accept the WITH operator.

The specification says a pattern that matches no file is an error. The backends do not agree, so check the built wheel:

Mistake hatchling 1.32.4 setuptools 84.2.1 poetry-core 2.5.0

license-files = ["LICENSE.txt"] (no such file)

Builds silently, without any license file

Builds, with a warning: Pattern 'LICENSE.txt' did not match any files.

Fails: RuntimeError: No files found for license file glob pattern 'LICENSE.txt'

license = "MIT License" (not SPDX)

Fails with a ValueError for project.license: Invalid license expression: 'MIT License'

Fails: invalid pyproject.toml config for project.license, which must be valid exactly by one definition (0 matches found)

Builds with the legacy License: MIT field instead of License-Expression; poetry check warns [project.license] is not a valid SPDX expression. This is deprecated and will raise an error in the future.

license = "MIT" plus a License :: OSI Approved :: MIT License classifier

Builds (the specification lets tools choose)

Fails: InvalidConfigError: License classifiers have been superseded by license expressions (see https://peps.python.org/pep-0639/). Please remove: …

(not tried)

Code vendored from elsewhere under another license makes the expression a combination ("MIT AND BSD-3-Clause"), and its license text goes into license-files too (["LICENSE", "src/birdtally/_vendor/LICENSE.*"]).

Classifiers, URLs and requires-python

  • Classifiers describe the project for search and filtering on PyPI; they do not restrict installation. Only names from the

    official trove list are accepted. `Private

    Do Not Upload` is always rejected by PyPI, which makes it a cheap safety net for internal packages.

  • URLs: the labels in [project.urls] are free text, but PyPI recognises the well-known ones (homepage, source, download, changelog, releasenotes, documentation, issues, funding, security) after normalising them (lowercase, punctuation and spaces removed), shows icons for them, and marks a URL verified when it can prove the uploader controlled it at upload time (Trusted Publishing from that repository, or a link to the project’s own PyPI page).

  • requires-python is checked by installers before anything else. Give a lower bound only: an upper bound such as <4 makes resolvers back off to older releases on new Pythons rather than fail clearly.

Entry points: scripts and plugins

[project.scripts] (and [project.gui-scripts] for windowed programs on Windows) make installers create a command; birdtally installs birdtally = "birdtally.cli:main". Any other group in [project.entry-points] is a plugin hook that other code can discover through the installed metadata. Adding an exporter group:

[project.entry-points."birdtally.exporters"]
csv = "birdtally.tally:tally_by_site"
from importlib.metadata import entry_points

for ep in entry_points(group="birdtally.exporters"):
    print(ep.name, "->", ep.value, ep.load())
csv -> birdtally.tally:tally_by_site <function tally_by_site at 0x7fb163aa5a60>

The wheel stores both in entry_points.txt ([console_scripts] and [birdtally.exporters]). Do not declare [project.entry-points.console_scripts] yourself: backends must reject it in favour of [project.scripts]. Distributing the CLI as an application is on Distributing Applications & CLI Tools.

Files that must ship

  • py.typed: an empty marker file inside the package that tells type checkers the library is typed (PEP 561), paired with the

    `Typing

    Typed` classifier. The built wheel must contain it: unzip -l on birdtally's wheel lists birdtally/py.typed with size 0.

  • Data files inside the package directory are included by hatchling and poetry-core; setuptools needs package-data or a MANIFEST.in for some cases. Read them with importlib.resources, never with paths relative to __file__.

  • CHANGELOG.md and tests: whether they belong in the sdist is a backend setting (Poetry needs an include entry with format = "sdist"). See Building sdists & Wheels for checking the contents.

The wheel of birdtally:

      223  2020-02-02 00:00   birdtally/__init__.py
     1089  2020-02-02 00:00   birdtally/cli.py
        0  2020-02-02 00:00   birdtally/py.typed
      979  2020-02-02 00:00   birdtally/tally.py
      714  2020-02-02 00:00   birdtally-0.1.0.dist-info/METADATA
       87  2020-02-02 00:00   birdtally-0.1.0.dist-info/WHEEL
       49  2020-02-02 00:00   birdtally-0.1.0.dist-info/entry_points.txt
      819  2020-02-02 00:00   birdtally-0.1.0.dist-info/licenses/LICENSE
      702  2020-02-02 00:00   birdtally-0.1.0.dist-info/RECORD

pip and Poetry

Both flavours of birdtally carry the same [project] table; the Poetry flavour adds [tool.poetry] packages and an include for the changelog (both files in full on pyproject.toml). poetry check validates the metadata before a build; on the pip side, the backend validates it at build time and twine check after it.