All in One View
Content from Writing Effective Functions
Last updated on 2026-11-06 | Edit this page
Overview
Questions
- When should I turn a block of code into a function?
- How do I design a function so someone else (or future-me) can use it correctly?
- What belongs in a docstring, and why write one at all?
Objectives
- Identify repeated or reusable logic that should be extracted into a function
- Write functions with clear positional arguments, defaults, and
*args/**kwargs - Add type hints to a function signature
- Write a docstring that documents a function’s purpose, arguments, and return value
Why write functions?
Open first_analysis.ipynb from the setup materials. It
loads a full-year of data from the OOI GI01SUMO
(Global Irminger Sea Array
Surface Mooring) NSIF
(Near-Surface
Instrument Frame) and computes
15-minute burst-median statistics for the CTD (temperature, salinity)
and the DOSTA (dissolved oxygen). It calculates the same statistics for
both instruments, with a six-line block copy-pasted and lightly edited
between them.
Challenge 1: Spot the repetition
Skim first_analysis.ipynb. Without changing any code
yet, identify (you can write a comment at the top of the file) which
blocks of code are doing “the same thing” to different data.
The CTD block and the DOSTA block: both convert an xarray
Dataset to a dataframe, resample it to 15-minute bins,
compute the median and the median absolute deviation (MAD) per bin, and
reassemble the two into one table. Only the input dataset changes
between them.
A good rule of thumb: if you’ve written the same logic twice, it should be a function. As you add instruments or expand your analysis, a bug fix means finding and fixing it in every copy. Functions turn that into one place.
Function anatomy
We’ll live-code the extraction together, pulling the burst-statistics logic out of the CTD block:
PYTHON
def resample_burst_stats(df, freq="900s"):
resampler = df.resample(freq, origin="start_day", offset=pd.Timedelta("3150s"))
median = resampler.median()
group_median = resampler.transform("median")
deviations = (df - group_median).abs()
mad = deviations.resample(freq, origin="start_day", offset=pd.Timedelta("3150s")).median()
stats = pd.concat([median, mad], axis=1, keys=["median", "mad"]).swaplevel(axis=1).sort_index(axis=1)
stats.index = stats.index + pd.Timedelta(freq) / 2
return stats
Key parts to narrate as you write:
-
Positional arguments (
df) vs. keyword arguments with defaults (freq="900s"): the burst interval defines the length of time between the start of collection intervals. It may change between instruments (but not here) - The return value: the original script only ever
printed or plotted
ctd_stats/dosta_stats; returning it is what lets the same function produce both -
*args/**kwargswould come in if, say, this needed to pass extra options through to.resample()without the function knowing about all of them in advance.
Challenge 2: Extract a function
Working from first_analysis.ipynb, extract the repeated
resampling block into resample_burst_stats and call it
twice: once for the CTD dataframe, once for the DOSTA dataframe.
PYTHON
def resample_burst_stats(df, freq="900s"):
resampler = df.resample(freq, origin="start_day", offset=pd.Timedelta("3150s"))
median = resampler.median()
group_median = resampler.transform("median")
deviations = (df - group_median).abs()
mad = deviations.resample(freq, origin="start_day", offset=pd.Timedelta("3150s")).median()
stats = pd.concat([median, mad], axis=1, keys=["median", "mad"]).swaplevel(axis=1).sort_index(axis=1)
stats.index = stats.index + pd.Timedelta(freq) / 2
return stats
ctd_stats = resample_burst_stats(ctd_df)
dosta_stats = resample_burst_stats(dosta_df)
Single responsibility
resample_burst_stats computes statistics. It doesn’t
load data and it doesn’t plot. The original notebook’s cells did all
three in sequence, which is fine for exploring, but bundling them
together is exactly what would stop this function from being reused and
limits its flexibility. We’ll revisit this when we split code into
modules in the next block.
Type hints
Type hints don’t change how Python runs your code. Rather, they’re documentation that your editor and static-analysis tools can check for you.
Challenge 3: Add type hints
Add type hints to resample_burst_stats. What type should
the return value be?
PYTHON
def resample_burst_stats(df: pd.DataFrame, freq: str = "900s") -> pd.DataFrame:
resampler = df.resample(freq, origin="start_day", offset=pd.Timedelta("3150s"))
median = resampler.median()
group_median = resampler.transform("median")
deviations = (df - group_median).abs()
mad = deviations.resample(freq, origin="start_day", offset=pd.Timedelta("3150s")).median()
stats = pd.concat([median, mad], axis=1, keys=["median", "mad"]).swaplevel(axis=1).sort_index(axis=1)
stats.index = stats.index + pd.Timedelta(freq) / 2
return stats
The return type is pd.DataFrame because both
median and the final stats table are
dataframes, and stats is what gets returned.
Docstrings
A docstring is the first statement inside a function, written as a
triple-quoted string. It’s what shows up when someone runs
help(resample_burst_stats).
first_analysis.ipynb already has one good example to look
at: fill_harmonic_gaps, which fills gaps in the
burst-median series using a linear-trend-plus-harmonic fit. It’s already
written as a clean, documented function. Use it as a reference
example.
PYTHON
def resample_burst_stats(df: pd.DataFrame, freq: str = "900s") -> pd.DataFrame:
"""Compute burst-median statistics for OOI instrument data.
OOI instruments often sample in short bursts; this
collapses each burst into a robust summary statistic.
Parameters
----------
df : pandas.DataFrame
Time-indexed instrument data, one column per variable, at full sampling resolution.
freq : str, default "900s"
Burst interval as a pandas offset string.
Returns
-------
pandas.DataFrame
Columns are a 2-level MultiIndex (variable, statistic), where
statistic is one of "median" or "mad" (median absolute deviation).
Index is the start time of each burst.
"""
...
We’re using the NumPy docstring style here since it’s common in scientific Python, but the important content is the same regardless of style: what the function does, what each argument means, and what comes back.
Challenge 4: Document your function
Add a docstring to resample_burst_stats. Then, in a
notebook cell, run help(resample_burst_stats) and confirm
it displays what you expect. Compare it to
fill_harmonic_gaps’s docstring already in the file.
Wrap-up
By the end of this block, learners should have
resample_burst_stats extracted, type-hinted, and
documented, alongside the already-clean fill_harmonic_gaps.
These two functions are what Block 2 organizes into a module.
- Extract a function when you find yourself repeating logic, not just to look tidy
- Prefer returning values over printing them, so functions can be composed
- Type hints document expected input/output types for editors and readers
- A docstring documents purpose, parameters, and return value. Its often best to write it as part of writing the function, not as an afterthought
Content from Organizing Functions into Modules
Last updated on 2026-11-06 | Edit this page
Overview
Questions
- When does a collection of functions become a module?
- How do I use my own modules from a Jupyter notebook, not just a script?
- How should I decide what becomes a module, and what doesn’t?
Objectives
- Explain the difference between a script, a module, and a package
- Import a local module into a running notebook, and pick up edits without restarting the kernel
- Split a set of related functions into modules by responsibility, not by dataset
- Recognize when not to make something a module
Why modules?
By the end of Block 1, first_analysis.ipynb has two good
functions in it: resample_burst_stats and
fill_harmonic_gaps. But they’re still living in notebook
cells. That means that if you want to reuse these functions, such as
resample_burst_stats, in a different notebook you would
have to copy the function out of this one by hand.
Instead, we are going to put the functions into a
module. A module is a single .py file that
other code can import. This is how we make a function
reusable outside the notebook that first needed it.
Challenge 1: From notebook cell to module
Create a new file, stats.py, next to
first_analysis.ipynb, and move
resample_burst_stats and fill_harmonic_gaps
into it, along with whatever import lines they need
(numpy, pandas).
PYTHON
# stats.py
"""Burst-statistics and gap-filling for OOI instrument time series."""
import numpy as np
import pandas as pd
def resample_burst_stats(df: pd.DataFrame, freq: str = "900s") -> pd.DataFrame:
"""Compute burst-median statistics for OOI instrument data.
OOI instruments often sample in short bursts; this
collapses each burst into a robust summary statistic.
Parameters
----------
df : pandas.DataFrame
Time-indexed instrument data, one column per variable, at full sampling resolution.
freq : str, default "900s"
Burst interval as a pandas offset string.
Returns
-------
pandas.DataFrame
Columns are a 2-level MultiIndex (variable, statistic), where
statistic is one of "median" or "mad" (median absolute deviation).
Index is the start time of each burst.
"""
resampler = df.resample(freq, origin="start_day", offset=pd.Timedelta("3150s"))
median = resampler.median()
group_median = resampler.transform("median")
deviations = (df - group_median).abs()
mad = deviations.resample(freq, origin="start_day", offset=pd.Timedelta("3150s")).median()
stats = pd.concat([median, mad], axis=1, keys=["median", "mad"]).swaplevel(axis=1).sort_index(axis=1)
stats.index = stats.index + pd.Timedelta(freq) / 2
return stats
def fill_harmonic_gaps(series: pd.Series, periods=("365.25D", "182.625D")):
"""Fill gaps in a time-indexed Series with a trend + harmonic fit.
Parameters
----------
series : pandas.Series
Time-indexed values, with NaNs marking gaps to fill.
periods : tuple[str, ...], default ("365.25D", "182.625D")
Harmonic periods to fit. Defaults are annual and semiannual cycles.
Returns
-------
filled : pandas.Series
fitted : pandas.Series
"""
t = (series.index - series.index[0]) / pd.Timedelta("1s")
omegas = [2 * np.pi / (pd.Timedelta(p) / pd.Timedelta("1s")) for p in periods]
X = np.column_stack([
np.ones_like(t),
t,
*[f(omega * t) for omega in omegas for f in (np.cos, np.sin)],
])
valid = series.notna().values
coeffs, *_ = np.linalg.lstsq(X[valid], series.values[valid], rcond=None)
fitted = pd.Series(X @ coeffs, index=series.index)
filled = series.where(valid, fitted)
return filled, fitted
Importing your own module into a notebook
With stats.py sitting next to
first_analysis.ipynb, a notebook cell can import it exactly
like any other package:
autoreload: editing a module without restarting the kernel
By default, once a notebook has imported a module, editing that
module’s .py file and re-running the import cell does
nothing because Python caches the module after the first import. Thus
any changes you made in the module will not be picked up by the
notebook/script/other module you are using.
The fix is two “magic” commands at the top of the notebook, before any of your own imports:
With this turned on, editing stats.py in your editor and
re-running a cell that calls resample_burst_stats(...)
picks up the change immediately. This significantly improves the use of
notebooks for initial explorations and development, then transfering the
changes and results into reuseable functions and modules.
Note: don’t shadow the standard library
Watch what happens if a module happens to share a name with something in Python’s standard library. In an empty scratch folder:
BASH
echo "pass" > random.py
uv run --no-project --with numpy python -c "import numpy as np; np.random.rand(3)"
OUTPUT
ImportError: cannot import name 'SystemRandom' from 'random' (/.../random.py)
In the above code, NumPy tried to import random
internally and instead of getting the standard-library modules, it
picked up your empty file. Some good module names to avoid are:
random.py, csv.py, json.py,
types.py, and test.py.
Deciding what becomes a module
It’s tempting to make a module for every conceptual piece of the workflow. Let’s take a look at what a module for loading the data in this workflow would actually contain:
One line, wrapping one library call, with nothing project-specific in it. A module built around this doesn’t save future-you any real work because ``xarray.open_dataset``` already is one line. Modularize logic that’s substantial, reusable, or repeated.
What does deserve a module in our notebook? The plotting code. Scroll
through first_analysis.ipynb and count how many
nearly-identical plotting cells it has. This is the type of repeated
code that makes sense as a function and a module.
Challenge 2: Extract a plotting module
Pull the repeated plotting logic out of
first_analysis.ipynb into plotting.py, with
one function per distinct kind of plot (not one function per cell). Aim
for something like:
-
plot_raw_timeseries(ctd, dosta, t1=None, t2=None)- the raw temperature/salinity/oxygen plots (used at three different zoom levels in the notebook)
-
plot_burst_stats(raw, stats, var, label, color, t1=None, t2=None)- raw points with the burst-median ± 2·MAD band
Don’t worry about creating full docstrings for these particular functions. Although you should always add docstrings on your personal code, for this example we can bypass that requirement.
PYTHON
# plotting.py
"""Plotting helpers for OOI CTD/DOSTA burst-statistics time series."""
import matplotlib.pyplot as plt
def plot_raw_timeseries(ctd, dosta, t1=None, t2=None):
"""Plot raw (unresampled) CTD temperature/salinity and DOSTA oxygen."""
# Subset the data based on the window to look at
window = slice(t1, t2)
ctd_w = ctd.sel(time=window)
dosta_w = dosta.sel(time=window)
# Plot the first subplot
fig, (ax, axb) = plt.subplots(2, 1, figsize=(14, 12), sharex=True)
ax.plot(ctd_w["time"], ctd_w["sea_water_temperature"], color="tab:red",
linestyle="", marker=".", label="Temperature (°C)")
ax.set_ylabel("Temperature (°C)", color="tab:red")
ax.set_title("Irminger Sea Temperature and Salinity at 12m Depth")
# Create a second y-axis on the first subplot
ax2 = ax.twinx()
ax2.plot(ctd_w["time"], ctd_w["sea_water_practical_salinity"], color="tab:blue",
linestyle="", marker=".", label="Salinity (PSU)")
ax2.set_ylabel("Salinity (PSU)", color="tab:blue")
ax.grid()
ax2.grid()
# Plot the second subplot
axb.plot(dosta_w["time"], dosta_w["oxygen_concentration_corrected"], color="tab:green",
linestyle="", marker=".", label="Oxygen (µmol/kg)")
axb.set_ylabel("Oxygen (µmol/kg)", color="tab:green")
axb.grid()
fig.autofmt_xdate()
return fig
def plot_burst_stats(raw, stats, var, label, color, t1=None, t2=None):
"""Plot raw points against burst-median +/- 2*MAD for one variable."""
# Subset the data based on the window to look at
window = slice(t1, t2)
raw_w = raw.sel(time=window)
median = stats[var]["median"].loc[window]
mad = stats[var]["mad"].loc[window]
# Plot the subsetted data
fig, ax = plt.subplots(1, 1, figsize=(14, 6))
ax.plot(raw_w["time"], raw_w[var], color=color, linestyle="", marker=".",
alpha=0.4, label="Raw")
ax.plot(median.index, median, color=color, label="Burst median")
ax.fill_between(median.index, median - 2 * mad, median + 2 * mad,
color=color, alpha=0.3, linewidth=0, label="±2 MAD")
ax.set_ylabel(label)
ax.set_title(f"{label}: burst median ± 2 MAD")
ax.legend()
ax.grid()
fig.autofmt_xdate()
return fig
In the notebook the plotting cells collapse to:
PYTHON
plot_raw_timeseries(ctd, dosta)
plot_raw_timeseries(ctd, dosta, t1="2020-09-01", t2="2020-09-08")
plot_raw_timeseries(ctd, dosta, t1="2020-09-01T03:00:00", t2="2020-09-01T04:00:00")
This is much cleaner and easier to read.
Fixing real bugs while modularizing
Scroll to the “Merge and save” section at the bottom of
first_analysis.ipynb. It is unfinished, and if you run the
notebook from top to bottom it fails:
OUTPUT
NameError: name 'ctd_ds' is not defined
That one is a plain typo: the variable was named ds, not
ctd_ds, and the mistake went unnoticed because the notebook
was never re-run start to finish after that line was written.
There are two more problems waiting just past the end of the notebook. The merged table still has 2-level (variable, statistic) column names, and netCDF files can only store variables with plain string names:
OUTPUT
TypeError: Invalid name ('sea_water_temperature', 'mad') for DataArray or Dataset key: must be either a string or None for serialization to netCDF or zarr files
The notebook starts to flatten the column names, but only for the CTD table. If you merge that flattened CTD table with the DOSTA table, which still has 2-level columns, pandas refuses:
OUTPUT
MergeError: Not allowed to merge between different levels. (1 levels on the left, 2 on the right)
This is a common and useful side effect of turning exploratory code into functions: writing a function forces you to be explicit about inputs and outputs. If one function flattens a table and we call it for both instruments, the missing step becomes impossible to skip.
Challenge 3: Build a merge module and fix the bugs
Write merge.py with the following three functions: 1. A
function to flatten a stats table’s columns and add a
modeled_flag column 2. A function to merge the flattened
CTD and DOSTA tables and attach variable attributes (units, long names)
from the original datasets 3. A function to save the result as a
netCDF
Use them to merge the CTD variables
sea_water_temperature,
sea_water_practical_salinity, and
sea_water_pressure with the DOSTA variable
oxygen_concentration_corrected, and save the result. You
will need to fix the bugs described above along the way.
PYTHON
# merge.py
"""Merge CTD + DOSTA burst statistics into a dataset with metadata."""
import pandas as pd
import xarray as xr
# Attributes that describe each variable
VARIABLE_ATTRS = {
"sea_water_temperature": {"units": "degrees_Celsius", "long_name": "Sea Water Temperature"},
"sea_water_practical_salinity": {"units": "1", "long_name": "Sea Water Practical Salinity"},
"sea_water_pressure": {"units": "dbar", "long_name": "Sea Water Pressure"},
"oxygen_concentration_corrected": {"units": "umol kg-1", "long_name": "Corrected Dissolved Oxygen Concentration"},
}
# Attributes that describe the statistic that was computed
STAT_ATTRS = {
"median": {"comment": "Median of each 15-minute burst of samples"},
"mad": {"comment": "Median absolute deviation of each 15-minute burst of samples"},
}
# Attributes of the modeled flag, which is not in the units of the variable
FLAG_ATTRS = {"units": "1", "comment": "Indicates whether a value was observed (0) or modeled (1)"}
def flag_and_flatten(stats: pd.DataFrame, was_filled: pd.DataFrame, keep_vars: list[str]) -> pd.DataFrame:
"""Restrict to `keep_vars`, add a modeled_flag per variable, and flatten
the 2-level (variable, stat) columns into single flat names."""
stats = stats.loc[:, keep_vars].copy()
for var in keep_vars:
stats[(var, "modeled_flag")] = was_filled[var].astype(int)
stats.columns = ["_".join(col).strip() for col in stats.columns.values]
return stats
def merge_ctd_dosta(ctd_flat, dosta_flat):
"""Outer-merge flattened CTD and DOSTA tables and attach variable attributes."""
merged = pd.merge(ctd_flat, dosta_flat, left_index=True, right_index=True, how="outer")
merged.index.name = "time"
ds = merged.to_xarray()
for var in ds.data_vars:
if var.endswith("_modeled_flag"):
ds[var].attrs.update(FLAG_ATTRS)
continue
for stat, stat_attrs in STAT_ATTRS.items():
if var.endswith(f"_{stat}"):
base_name = var[: -(len(stat) + 1)]
ds[var].attrs.update(VARIABLE_ATTRS.get(base_name, {}))
ds[var].attrs.update(stat_attrs)
break
return ds
def save_merged_dataset(ds, path):
"""Write a merged dataset to netCDF, with a couple of dataset-level attrs."""
ds.attrs.setdefault("source", "OOI GI01SUMO near-surface instrument frame (CTD + DOSTA)")
ds.attrs.setdefault("processing", "15-minute burst median/MAD; gaps filled with linear trend + harmonic fit")
ds.to_netcdf(path)
Note that both CTD and DOSTA go through flag_and_flatten
now, which is what the original notebook was missing:
PYTHON
ctd_flat = flag_and_flatten(ctd_stats, was_filled,
["sea_water_temperature", "sea_water_practical_salinity", "sea_water_pressure"])
dosta_flat = flag_and_flatten(dosta_stats, dosta_was_filled,
["oxygen_concentration_corrected"])
merged_ds = merge_ctd_dosta(ctd_flat, dosta_flat)
save_merged_dataset(merged_ds, "gi01sumo_merged.nc")
Turning modules into a package
Two or three loose .py files next to a notebook works
for now, but as soon as you want to share this, import this code into a
different notebook in a different folder or repo or, even better, put it
on PyPi and allow others to pip install your code, you will
need to create a package: a directory with an
__init__.py file.
mooring_tools/
├── __init__.py
├── stats.py
├── plotting.py
└── merge.py
__init__.py can be empty: its presence is what tells
Python mooring_tools is an importable package, and modules
inside it are addressed as mooring_tools.stats,
mooring_tools.plotting,
mooring_tools.merge:
PYTHON
from mooring_tools.stats import resample_burst_stats, fill_harmonic_gaps
from mooring_tools.plotting import plot_raw_timeseries, plot_burst_stats
from mooring_tools.merge import flag_and_flatten, merge_ctd_dosta, save_merged_dataset
Module vs. package
A module is a single .py file. A
package is a directory of modules with an
__init__.py. stats.py is a module;
mooring_tools/ (once it has an __init__.py) is
a package containing three modules.
Challenge 4: Notebook to package
Turn stats.py, plotting.py, and
merge.py into a mooring_tools/ package (add
the __init__.py), and update
first_analysis.ipynb’s import cell to pull from it
instead.
Wrap-up
By the end of this section, you should have a
mooring_tools/ package with three modules,
stats, plotting, merge, and a
notebook that imports from it with autoreload active. This
package is what the next section will turn into a repository with a
managed environment, READMEs, and setup files.
- A module is a single
.pyfile; a package is a directory of modules with an__init__.py -
%load_ext autoreload+%autoreload 2lets a notebook pick up edits to your own modules without a kernel restart - Python checks your notebook’s own directory before the standard
library, such that a module named
random.pyorcsv.pycan silently break unrelated imports elsewhere in your code - Not everything needs a module. Modularize logic that’s substantial or repeated
Content from Building a Repository and Managing Environments
Last updated on 2026-11-06 | Edit this page
Overview
Questions
- What actually belongs in a code repository?
- Why does each project need its own environment?
- How do I turn
mooring_tools/into somethingpip install-able?
Objectives
- Create a project with
uvand lay out a repository with asrc/package, notebooks, and data kept separate - Add dependencies to a project and explain what
pyproject.tomlanduv.lockeach record - Run a Jupyter notebook inside the project’s environment and import your own package from it
- Explain why a
src/layout catches packaging bugs that a flat layout hides
What goes in a repo
By the end of Block 2, mooring_tools/ is three modules
and an __init__.py, sitting next to
first_analysis.ipynb. That’s enough to import from this
notebook. It’s not enough to share with a colleague, or use from a
notebook anywhere else on your machine. For that, the package needs a
proper home:
gi01sumo-mooring-tools/
├── .gitignore
├── .python-version
├── LICENSE
├── README.md
├── pyproject.toml
├── uv.lock
├── src/
│ └── mooring_tools/
│ ├── __init__.py
│ ├── stats.py
│ ├── plotting.py
│ └── merge.py
├── notebooks/
│ └── first_analysis.ipynb
└── data/
├── gi01sumo_nsif_ctd.nc
└── gi01sumo_nsif_dosta.nc
A few things to note about the layout:
-
src/mooring_tools/, not justmooring_tools/as the repo root. -
notebooks/anddata/are separate from the package. The package is code meant to be imported; the notebook is one particular analysis that uses it. -
pyproject.tomlanduv.lockdescribe the project and the exact packages it was run with. Both are committed. We’ll build them up over this block. -
.python-versionrecords which version of Python the project is developed with. -
.gitignorekeeps generated/environment files out of version control, such as the venv folder,__pycache__/, and Jupyter checkpoint files. -
LICENSEstates what others are allowed to do with the code.
Creating the project with uv
You could create all of that by hand. Instead, we’ll let uv create the skeleton.
uv is one tool that replaces several you may have used
separately: it installs Python itself, creates virtual environments,
installs packages, and records what it installed. Confirm it’s
available:
Then create the project:
-
--libasks for a library layout: asrc/folder with a package inside it, ready to be imported by other code. -
--name mooring-toolsis the name the project is installed under. The folder is namedgi01sumo-mooring-tools, so we give the name explicitly. - uv also runs
git initfor you, so the folder is already a repository.
Two names for one thing
The project is called mooring-tools (with a hyphen) but
you import it as mooring_tools (with an underscore). The
first is the name used to install and publish it; the second is a Python
identifier, so it can’t contain a hyphen. uv turns one into
the other automatically. If you see
ModuleNotFoundError: No module named 'mooring-tools',
that’s the mix-up.
Challenge 1: Lay out the repository
- Run the
uv initcommand above, thencdinto the new folder and look at what it created (ls -a, ordiron Windows). -
uv initputs a placeholderhello()function insrc/mooring_tools/__init__.py. Replace that file with your__init__.pyfrom Block 2, and movestats.py,plotting.py, andmerge.pyintosrc/mooring_tools/. - Create
notebooks/anddata/, and movefirst_analysis.ipynband the two.ncfiles into them. (Update the data paths in the notebook to../data/....) - Add a line
data/to the.gitignorefile thatuv initcreated, so the large data files are never committed.
Why src/, specifically
Compare two layouts, both without the package actually installed:
BASH
# flat layout: mooring_tools/ directly at the repo root
cd flat-layout-repo
uv run --no-project python -c "import mooring_tools"
OUTPUT
(imports successfully — from mooring_tools/__init__.py right there in the folder)
BASH
# src layout: mooring_tools/ inside src/
cd src-layout-repo
uv run --no-project python -c "import mooring_tools"
OUTPUT
ModuleNotFoundError: No module named 'mooring_tools'
(--no-project tells uv not to build an environment from
a pyproject.toml, so that this really is “without the
package installed.”)
With a flat layout, Python finds the package because your current
directory is always searched first. That means
import mooring_tools can silently “work” from inside the
repo even if the package was never actually installed, or if the install
is broken. The bug only shows up later, when someone tries to use the
package from outside the repo and it’s nowhere to be found. With
src/, that failure happens immediately.
Environments
Every Python installation on your machine has one set of installed packages by default. If two projects need different, incompatible versions of the same library, installing globally means only one project can work at a time. A virtual environment gives each project its own isolated set of installed packages.
With uv you don’t create or activate one yourself. The first time you
add a dependency (next section), uv creates a .venv folder
in the project and installs into it. Every uv run ...
command then uses that environment automatically, from the project
folder or any folder inside it. There is no activate step
to forget, and no way to install into the wrong environment by
accident.
The .venv folder is
disposable
Nothing in .venv is precious. If it ever gets into a
confusing state, delete the folder and run uv sync.
uv will rebuild it from the project files. That is also why
.venv is in .gitignore: it’s machine-specific,
and fully recreatable.
Describing the Project: pyproject.toml
pyproject.toml, at the repo root, is the file that
describes your project to uv and to every other Python packaging tool.
It is written in TOML (Tom’s Obvious Minimal Language), a simple
key = value format. Open the one uv init
created:
TOML
[project]
name = "mooring-tools"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Your Name", email = "you@example.org" }
]
requires-python = ">=3.14"
dependencies = []
[build-system]
requires = ["uv_build>=0.11.32,<0.12.0"]
build-backend = "uv_build"
Two things need your attention before we go on. (Your version numbers and author will differ.)
-
requires-pythondefaults to the version of Python uv found on your machine. If yours is 3.14, a colleague on 3.11 would be told they cannot install your package, for no real reason. Our code runs fine on older versions, so relax it to">=3.10". -
descriptionshould say what the package does. Make it"Burst-statistics, plotting, and merge utilities for OOI GI01SUMO CTD/DOSTA data".
The [build-system] table tells tools how to turn
src/ into an installable package. uv fills it in; you don’t
need to edit it.
Adding dependencies
Our code needs xarray, netCDF4,
pandas, numpy, and matplotlib.
Add them with:
That one command does three things:
- creates
.venvif it doesn’t exist yet, - installs the packages (and everything they depend on) into it
- records them in two places.
Look at pyproject.toml again:
TOML
dependencies = [
"matplotlib>=3.10.9",
"netcdf4>=1.7.3",
"numpy>=2.2.6",
"pandas>=2.3.3",
"xarray>=2025.6.1",
]
uv has written a minimum version for each one: the version it installed, or the oldest version that works with the oldest Python you allow. This says what your code needs to run at all, loose enough not to fight with other projects.
For notebooks, add JupyterLab as a development
dependency. This is something you need to work on the project, but that
someone else who is only using mooring_tools does not:
Challenge 2: Set up the project’s dependencies
In the project folder, fix requires-python and
description in pyproject.toml, then run both
uv add commands above. Open pyproject.toml and
confirm the dependencies and
[dependency-groups] tables were filled in for you. Which
new files and folders has uv created?
.venv/ (the environment, git-ignored) and
uv.lock (the exact record of what was installed, which we
look at shortly). pyproject.toml itself was edited by
uv add.
Your minimum versions will differ from the ones above, because they reflect whatever was current when you ran the command.
Running code in the project
To run a command inside the project’s environment, put
uv run in front of it:
The printed path should point into your repo’s
src/mooring_tools/. uv installs your own package into
.venv in editable mode: Python imports it directly from
your src/ files rather than from a copy, so edits you make
take effect immediately without reinstalling (This is the persistent
cousin of Block 2’s %autoreload).
Now start JupyterLab inside the project:
Open notebooks/first_analysis.ipynb. The notebook’s
kernel is the project’s .venv Python, so
import mooring_tools simply works.
Challenge 3: Use the package from a notebook
Launch JupyterLab with uv run jupyter lab from the repo
root. In a new notebook inside notebooks/, run:
PYTHON
import mooring_tools
from mooring_tools.stats import resample_burst_stats
print(mooring_tools.__file__)
Then change the body of a function in
src/mooring_tools/stats.py (for example, the default
freq), and call it again. Do you need to restart the
kernel? (Remember what %autoreload 2 did in Block 2.)
The printed path points at your repo’s
src/mooring_tools/. Whether the edit shows up without a
restart depends on whether you ran %load_ext autoreload and
%autoreload 2: the editable install makes the package
findable from anywhere, while autoreload makes
already-imported code refresh in a running kernel.
If import mooring_tools raises
ModuleNotFoundError, the notebook is almost certainly
running in a different Python than the project’s. Check that you started
JupyterLab with uv run jupyter lab, not with a
jupyter installed elsewhere on your machine.
Package management: the lock file
pyproject.toml has loose, minimum versions. For exact
reproducibility look at the other file uv created: uv.lock.
It records the exact version of every package in the environment,
including the ones you never asked for directly (xarray
pulls in packaging, pandas pulls in
python-dateutil, and so on):
[[package]]
name = "xarray"
version = "2026.9.0"
...
(You don’t write or edit uv.lock; uv maintains it every
time you uv add, uv remove, or
uv lock.)
The difference between pyproject.toml and
uv.lock is that the former declares what is needed to run
your code, whereas the latter records exactly what was installed,
version by version, the last time uv resolved your dependencies. A
colleague reproducing your result wants the second one. The lock file
covers every platform and Python version your project supports, so a
lock file made on a Mac also works on Windows and Linux.
Commit both files. Then reproducing your environment on a new machine is:
uv sync creates .venv and installs exactly
what uv.lock says. If the right Python isn’t installed on
the new machine, uv downloads it too.
Challenge 4: Reproduce your environment
Commit your work (git add . then
git commit). Then, in a different folder, clone
your own repo and run uv sync. Compare the output of
uv run python -c "import xarray; print(xarray.__version__)"
in the clone and in your original. What do you expect?
When that works, delete the .venv folder in your
original project and run uv sync again. How long
does it take compared to the first time?
The versions match, because both environments were built from the
same uv.lock.
Rebuilding is much faster the second time: uv keeps a cache of
everything it has already downloaded, so a rebuild is mostly linking
files from that cache into a new .venv.
Changing your mind later
You’ll rarely get the dependency list right the first time. The same commands work whenever you need them:
BASH
uv add scipy # new dependency
uv remove scipy # no longer needed
uv lock --upgrade # move to the newest versions allowed, then...
uv sync # ...bring .venv in line with uv.lock
Each of these edits pyproject.toml and/or
uv.lock for you, so the two files and the environment stay
consistent. Commit the changed files together.
.gitignore and what to
commit
uv init already wrote a .gitignore
containing .venv, __pycache__/,
*.py[oc], build/, dist/,
wheels/, and *.egg-info. Add one line for
Jupyter:
.ipynb_checkpoints/
Commit: pyproject.toml, uv.lock,
.python-version, README.md,
LICENSE, and everything under src/ and
notebooks/. Don’t commit .venv/.
We already added data/ to .gitignore. Our
files are far larger than GitHub accepts, and large or sensitive data in
general rarely belongs in a code repository. Instead, the README says
where to get the data.
What about conda?
Many scientists use conda or mamba, and for good reason: it can
install non-Python libraries (compilers, GDAL, and so on) that
uv cannot. Every package in this workshop is available as a
pre-built wheel for macOS, Windows, and Linux, so uv is
enough here. If a future project needs non-Python system libraries,
conda remains a sensible choice for that project. The ideas in this
block — one environment per project, minimum versions in the project
file, exact versions in a lock file, commit both — apply either way.
Wrap-up
By the end of this block, mooring_tools is a real,
installable package: src/ layout, a
pyproject.toml declaring what it needs, a
uv.lock recording exactly what was installed, and an
environment that uv builds and uses without any activation. A colleague
can reproduce all of it with git clone and
uv sync. Block 4 adds the README and docstring pass that
makes this repo usable by someone who isn’t you.
- Separate the installable package (
src/mooring_tools/) from notebooks and data - A
src/layout turns “the package isn’t actually installed” from a silent bug into a raised error -
uv init --libcreates the project skeleton;uv addinstalls a dependency and records it inpyproject.tomlanduv.lock -
uv runruns a command in the project’s environment with nothing to activate; the.venvfolder is disposable and never committed -
pyproject.tomldeclares what your code needs (minimum versions);uv.lockrecords exactly what’s installed: commit both, and reproduction isgit cloneplusuv sync
Content from Documentation and Reuse
Last updated on 2026-11-06 | Edit this page
Overview
Questions
- What does a README need to contain to actually be useful?
- How do docstrings work together across a whole project, not just one function?
- How do I extend this package for new work without breaking what already works?
Objectives
- Write a README that lets a new user install and run the project
- Audit and complete docstrings and type hints across a small project
- Extend the package to support a new variable without editing existing functions
Writing a README
A README’s job is to let someone who isn’t you install the project
and run the code successfully, without opening an issue or emailing you
first. For mooring_tools, that means including the
following: what it does, how to install it, an example, and where the
data comes from.
Challenge 1: Draft the README
Write README.md for mooring-tools,
covering: what the package does, install instructions (using
uv sync), and a usage example a new user could actually run
against data/gi01sumo_nsif_ctd.nc.
MARKDOWN
# mooring tools
Burst-statistics, plotting, and merging utilities for OOI CTD/DOSTA
data (Global Irminger Sea Array, near-surface instrument frame).
## What this does
Many sensors on OOI moorings sample in short bursts. This
package takes raw CTD (temperature, salinity, pressure, density) and DOSTA
(dissolved oxygen) records and:
- computes robust burst-median statistics (median and median absolute
deviation) for each variable
- fills gaps in the burst-median series with a linear-trend and harmonic fit
- plots raw vs. burst-median data, and observed vs. modeled series
- merges CTD and DOSTA into one annotated, saveable netCDF dataset, flagging
which values are observed vs. gap-filled
## Install
This project uses [uv](https://docs.astral.sh/uv/) to manage Python and its
packages. [Install uv](https://docs.astral.sh/uv/getting-started/installation/),
then:
```bash
git clone <this-repo-url>
cd gi01sumo-mooring-tools
uv sync
```
`uv sync` creates a `.venv` folder and installs the exact package versions
recorded in `uv.lock`, plus `mooring_tools` itself. Nothing needs to be
activated: put `uv run` in front of any command to run it in this
environment.
To work in a notebook:
```bash
uv run jupyter lab
```
## Usage
```python
import numpy as np
import xarray as xr
from mooring_tools.stats import resample_burst_stats
ctd = xr.open_dataset("data/gi01sumo_nsif_ctd.nc")
scalar_vars = [v for v in ctd.data_vars if ctd[v].dims == ("time",) and np.issubdtype(ctd[v].dtype, np.number)]
ctd_stats = resample_burst_stats(ctd[scalar_vars].to_dataframe())
```
See `notebooks/first_analysis.ipynb` for a full worked example.
## Data
The `data/` folder is not part of this repository because the files are
too large. It should contain `gi01sumo_nsif_ctd.nc` and
`gi01sumo_nsif_dosta.nc`, a full deployment of real OOI GI01SUMO data. Get
them from the workshop's starter-files download (see the lesson's Getting
Started page), or download similar data from the OOI Data Explorer.
## License
MIT — see `LICENSE`.
Docstrings at the project level
Block 1 introduced docstrings at the level of a single function, with
fill_harmonic_gaps as the model. Now that there are three
modules, it’s worth revisiting any functions which we didn’t
finish/complete the docstrings for.
Open merge.py. Compare its docstrings to
stats.py’s:
PYTHON
# merge.py, as it stands after Block 2
def flag_and_flatten(stats, was_filled, keep_vars):
"""Restrict to `keep_vars`, add a modeled_flag per variable, and flatten
the 2-level (variable, stat) columns into single flat names."""
...
No type hints, no Parameters/Returns
sections, only a one-line summary only. Meanwhile
stats.py’s fill_harmonic_gaps has both. This
is a common issue that crops up during development and exploration.
Challenge 2: Docstring and type-hint audit
Go through plotting.py and merge.py and
bring every function up to the same standard as stats.py:
type hints on the signature, and a full NumPy-style docstring (summary,
Parameters, Returns).
PYTHON
# merge.py, after the audit
import pandas as pd
import xarray as xr
# Attributes that describe each variable
VARIABLE_ATTRS = {
"sea_water_temperature": {"units": "degrees_Celsius", "long_name": "Sea Water Temperature"},
"sea_water_practical_salinity": {"units": "1", "long_name": "Sea Water Practical Salinity"},
"sea_water_pressure": {"units": "dbar", "long_name": "Sea Water Pressure"},
"oxygen_concentration_corrected": {"units": "umol kg-1", "long_name": "Corrected Dissolved Oxygen Concentration"},
}
# Attributes that describe the statistic that was computed
STAT_ATTRS = {
"median": {"comment": "Median of each 15-minute burst of samples"},
"mad": {"comment": "Median absolute deviation of each 15-minute burst of samples"},
}
# Attributes of the modeled flag, which is not in the units of the variable
FLAG_ATTRS = {"units": "1", "comment": "Indicates whether a value was observed (0) or modeled (1)"}
def flag_and_flatten(stats: pd.DataFrame, was_filled: pd.DataFrame, keep_vars: list[str]) -> pd.DataFrame:
"""Restrict to `keep_vars`, add a modeled_flag per variable, and flatten
the 2-level (variable, stat) columns into single flat names.
Parameters
----------
stats : pandas.DataFrame
Output of `resample_burst_stats` (2-level column MultiIndex of
(variable, stat)).
was_filled : pandas.DataFrame
Boolean frame with one column per variable, True where that burst
was gap-filled rather than observed.
keep_vars : list[str]
Which variables (first-level column names) to keep.
Returns
-------
pandas.DataFrame
Flat columns like "sea_water_temperature_median",
"sea_water_temperature_mad", "sea_water_temperature_modeled_flag".
"""
stats = stats.loc[:, keep_vars].copy()
for var in keep_vars:
stats[(var, "modeled_flag")] = was_filled[var].astype(int)
stats.columns = ["_".join(col).strip() for col in stats.columns.values]
return stats
def merge_ctd_dosta(ctd_flat: pd.DataFrame, dosta_flat: pd.DataFrame) -> xr.Dataset:
"""Outer-merge flattened CTD and DOSTA tables and attach variable attributes.
Parameters
----------
ctd_flat, dosta_flat : pandas.DataFrame
Output of `flag_and_flatten` for each instrument. Both must already
be flattened to single-level columns -- merging one flattened and
one still-MultiIndex table raises a `MergeError`.
Returns
-------
xarray.Dataset
Merged on the shared burst-median time index. Each data variable gets
the `units` and `long_name` listed for it in `VARIABLE_ATTRS`, plus a
comment describing the statistic. To support a new variable, add it to
`VARIABLE_ATTRS` and include it in the `keep_vars` passed to
`flag_and_flatten` -- no changes needed here.
"""
merged = pd.merge(ctd_flat, dosta_flat, left_index=True, right_index=True, how="outer")
merged.index.name = "time"
ds = merged.to_xarray()
for var in ds.data_vars:
if var.endswith("_modeled_flag"):
ds[var].attrs.update(FLAG_ATTRS)
continue
for stat, stat_attrs in STAT_ATTRS.items():
if var.endswith(f"_{stat}"):
base_name = var[: -(len(stat) + 1)]
ds[var].attrs.update(VARIABLE_ATTRS.get(base_name, {}))
ds[var].attrs.update(stat_attrs)
break
return ds
def save_merged_dataset(ds: xr.Dataset, path: str) -> None:
"""Write a merged dataset to netCDF, with a couple of dataset-level attrs.
Parameters
----------
ds : xarray.Dataset
Typically the output of `merge_ctd_dosta`.
path : str
Output file path.
"""
ds.attrs.setdefault("source", "OOI GI01SUMO near-surface instrument frame (CTD + DOSTA)")
ds.attrs.setdefault("processing", "15-minute burst median/MAD; gaps filled with linear trend + harmonic fit")
ds.to_netcdf(path)
Reusing and updating the repo
The real test of a well-organized package is what happens when the
scientific scope changes. Here’s an example: add
sea_water_density as a variable to
data/gi01sumo_nsif_ctd.nc results.
resample_burst_stats has been computing burst statistics
for it since Block 1, since it works generically over every numeric
column. But merge.py’s keep_vars lists (in
Block 2’s Challenge 3, and VARIABLE_ATTRS) never included
it, so it’s never made it into the final dataset.
Challenge 3: Add
sea_water_density to the dataset
Add sea_water_density as a supported merged-output
variable. You should not need to edit resample_burst_stats,
flag_and_flatten, or merge_ctd_dosta at
all.
PYTHON
from mooring_tools.merge import VARIABLE_ATTRS
VARIABLE_ATTRS["sea_water_density"] = {"units": "kg m-3", "long_name": "Sea Water Density"}
keep_vars = ["sea_water_temperature", "sea_water_practical_salinity",
"sea_water_pressure", "sea_water_density"]
ctd_flat = flag_and_flatten(ctd_stats, was_filled_ctd, keep_vars)
resample_burst_stats already computed statistics for
every numeric column in the input dataframe, including density, since it
was never told to look for specific variable names.
flag_and_flatten and merge_ctd_dosta are
equally generic; they only needed VARIABLE_ATTRS extended
and a longer keep_vars list at the call site. If this
doesn’t work you may need to revisit the functions from Block 2.
Versioning changes
As the package evolves, with new variables, bug fixes, new functions, etc., here are how you should handle these changes:
-
Bump the version in
pyproject.toml(version = "0.1.0"→"0.2.0") when you make a change someone else relying on the package would care about.uv version --bump minoredits theversionline inpyproject.tomlfor you (usepatchfor bug fixes,majorfor breaking changes);uv versionon its own prints the current version. -
Tag the commit in git (
git tag v0.2.0) so “the version I ran this analysis with” is always recoverable later, even after further changes.
Which number to bump follows a widely used convention called semantic versioning: version numbers look
like MAJOR.MINOR.PATCH.
-
Patch (
0.1.0→0.1.1,uv version --bump patch): bug fixes that don’t change how anyone uses the package. -
Minor (
0.1.0→0.2.0,uv version --bump minor): new features that don’t break existing code, such as adding a new function or supporting density. -
Major (
0.2.0→1.0.0,uv version --bump major): changes that can break code that used the old version, such as renaming a function or changing what it returns.
Commit the changed pyproject.toml and
uv.lock together with the tag, so the version number and
the recorded environment never disagree.
Picking the work back up months later is the same two commands a new
user runs: uv sync to rebuild .venv from
uv.lock, then uv run jupyter lab.
Wrap-up
Across all four blocks: a repeated block became a function (Block 1), the function joined others in a package (Block 2), the package became installable with a managed environment (Block 3), and now it’s documented well, and generic, to extend for new scientific questions without touching working code (Block 4).
- A README’s job is to let someone else install and run the project without asking you
- Docstring consistency matters more as a project grows past one file
- A well-documented function’s docstring tells the reader what’s safe to extend, not just what the function currently does
- Reusing a repo well means extending generic functions with new data/config, not copy-pasting or editing working code
- Bumping a version number and tagging the commit is enough to keep “what did I run this with” answerable later