Packaging Your Python Code — Week 10
CU Boulder ATOC
Spring 2026
pyproject.toml that makes your project pip-installableLab 9 Due This Friday!
Office Hours:
Will: Tu 11:15-12:15p Th 9-10a Aerospace Cafe
Aiden: M / W 330-430p DUAN D319

Your classmate wants to use your Lorenz63 class. What do you do?
Option A: Email them lorenz63.py
integrators.py? plotting.py? The dependencies?Option B: “Just clone my repo and copy the files”
Option C: “Add my repo to your sys.path”
pip install Actually DoesWhen you run pip install numpy, here’s what happens:
1. Downloads the package from PyPI (Python Package Index)
2. Copies it into your environment's site-packages/
3. Installs any dependencies it needs (e.g., numpy needs BLAS)
4. Registers any command-line scripts
5. Done — you can now `import numpy` from anywhere
Your package can do the exact same thing. The only question is: what files does pip need to know how to install it?
Answer: a single file called pyproject.toml at the root of your repo.
Every import you write is loading an installed package:
These all started as someone’s project folder — just like yours. The authors added packaging metadata, uploaded to PyPI, and now the whole world can install them.
Your lorenz_project is 90% of the way there. Today we add the last 10%.
Think About It
Right now, how would a classmate use your Lorenz63 class in their own script on their own laptop?
What steps would they have to follow? What could go wrong?
From Week 5.5, your project looks like this:
lorenz_lab/
└── lorenz_project/
├── __init__.py
├── integrators.py
├── lorenz63.py
├── plotting.py
└── run_lorenz_ensemble.py
This is already a valid Python package — you can import lorenz_project.lorenz63 when you’re inside lorenz_lab/.
But it’s not installable. pip doesn’t know it exists.
Add three files at the repo root (above lorenz_project/):
lorenz-project/ ← repo root (note: hyphen in repo name is fine)
├── pyproject.toml ← NEW — tells pip how to install
├── README.md ← NEW — project description
├── LICENSE ← NEW — open source license (MIT)
└── lorenz_project/ ← your existing package (underscore!)
├── __init__.py
├── integrators.py
├── lorenz63.py
├── plotting.py
└── run_lorenz_ensemble.py
Convention: repo name uses hyphens (lorenz-project), package folder uses underscores (lorenz_project). Python imports can’t have hyphens.
Common Error
pyproject.toml must be above the package folder, not inside it.
lorenz_project/ ← WRONG
├── pyproject.toml
├── __init__.py
├── lorenz63.py
└── ...
pip can’t find the package — it looks for a subfolder to install.
lorenz-project/ ← CORRECT
├── pyproject.toml
└── lorenz_project/
├── __init__.py
├── lorenz63.py
└── ...
pyproject.toml is at the root, package is one level down.
If you currently have everything in one folder, create a parent directory and move pyproject.toml, README.md, and LICENSE there.
A brief history of Python packaging:
| Era | File | Status |
|---|---|---|
| 2000–2020 | setup.py |
Legacy — still works but don’t use for new projects |
| 2016–2020 | setup.cfg |
Transitional — declarative but limited |
| 2021–now | pyproject.toml |
The standard — PEP 621, all tools support it |
Why the change?
setup.py is executable Python → security risk, hard to parse staticallypyproject.toml is a simple config file (like JSON but readable)setup.py + setup.cfg + requirements.txt + MANIFEST.inTOML = Tom’s Obvious, Minimal Language. If you can read an INI file, you can read TOML.
Here’s a complete, working pyproject.toml for your Lorenz project:
[build-system]
requires = ["setuptools >= 68.0"]
build-backend = "setuptools.backends._legacy:_Backend"
[project]
name = "lorenz-project"
version = "0.1.0"
description = "Lorenz63 model integration and ensemble analysis"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">= 3.9"
authors = [
{name = "Your Name", email = "you@colorado.edu"},
]
dependencies = [
"numpy >= 1.22",
"matplotlib >= 3.5",
]
[project.optional-dependencies]
dev = ["build", "twine"]
[project.scripts]
run-lorenz = "lorenz_project.run_lorenz_ensemble:main"That’s the whole file. Let’s break it down.
| Section | Key | What it does |
|---|---|---|
[build-system] |
requires |
Tools needed to build your package (setuptools) |
build-backend |
Which backend actually builds the wheel | |
[project] |
name |
Package name on PyPI — must be globally unique |
version |
Current version of your package | |
description |
One-line summary shown on PyPI | |
readme |
Path to README file (rendered on PyPI page) | |
license |
License identifier | |
requires-python |
Minimum Python version | |
authors |
List of author names and emails | |
dependencies |
Packages your code needs to run | |
[project.optional-dependencies] |
dev |
Extra packages for development (not required by users) |
[project.scripts] |
run-lorenz |
Command-line command → Python function mapping |
Version numbers follow MAJOR.MINOR.PATCH:
v 1 . 4 . 2
│ │ └── PATCH — bug fixes, no new features
│ └────── MINOR — new features, backwards compatible
└────────── MAJOR — breaking changes
| Version bump | When? | Example |
|---|---|---|
0.1.0 → 0.1.1 |
Fixed a bug in integrate() |
Patch |
0.1.1 → 0.2.0 |
Added run_ensemble() method |
Minor |
0.2.0 → 1.0.0 |
Changed Lorenz63 constructor arguments |
Major |
For this course: start at 0.1.0. You’re pre-1.0, so breaking changes are expected.
Three styles of specifying dependencies:
Rule of thumb:
| You’re writing a… | Pin style | Why |
|---|---|---|
| Library (for others to install) | >= minimum |
Let pip resolve compatible versions |
| Application (deployed on a server) | == exact |
Reproducibility matters most |
| Your Lorenz project | >= minimum |
It’s a library — keep it flexible |
Common Error
Exact pins (==) in a library cause dependency conflicts when users have other packages that need different versions.
Some dependencies are only needed for development or special features:
Install them with bracket syntax:
This keeps your package lightweight by default — users only install what they need.
Spot the Errors
This pyproject.toml has 3 errors. Can you find them?
Errors:
name = "lorenz_project" — package names on PyPI use hyphens, not underscores → "lorenz-project"version = "1" — not valid semver. Should be "1.0.0" (or "0.1.0" for a new project)numpy == 1.26.4 — exact pin in a library causes dependency conflicts → use "numpy >= 1.22"python -m to a Real CommandRight now you run your ensemble script like this:
Entry points call a function, so we need to wrap your script’s code in main():
Before (Week 5.5):
# run_lorenz_ensemble.py
from lorenz_project.lorenz63 import Lorenz63
from lorenz_project.plotting import (
plot_ensemble_panels,
)
import numpy as np
model = Lorenz63()
ics = model.perturbed_ic(n=30)
ensemble = model.run_ensemble(ics, 0.01, 5000)
fig = plot_ensemble_panels(ensemble, 0.01)
fig.savefig("lorenz_ensemble.png")
print("Done!")After (today):
# run_lorenz_ensemble.py
from lorenz_project.lorenz63 import Lorenz63
from lorenz_project.plotting import (
plot_ensemble_panels,
)
import numpy as np
def main():
model = Lorenz63()
ics = model.perturbed_ic(n=30)
ensemble = model.run_ensemble(
ics, 0.01, 5000,
)
fig = plot_ensemble_panels(
ensemble, 0.01,
)
fig.savefig("lorenz_ensemble.png")
print("Done!")
if __name__ == "__main__":
main()Two changes: (1) wrap code in def main():, (2) add if __name__ guard (review from Week 5.5). The entry point calls main() directly.
Common Error: “command not found”
If run-lorenz isn’t found:
pip install -e . from the repo root (where pyproject.toml is)?which run-lorenz to see if it was installedThe most important command for development:
What -e (editable) does:
site-packages/, it creates a link to your source directory.py files take effect immediately — no reinstall neededWithout -e:
site-packages/lorenz_project/ ← frozen copy, doesn't see your edits
With -e:
site-packages/lorenz_project → ~/lorenz-project/lorenz_project/ (symlink)
You edit a file, the next import picks up the change.
After pip install -e ., run these four checks:
# 1. Is it installed?
pip show lorenz-project
# 2. Can Python import it from anywhere?
cd /tmp
python -c "from lorenz_project import Lorenz63; print('OK')"
# 3. Does the CLI command work?
run-lorenz
# 4. Is it the editable version? (should show your local path)
pip show lorenz-project | grep LocationIf all four pass, your package is correctly installed. You can now import lorenz_project from any script, notebook, or directory on your machine.
When you’re ready to share your package (not just develop locally), build it:
This creates two files in dist/:
dist/
├── lorenz_project-0.1.0-py3-none-any.whl ← wheel (fast install)
└── lorenz_project-0.1.0.tar.gz ← sdist (source archive)
| Format | What it is | Used for |
|---|---|---|
Wheel (.whl) |
Pre-built, ready to install | pip uses this first — fast |
sdist (.tar.gz) |
Source code archive | Fallback if wheel doesn’t match |
Both get uploaded to PyPI. pip prefers the wheel because it’s faster to install.
Common Error
python -m build fails. Three common causes:
1. Wrong directory — you must run python -m build from the folder that contains pyproject.toml:
2. Missing __init__.py — your package folder must have one:
lorenz_project/
├── __init__.py ← if this is missing, build may succeed
│ but import will fail
PyPI (pypi.org) is where pip install downloads packages from.
| TestPyPI | Real PyPI | |
|---|---|---|
| URL | test.pypi.org | pypi.org |
| Purpose | Practice uploads | Production releases |
| Packages are permanent? | No — can delete | Yes — cannot delete a version |
pip install uses by default? |
No | Yes |
Today we use TestPyPI. It’s identical to the real thing but designed for experimentation. You can upload, delete, and re-upload as many times as you want.
pypi- and you’ll only see it onceSave it in a file pip can use:
Never commit .pypirc to git. It contains your secret token.
twine is the standard tool for uploading packages to PyPI:
If successful, you’ll see:
Uploading lorenz_project-0.1.0-py3-none-any.whl
Uploading lorenz_project-0.1.0.tar.gz
View at: https://test.pypi.org/project/lorenz-project/0.1.0/
Now anyone can install your package:
Why two URLs?
--index-url → look for your package on TestPyPI--extra-index-url → look for dependencies (numpy, matplotlib) on real PyPITestPyPI doesn’t have numpy — so without the second URL, pip can’t install your dependencies.
Common Error: TestPyPI Dependency Failures
If you see “No matching distribution found for numpy” — you forgot --extra-index-url. TestPyPI only has packages uploaded there, not the full PyPI catalog.
When you’re ready for the real thing:
| TestPyPI (today) | Real PyPI (later) | |
|---|---|---|
| Upload command | twine upload --repository testpypi dist/* |
twine upload dist/* |
| Install command | pip install --index-url https://test.pypi.org/simple/ ... |
pip install lorenz-project |
| Can delete versions? | Yes | No — a version is forever |
| Name collisions? | Common (it’s shared) | Rare (first-come-first-served) |
| When to use | Testing, class exercises | Stable releases others will depend on |
Golden rule: once you upload v0.1.0 to real PyPI, that version number is permanently taken, even if you delete the project. Always test on TestPyPI first.
Verification Checklist
Before moving on, confirm you can check off every item:
pip install -e . succeeds from repo rootpython -c "from lorenz_project import Lorenz63" works from /tmprun-lorenz command works from home directorypython -m build creates two files in dist/twine upload --repository testpypi dist/* succeedspip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ lorenz-project-yourCUusername works in a fresh environment| pip | conda | |
|---|---|---|
| Installs | Python packages only | Python, C, Fortran, R, … anything |
| Gets packages from | PyPI | conda-forge, defaults channel |
| Dependency solver | Basic (improving) | Full SAT solver |
| Virtual environments | venv (Python only) |
conda env (any language) |
| Can install compilers? | No | Yes |
| Written in | Python | Python (but manages non-Python packages) |
Short version: pip handles Python code. conda handles everything — including compiled libraries that Python packages depend on.
Some packages need compiled C/Fortran libraries that pip can’t install:
cartopy → needs GEOS, PROJ (C libraries for map projections)
netCDF4 → needs HDF5, netCDF-C (C libraries for file I/O)
scipy → needs LAPACK, BLAS (Fortran linear algebra)
eccodes → needs ecCodes (C library from ECMWF for GRIB files)
With pip:
conda has channels — repositories of packages:
| Channel | Maintained by | Packages | Quality |
|---|---|---|---|
defaults |
Anaconda Inc. | ~8,000 | Commercial-curated |
conda-forge |
Community volunteers | ~25,000+ | Community-reviewed |
Always use conda-forge for scientific work:
Almost every scientific Python package is on conda-forge. If it’s on PyPI, someone has probably packaged it for conda-forge too.
The journey from your laptop to conda install:
1. You publish your package to PyPI (pip installable)
│
▼
2. You write a conda recipe (meta.yaml)
— describes how to build from PyPI source
│
▼
3. Submit a PR to conda-forge/staged-recipes on GitHub
— community reviewers check your recipe
│
▼
4. PR merged → conda-forge bots auto-build for
Linux, macOS, Windows (all architectures)
│
▼
5. Anyone can now run: conda install -c conda-forge lorenz-project
Key insight: conda-forge usually builds from your PyPI package. So get your package on PyPI first.
Here’s what a conda-forge recipe looks like for your Lorenz project:
package:
name: lorenz-project
version: "0.1.0"
source:
url: https://pypi.org/packages/source/l/lorenz-project/lorenz_project-0.1.0.tar.gz
sha256: <sha256-hash-of-your-sdist>
build:
noarch: python
number: 0
script: python -m pip install . --no-deps --no-build-isolation
requirements:
host:
- python >= 3.9
- pip
- setuptools >= 68.0
run:
- python >= 3.9
- numpy >= 1.22
- matplotlib >= 3.5
test:
imports:
- lorenz_project
about:
home: https://github.com/yourusername/lorenz-project
license: MIT
summary: Lorenz63 model integration and ensemble analysisKey fields:
noarch: python — pure Python, one build works on all platformsrequirements.host — packages needed at build timerequirements.run — packages needed at install time (mirrors your pyproject.toml dependencies)Which benefits most from conda-forge?
Which of these packages would benefit most from being on conda-forge (vs just on PyPI)?
A. A pure-Python statistics calculator
B. A weather data downloader that uses requests
C. A geospatial analysis tool that depends on GDAL, PROJ, and GEOS
D. A simple file format converter
Answer: C. GDAL, PROJ, and GEOS are complex C libraries that are painful to install with pip. conda handles them automatically. The other packages are pure Python and work fine with just pip.
When should you use pip vs conda?
Is your package pure Python (no C/Fortran)?
├── YES → PyPI (pip) is usually enough
│ └── Want scientists on conda to find it easily?
│ └── YES → also add to conda-forge
│ └── NO → PyPI is fine
│
└── NO → conda-forge is important
└── Has complex compiled dependencies (GDAL, HDF5, etc.)?
└── YES → conda-forge is essential
└── NO → conda-forge is still nice to have
For your Lorenz project: it’s pure Python, so PyPI is sufficient. If you wanted to add it to conda-forge later, you could.
Every installable package needs a README. Here’s a minimal template:
Or from source:
MIT ```
This README gets rendered on your PyPI page. It’s the first thing people see.
Before you consider your package “release-ready”:
pyproject.toml exists at repo root with correct metadataREADME.md has installation instructions and a quick start exampleLICENSE file exists (MIT is a safe default)__init__.py exists in your package folder0.1.0)pip install -e . works and you can import from another directoryrun-lorenz from any directory)python -m build creates both .whl and .tar.gzAll students (4815 + 5815):
pyproject.toml to your lorenz-project repo with:
[build-system], [project] sectionsrun-lorenz)README.md with installation instructions and quick startLICENSE file (MIT)run_lorenz_ensemble.py in a main() functionpip install -e . succeedsrun-lorenz works from your home directorypython -m build creates dist/ with .whl and .tar.gzGraduate students (5815) bonus:
meta.yaml conda-forge recipe skeleton (does not need to build, but should have correct structure and fields)| Component | Points |
|---|---|
pyproject.toml correct and complete |
20 |
README.md with install + quick start |
10 |
LICENSE file present |
5 |
Entry point works (run-lorenz) |
15 |
| Editable install works | 10 |
python -m build succeeds |
10 |
| TestPyPI upload + install | 20 |
| Code quality and commit messages | 10 |
| Total | 100 |
| Grad bonus: meta.yaml skeleton | +10 |
pyproject.toml is the single file that makes your code pip-installablepip install -e .) lets you develop and test without reinstallingThis week’s lab:
meta.yaml skeletonFinal project:
The final project starts from today’s lab. The packaging skills you learn today are directly required.
Remember:
Instructor: Will Chapman Email: wchapman@colorado.edu Office Hours: Tu 11:15-12:15p, Th 9-10a (Aerospace Cafe)
TA: Aiden Pape Office Hours: M/W 3:30-4:30p (DUAN D319)

ATOC 4815/5815 - Week 10