URSSI
(The answers come later)
import?pip install it?This module merges three previous URSSI school modules:
It also draws on:
People heroically press forward, but this is painful, and not reusable.
In this first scenario, you will probably see a lot of sys.path manipulation and a utils.py:
$ tree examples/edit_sys_path
examples/edit_sys_path
├── code
│ └── utils.py # helper functions rosen, rosen_der
├── example.py # want to import rosen, rosen_der
└── jupytext.toml
1 directory, 3 files
# example.py
import sys
from pathlib import Path
# Make ./code/utils.py visible to sys.path
# sys.path[1] should be after cwd and before virtual environment
sys.path.insert(1, str(Path(__file__).parent / "code"))
from utils import rosen, rosen_der
x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])
result = minimize(rosen, x0, method="BFGS",
jac=rosen_der, options={"disp": True})
optimized_params = result.x
# array([1.00000004, 1.0000001 , 1.00000021, 1.00000044, 1.00000092])and are brittle to refactoring and change.
But we can do much better.
Module: an object that serves as an organizational unit of Python code. Modules have a namespace containing arbitrary Python objects. Modules are loaded into Python by the process of importing.
Deconstructing that definition:
import modulename I am loading a module.
numpy.random.default_rngBecause it’s good for your code to be modular.
Image credit: realpython.com/python-modules-packages
.py
pip install or conda install
Now that we spent all that time defining module, we can finally define package:
“A Python module which can contain submodules or, recursively, subpackages.”
Two main usages — see distribution package vs. import package.
This is the one we already talked about.
The thing you get when you write import packagename in your code.
We’re going to show you how to make one of these first.
The actual artifact that gets downloaded off the internet and stored on your computer somewhere — like when you run pip install packagename.
We need to learn how to make one of these too!
mypackage/ # the project folder
└── src/
└── mypackage/ # the package (importable)
├── __init__.py # makes it a package; defines the public API
├── stats.py # a module
└── helpers.py # another module
.py file with definitions (functions, classes, …)__init__.pypip“Package” gets used for both the importable folder and the thing on PyPI. Usually it’s clear from context.
Fame, glory
$$$
Por amor al arte
Two common cases for research code:
This kind of package does not necessarily need all the infrastructure you will learn about in this school — such as its own rendered documentation website.
If someone uses the code for your paper, they will get a copy of the code and set it up so they can import it the same way; they don’t expect to pip install paper.
For more on projects and research compendia in general, see:
This is what we’re here (mostly) to learn about!
This is when we want to make a “distribution package” too:
pip install awesometoolWe covered this in managing your computational environment — here is the one rule that matters once we start packaging:
Always install your package into an active environment — one per project.
That isolates each project’s dependencies (and your in-development package itself) from every other project and from the system Python: no clashes, no “works on my machine”. venv, the conda family, and uv all give you this.
import?The simplest possible Python package:
simple
└── simple/
└── __init__.py
__init__.py
__init__.py file tells Python that “this directory is a package”import my own code?This is enough for me to be able to import the package — as long as I’m in the right directory.
.py).py file, or a directory with an __init__.py, that has the name we’re importing.”How do I make it so I can
pip install simpleimport simple without being in the right directory?Note that this is the code I have locally — we aren’t talking about distribution packages yet!
We need one more file: a pyproject.toml.
pyproject.toml# This is a TOML document
title = "TOML Example"
[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00
[database]
enabled = true
ports = [ 8000, 8001, 8002 ]
data = [ ["delta", "phi"], [3.14] ]
temp_targets = { cpu = 79.5, case = 72.0 }
[servers]
[servers.alpha]
ip = "10.0.0.1"
role = "frontend"
[servers.beta]
ip = "10.0.0.2"
role = "backend"pip install .import it — from anywhere!Scenario: Sampson is a computational dog scientist.
Sampson has a set of functions they are using across all their projects, so they wrap them up in a package, dogpy.

Sampson’s project dogpy has:
src directory (for “source code”)src, a directory named dogpypyproject.toml fileWhat’s different here?

Sampson’s project dogpy has:
src/ layoutpyproject.toml; Python can import it straight from the project directory — including when you didn’t mean to.src/ layout, the only way to import it is to install it, so your tests run against the installed package, exactly as a user would get it.src, docs, tests.Both are valid. src/ is the safer default, and what we use below.
dogpy directory.py file): bark.pyfetchfetch sub-package contains two other modules: ball.py and bone.py__init__.py anyways?src/dogpy/fetch/__init__.py
import modules, functions, etc., hereInside your package, use relative imports so the package keeps working no matter where it is installed:
src/mypackage/solvers/linear.py
And use __init__.py to decide what your users see:
Short version: “flat is better than nested”
package.function, not package.subpackage.subsubpackage.functionnumpy.random.default_rngsklearn.model_selection.train_test_splitpip install it?We also need to define some more terms so that the rest of this makes sense.
setup.py?setup.py filesetuptoolssetup.py file at allThe long version: blog.ganssle.io/articles/2021/10/setup-py-deprecated.html
To move away from setup.py and setuptools, PEP 517 introduced the idea of “front-ends” and “back-ends”.
Mostly you shouldn’t have to think about this.
But in your pyproject.toml you will specify a build backend: the tool that knows how to take your “source tree” and make a distribution package.
Build backends (always) make two types of distribution package: a source distribution (“sdist”) and wheels.
.tar.gz)
pip uses this as a fallback if it can’t find a wheelconda, Homebrew.whl) of the file system structure and package metadata, with any dependencies prebuilt
You have to pick one — and there are a lot of them:
github.com/scientific-python/cookie
The good news: it has never been easier to point a package manager at some code and get it installed and running regardless of operating system or architecture. This is a small miracle.
hatchling or the classic setuptoolsscikit-build-core + pybind11pyproject.toml: how it gets builtThat’s it — the two lines that make your project buildable. Everything else in the file is metadata and tool configuration.
license = "MIT" is an SPDX identifier — a standard short code for a license, and the modern way to declare it since PEP 639. You’ll still see the older license = { text = "MIT" } and license classifiers in existing projects.
Optional dependencies (“extras”) that users opt into:
requires-python and dependenciesKnow about SPEC 0, which specifies what versions of Python the core scientific packages support.
Usually you don’t want upper bounds (>=3.10,<4.0) on Python or your dependencies: iscinumpy.dev/post/bound-version-constraints
Pin loosely (>=) for a library, so it plays nicely with other people’s projects.
Also worth adding: keywords, classifiers, and [project.urls] — these fill out your PyPI page.
pyproject.toml: configuring your toolsThe same file configures your dev tools — no more one .cfg per tool:
One file for packaging and your linter, formatter, test runner, type checker. It is the center of a modern Python project.
and now you can use it anywhere the environment is active:
Build backends allow for editable installs:
Editable installs add the files in your development directory to Python’s import path, so you can develop your code under src/ and have immediate access to it. (You only need to reinstall if you change the project metadata.)
That’s a real package. Everything after this is refinement.
A package can nest subpackages (each with its own __init__.py) and ship non-code files right alongside the modules.
Small reference data can travel inside the wheel. Large data (gigabytes) is better downloaded on demand.
In the wild: ClimKern’s radiative kernels are multi-GB, so it downloads them from Zenodo (python -m climkern download) instead of bundling them.
main() idiomPut your logic in functions, then guard the entry point so the file can be imported and run directly:
import mypackage.cli → defines main(), runs nothingpython -m mypackage.cli → __name__ == "__main__", so main() fires[project.scripts]Want your package to provide a terminal command? Point an entry point at a function:
(A deeper dive — skip if your project is pure Python.)
$ tree examples/compiled_packaging
examples/compiled_packaging
├── CMakeLists.txt # Addition of CMake
├── LICENSE
├── pyproject.toml # build backend change
├── README.md
├── src
│ ├── basic_math.cpp # C++ extension
│ └── rosen_cpp
│ ├── example.py
│ └── __init__.py
└── tests
└── test_example.py
3 directories, 8 filespyproject.toml, using scikit-build-core + pybind11:
# Specify CMake version and project language
cmake_minimum_required(VERSION 3.15...3.30)
project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX)
# Setup pybind11
set(PYBIND11_FINDPYTHON ON)
find_package(pybind11 CONFIG REQUIRED)
# Add the pybind11 module to build targets
pybind11_add_module(basic_math MODULE src/basic_math.cpp)
install(TARGETS basic_math DESTINATION ${SKBUILD_PROJECT_NAME})src/basic_math.cpp:
Installing locally is the same as for the pure-Python example:
The module name is the one given in C++:
If your code is publicly available in a Git repository, you’ve already done a version of distribution!
# Works for pure-Python packages
$ python -m pip install --upgrade "git+https://github.com/scikit-hep/pyhf.git"
# as well as packages with compiled extensions
$ python -m pip install --upgrade "git+https://github.com/scikit-hep/iminuit.git"
# General pattern (a specific branch, even)
$ python -m pip install "mypackage @ git+https://example.com/repo.git@branch"Great for trying a branch or an unreleased fix — but for users we want something tidier.
Rely on your build backend (e.g., hatchling) plus a build frontend like build:
$ python -m pip install --upgrade build
$ python -m build .
* Creating venv isolated environment...
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Building wheel...
Successfully built mypackage-0.1.0.tar.gz and mypackage-0.1.0-py3-none-any.whl
$ ls dist
mypackage-0.1.0-py3-none-any.whl mypackage-0.1.0.tar.gzUpload the files in ./dist/ to the Python Package Index — pip’s default index — and now anyone can pip install mypackage.
Historically you’d run twine upload. There is now a better way — see “What’s new in 2026”.
pip isn’t enough: the conda familySome scientific packages depend on compiled, non-Python libraries (Fortran/C++ wrappers, GPU stacks) that aren’t on PyPI — so pip install alone can’t get you there.
The conda family (conda, mamba, pixi) installs prebuilt binaries: Python and compiled libraries (compilers, Fortran, even the full CUDA stack). Grab those first, then pip:
The trade-off vs. pip: with sdists and wheels, if there’s no compatible wheel, pip falls back to building from source. There is no such fallback if no matching .conda binary exists.
To let users conda install your package, submit a recipe to conda-forge/staged-recipes:
meta.yaml (name, version, source URL and hash, dependencies) — tools like grayskull generate it from your PyPI releasePublish to PyPI first — most recipes just point at your PyPI sdist, and conda-forge keeps itself in sync from there.
We have been reasonably assuming that packaged code will be used in arbitrary environments alongside other compatible code — that is, it is library-like.
For distributing code, that is probably the correct view. But your analysis is not a library: your analysis is an application, a hand-crafted assembly of code from libraries.
While your analysis might run with many configurations of those libraries (reusable), you also want a hash-level specified version for reproducibility: a lock file.
Lock files are simple: a hash-level record of every dependency in the environment. They allow reproducibility by being, in effect, a list of every file to download.
They should be programmatically generated from a high-level requirements file, and kept in version control with your analysis.
The approach of pixi is similar to Julia:
Project.toml: describes the project at a high levelManifest.toml: absolute record of the state of the packages in the environment (a lock file)Julia’s package manager Pkg.jl gives users a high-level interface to edit Project.toml, then automatically updates Manifest.toml in response. Reproducibility of environment by default!
Project.toml in version controlProject.toml and Manifest.toml in version controlFull environment specification with Linux (Open Container Initiative) container images (Docker, Apptainer, Podman)
Orchestration of multiple environments inside an analysis, using workflow languages
The difficult realities of long-term preservation
What analysis reuse looks like — e.g. RECAST in particle physics
All the other stuff besides code that makes it easier for
# Package
`package` is a simple Python library that contains a single function
for rescaling arrays.
## Installation
Download the source code and use the package manager
[pip](https://pip.pypa.io/en/stable/) to install `package`:
`pip install .`
## Contributing
Pull requests are welcome. For major changes, please open an issue
first to discuss what you would like to change.
Please make sure to update tests as appropriate.
## License
TBDThings you want in your README:
You are giving other people permission to use your code.
A human-readable record of changes to your project.
Newest first, grouped by release, with the version numbers following semantic versioning (MAJOR.MINOR.PATCH) — see the documentation and versioning module.
The contributors list matters: when someone lands a PR, add them so they get credit beyond the commit log.
The open science module goes deep on this — here is the packaging hook.
Zenodo archives each GitHub release and mints a DOI:
tag a release → Zenodo mints a DOI → put it in your README and papers.
In the wild: ClimKern, 10.5281/zenodo.10291284

(the field has moved since the 2024 modules)
uv covers the whole lifecycleuv (from Astral, the Ruff folks) is a fast, Rust-based tool covering the whole lifecycle — an option, not a requirement:
twine upload.Use a template to set up your repository with best practices baked in:
scientific-python/cookie — templates for 11+ build backends, plus CI, linting, and docs scaffoldingcopier — like cookiecutter, but lets you re-apply template updates to an existing project laterPackaging best practices keep changing (for the better). Instead of maintaining your own lore, follow and engage with the teams building the tools.
Find and start working with Research Software Engineers (RSEs).
sys.pathpyproject.toml is the one file at the center of it all: build backend, metadata, toolspip install ituv, SPDX licenses, standardized lock files, and Trusted Publishing make it smoother than everCopy the mypackage skeleton and you’ve already started.
cookie, Scientific Pythonuv documentation