Nothing to install: start the workshops in your browser, on mybinder.org with no account needed, or in GitHub Codespaces with a GitHub account (see Launch on Binder and Launch on Codespaces below). Or run them in a container with nothing installed but Docker, or locally.
Guided, hands-on workshops for wrapture, the Python library for attaching bindings to arbitrary call sites without modifying the code being observed, and doing something useful with what flows through them: monkey patching, testing with recorded and supplied stand-ins, and tracing. Each workshop takes one thing you might want to do with wrapture and walks you through doing it, in a live JupyterLab, with the workshop checking your work as you go.
The workshops run on
jupyterlab-workshop,
a JupyterLab extension that shows the instructions in a side panel with
clickable actions that drive the session: terminals, files, the editor,
notebooks and kernels. A workshop can check what you have done, hold a
page until it is done, ask questions, and put files back when they go
wrong. Nothing here needs a container or a cluster: a workshop is a
directory of a workshop.yaml manifest and Markdown pages that runs
wherever JupyterLab runs. Each workshop installs wrapture for itself,
into a virtual environment of its own inside the workshop directory,
the way you would in a project, so nothing is installed into the
JupyterLab environment and nothing is left behind outside the workshop.
In the order to take them, with what each covers and roughly how long it takes. OUTLINE.md is the plan for the whole collection.
-
Your first binding (
first-binding, 10 minutes). Create a binding on a method, see that nothing is patched until you say so, apply it, suspend and resume it, remove it, scope it to a block, and change one thing about a call while the real method runs. In a notebook. -
Testing by wrapping, not replacing (
wrap-not-replace, 15 minutes). Write the same unit tests withunittest.mockand with wrapture: a stub that rejects a drifted call, a private method seen from outside, a real result with one field pinned, and an error path asserted on what did not happen, ending with the decorator form and the pytest plugin. In a terminal, with pytest. -
Recording what real code did (
recording-calls, 15 minutes). Record real calls on a timeline and read the tape back: what one event holds, filters against assertions against expectations, the call tree naming the method that leaked a connection and the stack naming the line, order across bindings, nested timelines instead of resetting, and the record turned into a pytest test that fails until you fix the code. In a notebook. -
Behaviour that changes over time (
phased-behaviour, 15 minutes). Script a binding's behaviour with phases: a count for a retry loop, a condition for a circuit breaker, a sequence for a polling loop with a loud error when it runs out, andadvance()from the test or from another binding's result stage. In a notebook. -
Bindings that are not calls (
beyond-callables, 15 minutes). Bind an attribute to record its reads and writes and guard its transitions, hold a value in an environment variable or a module constant, change a settings dict for every module holding it, wrap a callable kept in a registry, and see how far a consumer read a generator. In a notebook. -
A program that narrates itself (
live-tracing, 10 minutes). Apply bindings in a program's entry point with no timeline and let aPrinternarrate each call as it happens, redact the card numbers, then narrow the trace at the sink, at the binding, and for a whole subtree, and read the count of what was left out. In a terminal. -
Tracing without touching the program (
zero-code-tracing, 15 minutes). Move the bindings and the sink into awrapture.tomlbeside the program and run it unchanged underpython -m wrapture, then injected at interpreter startup by autowrapt; ask the traced process what is installed and switch it off and on; keep the trace as JSON Lines, read it back, and draw it as a sequence diagram. In a terminal. -
Analysing a trace in a notebook (
analysing-a-trace, 15 minutes). Run three hundred orders under a JSON Lines sink and treat the file as data: a DataFrame, the tree rebuilt from the parent links to give each path its total and self time, charts of where the time and the errors went, the latency of an order by tenant, and a few orders as a sequence diagram and a diff. In a notebook. -
One request as one tree (
tracing-flask, 15 minutes). Put the shop behind Flask and record each HTTP request as one tree from a single[[instrument]]entry: a quote, an order, a declined order, and a request that fails with a 500 and says why, then keep the health checks out. In two terminals. -
Where the time goes (
finding-slow-code, 15 minutes). Read the time off one request's tree, tell slow itself from slow because of a child with self time and assert on it in a test, get oneAggregatereport for thirty requests from a window in the config, and tag each request with its tenant. In two terminals, with pytest. -
The same events, sent to a backend (
opentelemetry-export, 20 minutes). Switch on OpenTelemetry export for the Flask shop with one[otel]table and read what the console exporters print, no collector needed: the request span named by its route, the view span beneath it with the exception on both, and the histograms after an export interval. Then a client and a quote service, and one trace id across both. In two terminals. -
wrapture and pytest, properly (
wrapture-with-pytest, 20 minutes). Scope bindings in a suite without mixing styles, with the with-block, the decorators, yield fixtures that hand the binding over and shared declarations applied per test; then the plugin, whose sweep fails a leaking test by name and whosetapefixture attaches the call tree to a failure report, and a query budget for the whole suite from a counter. In a terminal, with pytest. -
Converting a mock test suite (
coming-from-mock, 20 minutes). Take a test module written withunittest.mockand convert it to wrapture one idiom at a time, with the suite green after every step: a return value, a failure, a sequence of outcomes, several patches at once, a value in place, the real call with one change, the order of calls, a log message, and the one test to leave as mock. In a terminal, with pytest. -
When the test must supply the callable (
supplying-stand-ins, 15 minutes). Test a pipeline whose transport and completion hook the test has to supply: a stub for the hook, strict again with the real signature, a collaborator double from the named class that fabricates nothing, both on one tape, order across a batch, and failure paths reconfigured in place. In a notebook. -
Testing what a consumer does with a stream (
streaming-and-generators, 15 minutes). Test the consumers of a paginated catalogue against the real generator: one event per iteration with the item count and whether it finished, a proxy that sees every item and hears the end, a failure injected at the page you choose, items transformed on the way through, the proxy on the consumer's argument, and the whole thing as a pytest test. In a notebook. -
Async methods and generators (
testing-async-code, 15 minutes). Test a notifier whose client is async all the way down: a stub whose outcome arrives on await, a timeout that stops a broadcast early through the real loop, the coroutine that was never awaited caught in one line, concurrent sends on one tape, an async generator with stubbed items, and the same assertions as a pytest suite under pytest-asyncio. In a notebook. -
Changing what a library does (
patching-third-party-code, 20 minutes). Patch a vendored client you cannot edit: a header injected on the way in, the patch suspended, resumed and removed, reconfigured while installed, a retry around the whole call, an attribute clamped, the patch applied from a post-import hook before the library is imported, calls recorded without the token, and the same patch from a config file with the code beside it. In a terminal. -
Messages, phases and handled failures as events (
logs-blocks-and-notes, 15 minutes). Three more producers of events on the same tape as the calls: log messages pinned to the call that emitted them, named blocks that give a test phases to assert within, annotations that attach what the code knows, and a noted exception for a failure the code handled itself, all inert when nothing is listening. In a notebook. -
Where events go (
sinks-and-collectors, 20 minutes). The other side of the tape: a sink of your own, what a process sink hears that a timeline cannot and what a bound method costs when nobody listens, fan-out, depth, filtering and sampling in one registration,CounterandAggregatekeeping numbers rather than events, a binding declared a terminal node with a category, and resolvers naming each event per operation. In a terminal. -
Reading a trace after the fact (
trace-files-and-tools, 15 minutes). JSON Lines as the durable form of a trace: a two-thread pipeline streamed to disk from a config file, converted for Perfetto with one lane per thread, rendered as a canonical golden file that a test compares the live call tree against, and rotated on a schedule through a path template. In a terminal. -
Reports on a schedule (
watching-a-service-over-time, 15 minutes). AWindowwithevery=closing a run each period in code, then the same from the config file for the Flask shop: a summary every few seconds with one file per run, a report on demand from a signal, an always-on JSON Lines stream that rotates rather than grows, and what a restart does to a schedule. In two terminals. -
One trace across two processes (
distributed-tracing, 20 minutes). A client and a server, standard library only, one trace id across both: the identity in thetraceparentheader on the way out, parsed at the WSGI boundary on the way in, and two JSON Lines files joined on the id with no backend. Then work the caller does not wait for, on a thread and through a queue, linked back to its origin rather than nested under it. In two terminals. -
Requests in tests, and at the boundary (
testing-web-requests, 15 minutes). The Flask instrumentation inside a pytest test: the request event with its status as the result and its route indata, a streamed body's chunks, the exception behind a 500, an ignored path, and then theon_requestnamespace on a WSGI binding, a canned response, a fault the server sees and a status rewritten on the way out. In a terminal. -
Instrumenting a package nobody has covered (
writing-instrumentation, 20 minutes). AnInstrumentationclass for a small library shipped with the workshop: the shape, a hook per trigger module, handlers observed as they register, an absorbed failure noted from the error handler, settings validated when the config loads, the class tested directly and through wrapture, listed by the tool, and how it would be packaged with an entry point. In a terminal.
mybinder.org is a free public service that builds this repository into a temporary JupyterLab and runs it for you in the browser, so there is nothing to install. To start, click this link:
Launch the workshops on Binder
The badge at the top of this page opens the same link. Building and
starting the session takes a minute or two. When JupyterLab appears,
the workshops are listed in its workshop browser, numbered in the
order to take them, and the Finish dialog of each offers the next.
Opening a workshop locally shows a dialog asking you to trust it, since
its actions run commands on your machine. On Binder that dialog is
removed: the session is a container of its own, created for you and
discarded when you are done, and at no time is anything done on your
machine. The binder/postBuild script installs a settings override
that marks the checkout's workshops as trusted, turns off editing,
subscribes to the checkout's own collection.json, and names
binder/welcome.md as the message shown when the session starts,
which says what the workshops are and how to end the session. The same
override turns on reporting to the workshops' own analytics service,
which collection.json names as the collection's sink, so every
session reports which pages, actions and checks happened and when, and
it can be seen where the workshops are clear and where they are not.
Sessions are anonymous and the events never carry file contents,
command output or form answers; the welcome message says that progress
is reported. The token in the index is as public as the index and only
routes events to the collection; the events say whether they came from
Binder, a codespace or a container.
Binder sessions are temporary: anything you do in one is gone when it ends, so finish a workshop in the session you started it in. When you are done with the session, whether you finished a workshop or not, shut it down rather than closing the browser tab, so the resources go back to Binder for other users. The Finish dialog at the end of a workshop has a button for this, and so does JupyterLab's File menu, under "Shut Down".
GitHub Codespaces builds this repository into a container of your own in the cloud and opens it in VS Code in the browser. It needs a GitHub account, and the codespace uses your account's Codespaces allowance: personal accounts get a monthly amount of use at no cost, beyond which GitHub charges for it or stops it. Unlike a Binder session, a codespace is kept until you delete it. To start, click this link:
Launch the workshops on Codespaces
The Codespaces badge at the top of this page opens the same link. If you already have a codespace for this repository, the link offers to resume it rather than create another.
Creating the codespace takes a few minutes. VS Code opens first, with
.devcontainer/welcome.md open in it, while .devcontainer/setup.sh
installs JupyterLab and the extension from binder/requirements.txt
and downloads the notebook workshops' packages into a wheelhouse, as
binder/postBuild does, and .devcontainer/start.sh starts JupyterLab
in the background. When JupyterLab is ready, VS Code shows a
notification that the application on port 8888 is available: click
Open in Browser to open JupyterLab in a new tab. If the notification
has gone, open the address of the port labelled JupyterLab from VS
Code's Ports panel. The tab is not opened by itself, because browsers
block a tab nobody clicked for. From there the workshop browser lists
the workshops in order, as on Binder, and setup.sh installs the same
settings override as binder/postBuild, reporting progress to the
collection's sink without asking as Binder does, with two differences:
it names .devcontainer/welcome.md as the message shown when
JupyterLab starts, and it does not mark the workshops as trusted.
On Binder the trust dialog is removed, because the session is an
anonymous container that is thrown away when you are done. A codespace
is yours, tied to your GitHub account, so opening a workshop shows the
trust dialog: what the workshop will do, and how far to trust it.
Choose Trust to let its actions run as intended; Restricted types
commands without running them and asks before changing files or
running code. You are asked once for each workshop, and again only if
it changes. .devcontainer/start.sh also starts JupyterLab without the
codespace's GitHub credentials: the GITHUB_TOKEN variable is removed
from its environment and git is given no credential helper, so nothing
a workshop runs, the packages it installs included, is handed a token
for your account. That narrows what workshop code can reach; it does
not sandbox it.
Several workshops start small services of their own on other ports, which their commands reach from inside the codespace. Those ports are forwarded without a notification, and there is no need to open them in the browser.
JupyterLab in the codespace asks for no token, because the forwarded port is private: only you, signed in to GitHub, can reach it. Leave the port's visibility as Private. Made public, it would let anyone with its address run commands in your codespace.
A codespace stops by itself after a period of inactivity and keeps your work, and starting it again from github.com/codespaces starts JupyterLab again with it. A stopped codespace still uses your storage allowance, so delete it there when you have finished with the workshops.
Every jupyterlab-workshop release is published as a container image, with JupyterLab and the extension ready and no workshops inside. Run it with this collection's index and it installs the workshops, starts JupyterLab and prints the link to open:
docker run --rm -p 8888:8888 \
-e WORKSHOP_COLLECTION=https://raw.githubusercontent.com/GrahamDumpleton/wrapture-workshops/main/collection.json \
-e WORKSHOP_INSTALL=1 \
-e WORKSHOP_ANALYTICS=always \
ghcr.io/grahamdumpleton/jupyterlab-workshop:0.15.0
The link, http://127.0.0.1:8888/lab?token=…, appears in the
container's output once the server is up. It opens the workshop browser
with the workshops listed under Installed, numbered in the order to
take them, and trusted, since whoever ran the image chose them. Each
workshop builds its environment on first open from the image's Python,
installing wrapture and its other requirements with pip, so the
container needs the network then as well as at start. Podman runs the
same command. Name a workshop to open it straight away instead of the
browser, -e WORKSHOP_WORKSHOP=first-binding, and pass a fixed token
with -e JUPYTER_TOKEN=… so the link is the same every start.
The workshops, their environments and everything you make in them live
in the container's home directory, /home/jovyan, and go when the
container does. To keep them, mount a volume there:
-v wrapture:/home/jovyan. The next run finds the workshops installed
and their environments built, and finds your files and your progress
where you left them.
WORKSHOP_ANALYTICS=always reports your progress to the workshops'
own analytics service as Binder and Codespaces do, which pages you
visited, which actions you clicked and what the checks found, and when,
with nothing that identifies you and none of what you type or make.
The trust dialog is not shown in the container, so leaving the
variable out means nothing is reported: the checkbox that would offer
it is never seen. The image, its variables and how a collection can
build an image of its own with the workshops already inside are
described in the
documentation.
You need Python 3.14 and uv. Clone the repository, install the environment and start JupyterLab from the checkout:
git clone https://github.com/GrahamDumpleton/wrapture-workshops
cd wrapture-workshops
uv sync --no-dev
uv run jupyter lab --config=jupyter_lab_config.py
Or clone nothing: install the extension as a uv tool, with the lab
extra bringing JupyterLab along, and launch it on the collection by
URL, with a directory of your own to keep the workshops in. Python 3.14 is named because each workshop
builds its own environment from the Python JupyterLab runs on.
uv tool install --python 3.14 "jupyterlab-workshop[lab]"
jupyter-workshop launch --root ~/training --collection https://raw.githubusercontent.com/GrahamDumpleton/wrapture-workshops/main/collection.json
The same in one line, installing nothing that stays:
uvx --python 3.14 --from "jupyterlab-workshop[lab]" jupyter-workshop launch --root ~/training --collection https://raw.githubusercontent.com/GrahamDumpleton/wrapture-workshops/main/collection.json
Both need jupyterlab-workshop 0.1.26 or later. They start JupyterLab
on a free port with ~/training as its root and open the workshop
browser with this collection added; each workshop is installed from
this repository into ~/training/workshops as you open it, and stays
there for the next launch. Add a workshop's name to the command,
jupyter-workshop launch <name> --root ~/training --collection …,
to install and open it straight away.
Without uv, the environment comes from the requirements file Binder uses:
python3 -m venv .venv && source .venv/bin/activate
pip install -r binder/requirements.txt
jupyter lab --config=jupyter_lab_config.py
Run from the checkout, the workshops appear under Installed in the
workshop browser, because they sit in the workshops directory the
extension looks in by default. The config file opens JupyterLab at
http://localhost:8888/lab?collection=collection.json, which adds the
collection for the session, so the browser lists the workshops numbered
in the order to take them, under the collection's title; without it
they are listed in directory order. From the browser, open a workshop,
or go straight to one with
http://localhost:8888/lab?workshop=workshops/<name>. Outside
Binder the trust dialog appears when a workshop opens; it lists what
the workshop's pages are allowed to do; --trust trusted on the
launch command skips it.
With the extension installed anywhere, subscribe to this collection and the workshops are offered under Available in the workshop browser, with Install fetching each from this repository. In the browser, choose "Collections…" and enter the raw URL of the index:
https://raw.githubusercontent.com/GrahamDumpleton/wrapture-workshops/main/collection.json
A launch link does the same for one session, landing in the browser
with the collection on offer, and jupyter workshop launch --collection
with that URL starts a JupyterLab on such a link:
http://localhost:8888/lab?collection=https://raw.githubusercontent.com/GrahamDumpleton/wrapture-workshops/main/collection.json
workshops/
<name>/ a workshop: workshop.yaml and pages/*.md
collection.json the index the workshop browser reads, written by `just index`,
which also names the analytics service every deployment
reports to
binder/
requirements.txt the locked runtime environment, exported from uv.lock
runtime.txt the Python version for the Binder image
postBuild writes the settings override described above
welcome.md the message shown when a Binder session starts
.devcontainer/
devcontainer.json the GitHub Codespaces container: image, setup and start, ports
setup.sh installs from binder/requirements.txt, fills the wheelhouse and
writes the settings override, as postBuild does
start.sh starts JupyterLab on port 8888, without GitHub credentials
welcome.md the message shown when JupyterLab starts in a codespace
reference/wrapture a git submodule of wrapture at the release the workshops teach,
the source of truth for its API and documentation
reference/jupyterlab-workshop
a git submodule of jupyterlab-workshop at the pinned release,
the full documentation and source of the workshop format
pyproject.toml the uv project: JupyterLab and the extension, with the
authoring tools in the dev group
jupyter_lab_config.py opens a local JupyterLab on the collection, so the workshops
are listed in order; `just lab` passes it to jupyter lab
Justfile the common tasks; run `just` to list them
.mcp.json the MCP server configuration for AI agent clients
AGENTS.md guidance for AI agents writing workshops here
OUTLINE.md the design of the collection: the workshops planned,
what each covers, and where each stands
.github/workflows/
test.yml lints and self-tests every workshop on every push
Each workshop is self-contained and can be copied out on its own.
just install sets up the environment: it syncs uv, fetches the
reference submodules, downloads the browser the self-test drives, and
links the authoring skill shipped in the jupyterlab-workshop package
into .claude/skills, where Claude Code finds it. Then, with just lab running in one terminal so the live
tools have a JupyterLab to work in, start Claude Code (or VS Code with
the Claude extension) in this directory, and ask for a workshop. The
.mcp.json file gives the agent the jupyter workshop tools; the
skill tells it the format. AGENTS.md says what the workshops here are
for and the rules they follow.
The same tools work by hand:
just new <name> scaffold a workshop under workshops/
just lint lint collection.json and every workshop
just render <name> render a workshop to HTML
just test <name> self-test one workshop in a JupyterLab of its own
just index write or refresh collection.json
The self-test starts a JupyterLab, opens the workshop trusted, runs every
action, check, quiz and form in order, and reports PASS or FAIL for
each. It runs the workshop's commands for real, as you, on your
machine; only the workshop directory is protected, by a temporary copy.
Read a workshop before testing it. The workflow in
.github/workflows/test.yml lints and self-tests every workshop on
Linux and Windows from the same lock file, so CI tests what Binder and
Codespaces serve.
jupyterlab-workshop is pinned once, in pyproject.toml. just bump <version> moves the pin, relocks, rewrites binder/requirements.txt,
relinks the skill and moves the reference/jupyterlab-workshop
submodule to the same release tag. Binder builds a fresh image for the
new commit, a codespace created after it installs the new release (an
existing codespace keeps the one it was created with), and CI tests the
workshops against the new release.
The wrapture release the workshops teach is named in each workshop's
requirements and matched by the reference/wrapture submodule.
just bump-wrapture <version> moves the submodule to the release tag;
the workshops' requirements are then updated by hand and the workshops
retested.