Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Making a basic package

Slides

In the last chapters you learned how environments and dependencies work, and you saw a minimal pyproject.toml. Now you’ll build a real package from scratch, by hand, so that every file in it is something you understand. By the end of this hour you will have a package that:

We’ll build everything manually this time; the next chapter shows the template that generates all of this (and more) for you.

From script to package

Much code starts life in a notebook or a script. Ours is a single function:

import numpy as np


def rescale(input_array):
    """Rescale an array so its values span [0, 1]."""
    low = np.min(input_array)
    high = np.max(input_array)
    return (input_array - low) / (high - low)

It works (rescale(np.linspace(0, 100, 5)) gives [0, 0.25, 0.5, 0.75, 1]), but you can’t pip install it, can’t import it from another project, and can’t share it. Let’s fix that.

The layout

Make a new directory with a git repo, and lay out the files like this:

rescale
├── pyproject.toml
└── src
    └── rescale
        ├── __init__.py
        └── core.py
src/rescale/__init__.py
from rescale.core import rescale

__all__ = ["rescale"]

The pyproject.toml

The only other required file is pyproject.toml. Two tables matter:

pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "rescale"
version = "0.1.0"
dependencies = ["numpy"]

[build-system] selects a build backend: the tool that turns your source tree into something installable. The frontend (pip, uv, ...) installs requires into an isolated environment and asks the build-backend to do the build. All backends read the same standard [project] table, so switching is easy; they differ in file selection, dynamic versioning, and extras:

Hatchling
uv_build
Flit-core
Setuptools
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

A great default: fast, extendable, good file-inclusion defaults (it reads your .gitignore). We’ll use it in this chapter.

Install it and use it

Now install your package into an environment. You have a low-level and a high-level option:

uv run (high level)
pip install -e (low level)
uv run python -c "import rescale, numpy; print(rescale.rescale(numpy.linspace(0, 100, 5)))"

Remember from the setup chapter: uv run makes the venv, installs your package editably along with its dependencies, and runs the command. There is no step two.

A first test

Let’s lock in that manual check as a real test, in a tests/ folder next to src/ (not inside it):

tests/test_core.py
import numpy as np

from rescale import rescale


def test_rescale():
    np.testing.assert_allclose(
        rescale(np.linspace(0, 100, 5)),
        np.array([0.0, 0.25, 0.5, 0.75, 1.0]),
    )

pytest is a development-only dependency, so it goes in a dependency group, not in dependencies:

pyproject.toml
[dependency-groups]
dev = ["pytest"]

Since uv installs the dev group by default, running the tests is just:

uv run pytest

The next chapter covers testing properly; for now, one passing test is enough to tell us we haven’t broken anything while we work on the metadata.

Filling out the metadata

name and version are the only required fields, but a real package should tell its users (and PyPI) much more. Everything goes in the same standard [project] table, regardless of backend.

Informational metadata

These fields describe your package to humans and search engines:

pyproject.toml
[project]
name = "rescale"
version = "0.1.0"
description = "Rescale NumPy arrays to span [0, 1]."
readme = "README.md"
authors = [{ name = "My Name", email = "me@email.com" }]
license = "BSD-3-Clause"
license-files = ["LICENSE"]
keywords = ["arrays", "normalization"]
classifiers = [
  "Development Status :: 3 - Alpha",
  "Intended Audience :: Science/Research",
  "Programming Language :: Python :: 3",
  "Topic :: Scientific/Engineering",
  "Private :: Do Not Upload",
]

[project.urls]
Homepage = "https://github.com/me/rescale"
"Bug Tracker" = "https://github.com/me/rescale/issues"
Changelog = "https://github.com/me/rescale/releases"

A few of these deserve a closer look:

license
The modern form is an SPDX identifier expression like "BSD-3-Clause" or "MIT AND (Apache-2.0 OR BSD-2-Clause)", plus license-files globs pointing at the license text. You’ll see older packages use a License :: classifier or license = { file = "..." } table instead; don’t mix the old and new styles. And never write your own license text; pick a standard one from choosealicense.com.
classifiers
Free-form-ish tags from a fixed list. They help searching and communicate stability (“Development Status”). The special Private :: Do Not Upload classifier isn’t real, which means PyPI will reject the package; perfect for tutorials and internal code. Remove it when you mean to publish.
readme
Points at your README.md; its contents become the package’s long description, rendered on the PyPI page.

Functional metadata

These fields change how installers treat your package:

pyproject.toml
[project]
# ...
requires-python = ">=3.10"
dependencies = ["numpy>=1.24"]

