Packaging Your Python Code — Week 10
CU Boulder ATOC
Spring 2026
pyproject.toml that makes your project pip-installableLab 7 Due Tonight
Final Proposal Due Tonight
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 Doespip (“Pip Installs Packages”) is Python’s package installer — the tool that downloads and installs packages from the internet into your environment. You’ve been using it all semester (pip install xarray, pip install cartopy). Today you’ll learn how to make your own code installable the same way.
When 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. when done, you can `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. The authors added packaging metadata, uploaded to PyPI, and now anyone can install them.
Your lorenz_project is 90% of the way there. Our last lab will add the final 10%.
Right now, sharing your Lorenz63 code with a classmate means sending files, hoping their paths match, and debugging their environment. After today:
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. It’s just key-value pairs and tables — you’ll recognize the syntax immediately.
Here’s a complete, working pyproject.toml for your Lorenz project:
[build-system]
requires = ["setuptools >= 61.0"]
build-backend = "setuptools.build_meta"
[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.
The file has three sections. [project] is the only one you edit regularly — the others are boilerplate you copy once and forget.
| Section | Key | What it does | First-timer note |
|---|---|---|---|
[build-system] |
requires |
Which tool pip uses to package your code | Always setuptools — just copy this block |
build-backend |
The internal entry point for that tool | Boilerplate — don’t change it | |
[project] |
name |
Your package’s name on PyPI | Use hyphens, must be unique across all of PyPI |
version |
Version number | Start at "0.1.0", bump when you make changes |
|
description |
One-line summary | Shows up on your PyPI page | |
readme |
Path to your README | PyPI renders this as your project homepage | |
license |
Which open-source license | {text = "MIT"} is a safe default |
|
requires-python |
Oldest Python version you support | ">= 3.9" is a reasonable floor |
|
authors |
Your name and email | Shows on PyPI | |
dependencies |
Other packages your code imports | pip installs these automatically for your users | |
[project.optional-dependencies] |
dev |
Packages only you need (build tools, test runners) | Users don’t get these unless they ask for [dev] |
[project.scripts] |
run-lorenz |
Maps a terminal command to a Python function | After install, typing run-lorenz calls main() |
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: use >= minimum for libraries (your Lorenz project), == exact only for deployed applications where reproducibility is critical.
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.
The 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 — open ~/.pypirc in your editor:
Paste this content:
username must be the literal string __token__. password is your API token (starts with pypi-).
Before building: TestPyPI is shared — append your CU username to avoid name collisions.
In pyproject.toml:
Then build and upload:
If successful, you’ll see:
Uploading lorenz_ens_yourCUusername-0.1.0-py3-none-any.whl
View at: https://test.pypi.org/project/lorenz-ens-yourCUusername/0.1.0/
Upload troubleshooting
403 Forbidden? Try these in order:
1. keyring may be intercepting your credentials — uninstall it:
2. Point twine directly at your .pypirc:
3. Skip .pypirc entirely and pass credentials inline:
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.
Six things that should all work at this point:
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-ens-yourCUusername works in a fresh environmentStart here — fork the starter repo:
main branch only” checked → Create forkAll students (4815 + 5815):
pyproject.toml to the repo root 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.gzSubmit on Canvas: paste two links — your GitHub repo URL and your TestPyPI package URL (e.g. https://test.pypi.org/project/lorenz-ens-yourCUusername/)
| 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 |
Submit on Canvas: GitHub repo URL + TestPyPI package URL
Warning
run-lorenz not found? Run pip install -e . from the repo root (where pyproject.toml lives), confirm your conda env is active, then open a fresh terminal.
| 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 >= 61.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)Not every package needs to be on conda-forge. The cases where it matters:
Pure Python (statistics calculator, file downloader, format converter) — PyPI is sufficient. pip install works fine.
Packages with compiled C/Fortran dependencies (GDAL, PROJ, GEOS, HDF5, LAPACK) — conda-forge is essential. pip can’t install those system libraries; conda can.
Rule: if your install instructions say “first install X with Homebrew/apt, then pip install…” — that’s a sign it belongs on conda-forge.
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:
# lorenz-project
Lorenz63 model integration and ensemble analysis for ATOC 4815/5815.
## Installation
```bash
pip install lorenz-project
```
Or from source:
```bash
git clone https://github.com/yourusername/lorenz-project.git
cd lorenz-project
pip install -e .
```
## Quick Start
```python
from lorenz_project import Lorenz63
model = Lorenz63(sigma=10, rho=28, beta=8/3)
trajectory = model.run([1, 1, 1], dt=0.01, n_steps=5000)
```
## Command Line
```bash
run-lorenz # generates lorenz_ensemble.png
```
## License
MITThis 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.gzpyproject.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