Project setup
What you are actually building
That machine is ninety-nine chapters away, and every one of them ends with something running. This volume builds her voice: by chapter 10 your laptop will hold a real conversation with you, in her voice, using a brain that lives on your disk. Later volumes give her memory, judgment, a body, and finally a house to run.
Today, though, she needs a home for her code. That sounds like housekeeping, and compared to voice cloning it is. It is also the difference between a project that survives to chapter 99 and one that dies in chapter 20 with a dependency error nobody can reproduce. So we do it properly, once, and never think about it again.
The oldest bug in the book
Here is the failure this chapter exists to prevent. Somewhere around volume 8 you will
copy her code onto a different machine. If your environment is "whatever
pip install happened to grab this year," the model that loads fine on your
laptop will crash on the new board, because the two machines resolved different package
versions on different days. "Works on my machine" has ended more hobby robots than
soldering irons have.
The traditional fix is pip plus virtualenv, and it leaks.
pip install grabs whatever is newest today and records nothing. People bolt
on pip freeze, requirements.txt and pyenv: three
tools papering over one missing idea, the lock file. A lock file records the
exact version of every package you depend on, including the dependencies of your
dependencies, so that any machine, any day, resolves to identical code.
uv is that idea built in from the start: one binary that pins your Python
version, resolves dependencies, writes an exact uv.lock, and runs your code.
The mental model to carry through all ten volumes: her project is defined by the
files you commit — pyproject.toml, uv.lock, and a
deliberate folder tree — not by whatever state your machine has drifted into.
uv manages Python projects: it installs Python itself, creates the
project, adds dependencies, and locks all of it. We use it as a tool throughout;
everything you need is uv init, uv add, and
uv run. Install it once:
curl -LsSf https://astral.sh/uv/install.sh | sh
Then restart your shell so uv is on your PATH. If you would like the full
story of modern Python tooling, Python Zero volume 4 teaches it in depth; nothing in
this book requires it.
A project with a pinned Python
uv init GladOS
cd GladOS
uv python pin 3.11
$ uv init GladOS && cd GladOS && uv python pin 3.11
Initialized project `glados` at `/home/you/GladOS`
Pinned `.python-version` to `3.11`
uv init creates pyproject.toml (the project's identity and
dependency list) and a stub main.py you can delete. The pin writes
.python-version, and from now on uv run uses Python 3.11 here
no matter what the rest of your machine prefers. Open pyproject.toml and
set the floor explicitly:
[project]
name = "glados"
version = "0.1.0"
requires-python = ">=3.11"
Why 3.11? It is the version every library in this book supports, including two with slow-moving native dependencies you will meet in chapters 5 and 7. Newer Pythons work for most of the stack; when a voice library lags behind a fresh release, the pin is what keeps that from being your problem.
A layout declared as data
Her project needs six directories, and it will earn more as the book goes on:
labs for the scripts you build per chapter, configs for the
settings files that start mattering in volume 2, glados/data and
glados/models for datasets and downloaded model weights,
experiments for tuning runs, prompts for her personality
files. You could create them by hand. Instead we write a script, for a reason worth two
minutes: a layout created by hand exists only on your machine, but a layout created by a
committed script is part of the project. Machine two runs the script and matches machine
one exactly.
Create labs in your editor first, then save the file below inside it.
A script cannot create the directory that must already exist to save that script.
Run each command from the GladOS project root.
# labs/setup_dirs.py
from pathlib import Path
Path("labs").mkdir(exist_ok=True)
print("Created: labs/")
$ uv run python labs/setup_dirs.py
Created: labs/
One directory, then run it. The habit this book asks of you starts here: never write thirty lines and hope. Write three, run them, and build on ground you have tested.
The flag already matters. Without exist_ok=True, the second run of this
script raises FileExistsError, because labs/ is already
there. With it, creation is idempotent: safe to run any number of times,
which is exactly what you want from a setup script you will run on every machine she
ever lives on.
# labs/setup_dirs.py
from pathlib import Path
DIRS = ["labs", "configs", "glados/data", "glados/models", "experiments", "prompts"]
for d in DIRS:
Path(d).mkdir(parents=True, exist_ok=True)
print(f"Created: {d}/")
$ uv run python labs/setup_dirs.py
Created: labs/
Created: configs/
Created: glados/data/
Created: glados/models/
Created: experiments/
Created: prompts/
The structure is now a list you can read at a glance, and adding a directory later
means adding one string. Note the second flag: glados/data is a nested
path, and plain mkdir creates only the final component, assuming the
parent already exists. parents=True builds the whole chain,
glados first, then glados/data. Leave it off and you get the
crash in the failure box below, exactly as written.
# labs/setup_dirs.py
from pathlib import Path
DIRS = ["labs", "configs", "glados/data", "glados/models", "experiments", "prompts"]
def build_structure(dirs: list[str]) -> int:
created = 0
for d in dirs:
Path(d).mkdir(parents=True, exist_ok=True)
print(f"Created: {d}/")
created += 1
return created
def main() -> None:
count = build_structure(DIRS)
print(f"\nProject structure ready — {count} directories.")
if __name__ == "__main__":
main()
$ uv run python labs/setup_dirs.py
Created: labs/
Created: configs/
Created: glados/data/
Created: glados/models/
Created: experiments/
Created: prompts/
Project structure ready — 6 directories.
Two conventions arrive here and stay for the whole book. First,
build_structure returns its count instead of only printing, so
callers and tests can check the result; chapter 2 makes the case for why that
distinction runs deeper than style. Second, the
if __name__ == "__main__": guard means another file can
import setup_dirs to reuse the function without the script creating
folders as a side effect of the import. The typed signature
(list[str], -> int) lets your editor flag a wrong argument
before you ever run it.
Why this works: how mkdir walks a path
A directory can only be created inside a parent that already exists; that is a filesystem
rule, not a Python one. Path.mkdir() exposes two flags that decide what
happens at the edges of that rule, and the three combinations behave differently enough
to be worth setting side by side:
mkdir()is strict: it creates exactly one level, raisesFileNotFoundErrorif a parent is missing andFileExistsErrorif the directory is already there.mkdir(parents=True)builds every missing parent top-down, but still refuses a directory that already exists.mkdir(parents=True, exist_ok=True)builds the chain and accepts what is already there. Idempotent, and the combination this project uses everywhere.
Declaring the layout as a list and feeding it through one function splits the concerns cleanly: the structure is data you can read, and the behavior (create, idempotently, with parents) lives in exactly one place.
This version looks fine and even works for the first two entries. The author dropped
parents=True, and the flat folders created cleanly in testing:
for d in DIRS:
Path(d).mkdir(exist_ok=True) # forgot parents=True
print(f"Created: {d}/")
$ uv run python labs/setup_dirs.py
Created: labs/
Created: configs/
Traceback (most recent call last):
File "labs/setup_dirs.py", line 6, in <module>
Path(d).mkdir(exist_ok=True) # forgot parents=True
File "/usr/lib/python3.11/pathlib.py", line 1116, in mkdir
os.mkdir(self, mode)
FileNotFoundError: [Errno 2] No such file or directory: 'glados/data'
Read the trace from the bottom: the operating system refused to create
glados/data because glados does not exist yet. The first two
entries are flat, so plain mkdir survives them; the third is nested, and
without parents=True Python will not build the chain. Partial success is
the tell: a script that dies on entry three of six almost always has an
assumption that the first two happened to satisfy.
git status now lists pyproject.toml, .python-version, uv.lock and labs/setup_dirs.py. The first uv run creates or updates the lock file and syncs the environment; commit that lock file. Git tracks the setup script, not its empty directories.
Checkpoint, and what comes next
- I can explain what
uv.lockrecords thatpip installdoes not, and why that matters the day her code moves to a second machine. - I can predict what the setup script prints on its second run, and why it does not error.
- I know which of the two
mkdirflags builds missing parents and which one forgives existing directories, and what each failure looks like without it. - I can say what the
__main__guard protects: importingsetup_dirsdefines functions, and creates nothing.
Exercise 1 — break it on purpose. Delete the
glados directory, remove parents=True from the script, and
run it. Which entries succeed before it dies, and why those?
labs and configs succeed: both are flat paths whose
parent (the project root) exists. The script dies on glados/data, the
first nested path, with FileNotFoundError. Put the flag back and the
run completes; run it once more to confirm the second pass is a clean no-op.
Exercise 2 — grow the layout. Volume 2 will need a
glados/memory directory. Add it the way the design intends, and say what
the script prints afterward.
One string in DIRS: "glados/memory". Nothing else
changes; parents=True handles the nesting, and the run now prints
seven Created: lines and "Project structure ready — 7 directories."
That one-line change is what "structure as data" buys.
Exercise 3 — prove the guard. From the project root, run
uv run python -c "import labs.setup_dirs" in a directory where you have
deleted prompts/. Does prompts/ come back?
No. The import defines build_structure and main and runs
neither, so nothing is created. The directory returns only when something calls the
function: uv run python labs/setup_dirs.py, which enters through the
guard. Side effects on import are how libraries surprise you; the guard is how
yours never will.
The workspace is real: a pinned Python, a lock file waiting for its first dependency, a layout that rebuilds itself anywhere. Next chapter covers the handful of Python habits this build leans on hardest, starting with why a function that returns beats a function that prints, which sounds pedantic right up until chapter 10 composes five of them into her voice.