[project.optional-dependencies]
plot = ["matplotlib"]
requires-python
Installers use this to back-solve: pip install rescale on an old Python walks back through your releases until it finds one whose requires-python passes. That’s what lets you drop old Pythons safely. It only works in that direction; never add an upper cap like <4 here, it breaks the back-solving logic and helps nobody.
dependencies
Version specifiers support lower bounds (>=1.24), caps (<3), and pins (==2.1.0). For a library: set lower bounds you actually test, and avoid upper caps unless you know the next major version breaks you (see bound version constraints for why). Exact pins belong in lockfiles, not here; remember from the setup chapter that your users must be able to share an environment with other packages.
optional-dependencies
The extras from the setup chapter: users opt in with pip install 'rescale[plot]'. Unlike dependency groups, extras are published metadata and available to your users from PyPI.

Entry points: adding a command line script

To give your package a shell command, add a scripts entry point mapping a command name to package.module:function:

pyproject.toml
[project.scripts]
rescale = "rescale.__main__:main"

with a matching function:

src/rescale/__main__.py
import sys

import numpy as np

from rescale.core import rescale


def main() -> None:
    values = np.array([float(x) for x in sys.argv[1:]])
    print(rescale(values))


if __name__ == "__main__":
    main()

On install, the installer generates a real rescale executable in the environment’s bin/. Using __main__.py as the file also makes python -m rescale work for free.

Versioning

A quick word on choosing version numbers, then the fun part: never writing them by hand again.

The common schemes are SemVer (major.minor.patch) and CalVer (date-based, like pip’s 25.1). Treat SemVer as an abbreviated changelog expressing author intent — patch: “nothing to see”, minor: “new stuff available”, major: “you should look before upgrading” — not as a promise that nothing will ever break; with enough users, every change breaks someone. That’s also why you shouldn’t preemptively pin package<2 in your dependencies.

Single-sourcing the version

Right now the version lives in pyproject.toml, and users may also expect rescale.__version__. Two copies drift. Backends can compute the version for you; you declare it dynamic and configure the backend:

From git tags (hatch-vcs)
From a file (hatchling)
pyproject.toml
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
name = "rescale"
dynamic = ["version"]

[tool.hatch]
version.source = "vcs"
build.hooks.vcs.version-file = "src/rescale/_version.py"

Now git tag v0.2.0 is the release process; commits after a tag get dev versions like 0.2.1.dev3, so every commit is uniquely identifiable. The generated _version.py is a build artifact: add it to .gitignore, and re-export it:

src/rescale/__init__.py
from rescale._version import __version__
Versions in GitHub’s tarballs

Git tags aren’t included in git archive output, which is what GitHub’s “Download source” tarballs are. Two small files fix that. .git_archival.txt:

node: $Format:%H$
node-date: $Format:%cI$
describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$

and a line in .gitattributes:

.git_archival.txt  export-subst

Git substitutes the real values when creating the archive, and hatch-vcs knows to read them.

The supporting files

Your repository needs a few files that aren’t code but make it a project someone else can use:

FileWhat it’s for
README.mdName, one-paragraph description, install command, tiny usage example. Keep it brief; details belong in docs. Rendered on GitHub and PyPI.
LICENSEThe exact text of the license you declared in pyproject.toml. Without one, default copyright applies and nobody may legally use your code.
.gitignoreStart from GitHub’s Python template. Doubly important with hatchling, which uses it to decide what doesn’t go into your package.
CHANGELOG.mdHuman-readable list of changes per version (keep a changelog). GitHub Releases can also fill this role.

Building your first distributions

Installing directly from the source tree is for development. To distribute a package, you build it into two standard artifacts:

SDist
The source distribution: a .tar.gz of your source tree plus metadata. Installing from it runs the build backend on the user’s machine.
wheel
A built distribution: a .whl zip file that is simply unpacked into site-packages. No code runs at install time, making installs fast and safe. For pure Python, one wheel (py3-none-any) works everywhere; compiled packages need one per platform, which is most of the rest of this workshop.

Build both with:

uv build

(or pipx run build; both call your build backend per the standard interface). The results land in dist/, and you can (and should!) look inside:

tar -tf dist/*.tar.gz    # SDist contents
unzip -l dist/*.whl      # wheel contents

Summary

build backend
The tool (hatchling here) that turns your source tree into installable artifacts. Selected in [build-system], configured under [tool.*].
src layout
Keeping code in src/<package>/ so nothing works without a real install.
[project] table
The standardized metadata: name, version, dependencies, entry points, and everything PyPI displays. Identical across backends.
entry point
Metadata mapping a command name to a Python function; installers generate the executable.
SDist / wheel
Source vs. built distribution. Ship both.

You built all of this by hand once so it holds no mysteries. You will probably never do it by hand again: the next chapter introduces the Scientific Python Development Guide and its template, which generates a package like this, plus tests, linting, typing, and CI, in one command.