Build a package by hand, so every file holds no mysteries:
src
Next chapter: the template that generates all of this for you.
A function in a notebook or script:
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)
Works fine — but you can't pip install it, import it elsewhere, or share it.
pip install
import
rescale ├── pyproject.toml └── src └── rescale ├── __init__.py └── core.py
__init__.py marks the package and defines the public API:
__init__.py
from rescale.core import rescale __all__ = ["rescale"]
Without it, python and pytest import the local folder, not the installed package:
python
pytest
[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "rescale" version = "0.1.0" dependencies = ["numpy"]
[build-system]
name
src/rescale
All read the same standard [project] table — switching is easy:
[project]
They differ in file selection, dynamic versioning, and extras.
High level:
uv run python >>> from rescale import rescale
Low level:
uv venv uv pip install -e .
-e = editable: edits to src/ are visible on the next import.
-e
src/
Create the rescale package:
rescale
pyproject.toml
git init
numpy.linspace(0, 100, 5)
Bonus: delete dependencies = ["numpy"], remove .venv + uv.lock, and see what breaks.
dependencies = ["numpy"]
.venv
uv.lock
# 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]), )
[dependency-groups] dev = ["pytest"]
uv run pytest
name + version are required; a real package says much more.
version
Same standard [project] table for every backend:
requires-python
[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"] classifiers = [ "Development Status :: 3 - Alpha", "Private :: Do Not Upload", ] [project.urls] Homepage = "https://github.com/me/rescale"
License: modern form is an SPDX expression
"BSD-3-Clause"
"MIT AND (Apache-2.0 OR BSD-2-Clause)"
License ::
Classifiers: tags from a fixed list on PyPI
Private :: Do Not Upload
[project] requires-python = ">=3.10" dependencies = ["numpy>=1.24"] [project.optional-dependencies] plot = ["matplotlib"]
dependencies
pip install 'rescale[plot]'
project.dependencies
build-system.requires
[project.scripts] rescale = "rescale.__main__:main"
# src/rescale/__main__.py def main() -> None: ...
bin/
__main__.py
python -m rescale
uv run --with pip pip show -v rescale
uv run rescale 1 2 3
Stale metadata? uv cached the old build: uv sync --reinstall-package rescale
uv sync --reinstall-package rescale
SemVer (major.minor.patch) — read it as author intent:
major.minor.patch
package<2
CalVer — date-based (pip's 25.1); communicates age and deprecation windows
25.1
Two copies (pyproject.toml + __version__) drift. Let the backend compute it:
__version__
[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"
git tag v0.2.0
0.2.1.dev3+g1a2b3c4
_version.py
.gitignore
from rescale._version import __version__
Alternative: version.path reads __version__ out of your source file.
version.path
uv sync --reinstall-package rescale uv run python -c "import rescale; print(rescale.__version__)"
README.md
LICENSE
CHANGELOG.md
readme
license-files
.tar.gz
.whl
site-packages
py3-none-any
Compiled packages need a wheel per platform — most of the rest of this workshop!
uv build
tar -tf dist/*.tar.gz # SDist contents unzip -l dist/*.whl # wheel contents
rescale-0.2.0.dist-info/METADATA
RECORD
Build, then find:
tests/
unzip -p dist/*.whl '*/METADATA' | head -20
You did it by hand once — next chapter: the template that does it for you.