Building a Repository and Managing Environments
Last updated on 2026-11-06 | Edit this page
Estimated time: 140 minutes
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.
Two practical points. First, uv must be on each learner’s
PATH in a new terminal window; “command
not found: uv” right after installing is almost always an old terminal.
Second, the first uv add downloads several hundred
megabytes (xarray, pandas, numpy, matplotlib, netCDF4, and JupyterLab
with its dependencies). On workshop wifi, have learners run Challenge 2
at the same time rather than one at a time, or pre-warm uv’s cache by
asking them to run the commands from learners/setup.md
before the session.
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