Math, Statistics, and Data › Data Visualization › Day 128
Hands-on lab — Day 128: Matplotlib Fundamentals
- ← Back to the Day 128 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals/
Commands
Setup
cd labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import matplotlib; print(matplotlib.__version__)" Run
cd examples && ../.venv/bin/python3 01_the_two_apis.py && cd ..
cd examples && ../.venv/bin/python3 02_data_round_trip.py && cd ..
cd examples && ../.venv/bin/python3 03_pixel_arithmetic.py && cd ..
cd examples && ../.venv/bin/python3 04_labels_limits_and_scales.py && cd ..
cd examples && ../.venv/bin/python3 05_subplots.py && cd ..
cd examples && ../.venv/bin/python3 06_log_scale_drops_nonpositive.py && cd ..
cd examples && ../.venv/bin/python3 07_legends.py && cd ..
cd examples && ../.venv/bin/python3 08_figure_leak.py && cd ..
cd examples && ../.venv/bin/python3 09_vector_versus_raster.py && cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
.venv/bin/pytest starter -q -p no:cacheprovider Test
bash tests/run_tests.sh File tree
examples/01_the_two_apis.py examples/02_data_round_trip.py examples/03_pixel_arithmetic.py examples/04_labels_limits_and_scales.py examples/05_subplots.py examples/06_log_scale_drops_nonpositive.py examples/07_legends.py examples/08_figure_leak.py examples/09_vector_versus_raster.py examples/conftest.py examples/plotting.py examples/test_reference.py expected-output/01-the-two-apis.txt expected-output/02-data-round-trip.txt expected-output/03-pixel-arithmetic.txt expected-output/04-labels-limits-and-scales.txt expected-output/05-subplots.txt expected-output/06-log-scale-drops-nonpositive.txt expected-output/07-legends.txt expected-output/08-figure-leak.txt expected-output/09-vector-versus-raster.txt expected-output/FIELDS.md expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/00_brief.md starter/conftest.py starter/plotting.py starter/test_starter.py tests/run_tests.sh troubleshooting.md
Lab README
Day 128 lab — Plots You Can Assert On
Lesson
- Lesson title: Matplotlib Fundamentals
- Day number: 128 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-128-matplotlib-fundamentals
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-128-matplotlib-fundamentalswhen the site is running.
Purpose
Two lines of code look almost identical and behave completely differently.
plt.plot(x, y) draws into whichever figure happens to be "current" —
call a helper built that way twice and both calls silently land on the
same figure, with nobody having asked for that. ax.plot(x, y), on a
named ax from fig, ax = plt.subplots(), cannot make that mistake,
because there is no "current" for it to guess at — every instruction says
exactly which Axes it means.
This lab builds nine small, checkable pieces of that distinction and the
practices that follow from it: the object model (Figure holds Axes, Axes
holds Artists), savefig's exact pixel arithmetic, subplot grids as
genuinely independent Axes, what a log scale actually does to a
zero-valued point (nothing dramatic — it just silently stops drawing it),
the label-then-legend pattern, the figure-lifecycle leak that a
non-interactive report script can accumulate for hours before a memory
warning ever fires, and the concrete difference between a raster and a
vector output file. Every exercise is checked by reading state directly
off the Figure and Axes objects matplotlib returns — never by comparing
rendered pixels to a stored "golden" image, which is fragile across
fonts, DPI and matplotlib versions in a way that artist-state assertions
are not.
Learning objectives
By the end you will be able to:
- Explain why the pyplot state-machine API (
plt.plot,plt.xlabel) and the object API (fig, ax = plt.subplots(), thenax.plot,ax.set_xlabel) behave differently when a drawing routine is called more than once, and demonstrate the difference withplt.get_fignums(). - Name the three-level object model — Figure, Axes, Artist — and say what each level owns: a Figure holds one or more Axes; an Axes owns its plotted Artists, its labels, its limits and its ticks.
- Predict a saved PNG's exact pixel dimensions from
figsizeanddpi, and explain whybbox_inches='tight'breaks that exact arithmetic. - Build a grid of subplots with
plt.subplots(nrows, ncols), read the shape of the returned Axes array, and demonstrate that each Axes in the grid is independent of every other. - Set labels, a title and explicit axis limits, and demonstrate that an
explicit
set_ylimoverrides autoscaling rather than being merged with it. - Describe exactly what
set_yscale('log')does to a data point with value zero or negative — it does not raise, and it does not always warn — and demonstrate the effect by inspectingax.get_ylim(). - Apply the label-then-legend pattern and verify a legend's text and
order by reading
ax.get_legend().get_texts(). - Explain matplotlib's figure lifecycle, reproduce the leak that follows
from plotting in a loop without
plt.close(), and trigger matplotlib's own too-many-open-figures warning for real. - State the concrete, testable difference between a raster (PNG) and a vector (SVG) output — one contains text as literal markup, the other does not — and choose between them for a given downstream use.
- Test a chart by asserting on its artists (
ax.get_xlabel(),len(ax.lines),ax.lines[0].get_xydata(),ax.get_yscale()) instead of diffing image bytes, and explain why that approach is more robust.
Prerequisites
- Day 91 — running and reading pytest output, this lab's testing pattern.
- Days 71-74 — installing packages with
pipinto a virtual environment. - Day 43 —
python3 -m venvandpip install -r requirements.txt. - Comfort with Python functions, tuples, and reading a stack trace.
- No prior matplotlib experience is assumed — this lab and its lesson build the object model from the ground up.
Supported operating systems
- macOS — run and captured here (macOS 26.5.2, Apple Silicon, arm64).
- Linux — the same commands apply unchanged. Not run here.
- Windows — use the Windows Subsystem for Linux and follow the Linux
instructions, or Git Bash with
.venv\Scripts\python.exein place of.venv/bin/python3. Not run here;troubleshooting.mdsays so plainly.
Hardware requirements
Anything that runs Python. Every chart in this lab is a handful of points rendered headlessly to a temporary file; the heaviest single operation is saving a figure at 200 dpi, well under a tenth of a second. Roughly 90 MB of disk for the virtual environment, almost all of it matplotlib and its own dependencies (contourpy, fonttools, kiwisolver, pillow).
Required software
python3— 3.14.0 here.matplotlib3.11.1,numpy2.5.2 andpytest9.1.1, installed into a lab-local virtual environment fromrequirements/requirements.txt.bash— 3.2.57 here, for the test harness.
Free and open-source options
All three dependencies are free and open source and there is no paid tier of anything in this lab. matplotlib is distributed under its own BSD-compatible licence, NumPy under BSD 3-Clause, and pytest under MIT. No account, no key, no signup, personally or commercially.
seaborn (installed in the authoring environment, not imported by this
lab — see Day 129), plotnine and plotly are all free and open source
too; plotnine and plotly are not installed here and are described from
documentation only in the lesson's Tools section. plotly's static-image
export (via the separate kaleido package) and its paid Dash Enterprise
product are the only parts of that ecosystem with a commercial tier —
interactive charts in a notebook or exported HTML are free.
Installation
From the repository root:
cd labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import matplotlib; print(matplotlib.__version__)"
Expect 3.11.1. That is the only time this lab needs the network.
File structure
.
├── README.md this file
├── metadata.yml how the lab was actually run, and when
├── requirements/
│ ├── README.md why each package is here, its licence, and what seaborn/plotnine/plotly would add
│ └── requirements.txt matplotlib==3.11.1, numpy==2.5.2, pytest==9.1.1
├── starter/ your work goes here
│ ├── 00_brief.md the nine exercises, in order
│ ├── conftest.py makes this directory's own module the one its tests import
│ ├── plotting.py all nine exercises — functions to write
│ └── test_starter.py your running score; unattempted work skips
├── examples/ the reference, to read after you have tried
│ ├── conftest.py the same import guard
│ ├── plotting.py the finished nine functions
│ ├── 01_the_two_apis.py the bug: two plt.* calls land on one figure; two fig,ax calls do not
│ ├── 02_data_round_trip.py ax.lines[0].get_xydata() equals the input arrays exactly
│ ├── 03_pixel_arithmetic.py figsize x dpi predicts the saved PNG's pixel size exactly
│ ├── 04_labels_limits_and_scales.py set_ylim overrides autoscaling
│ ├── 05_subplots.py plt.subplots(2, 3) returns an independent (2, 3) Axes array
│ ├── 06_log_scale_drops_nonpositive.py a zero-valued point silently falls outside a log-scale view
│ ├── 07_legends.py legend text matches the labels supplied, in order
│ ├── 08_figure_leak.py unclosed figures accumulate; matplotlib's own warning fires past 20
│ ├── 09_vector_versus_raster.py SVG carries text as markup; PNG does not
│ └── test_reference.py 19 tests over real artist state and real exceptions
├── tests/
│ └── run_tests.sh the bash harness: 34 checks, exits non-zero on any failure
├── expected-output/ captured from real runs on 2026-08-20
│ ├── FIELDS.md what may legitimately differ on your machine
│ ├── 01-the-two-apis.txt
│ ├── 02-data-round-trip.txt
│ ├── 03-pixel-arithmetic.txt
│ ├── 04-labels-limits-and-scales.txt
│ ├── 05-subplots.txt
│ ├── 06-log-scale-drops-nonpositive.txt
│ ├── 07-legends.txt
│ ├── 08-figure-leak.txt
│ ├── 09-vector-versus-raster.txt
│ └── test-run.txt
├── troubleshooting.md
└── security.md
How to run
Read starter/00_brief.md first. Then work, checking yourself as you go:
.venv/bin/pytest starter -q
On an untouched checkout that prints 1 passed, 13 skipped. A skip means
"not attempted"; a failure means "attempted and wrong", and prints both
your answer and the real one.
Afterwards, read the reference — each script prints its working and asserts every claim it makes:
cd examples
../.venv/bin/python3 01_the_two_apis.py
../.venv/bin/python3 02_data_round_trip.py
../.venv/bin/python3 03_pixel_arithmetic.py
../.venv/bin/python3 04_labels_limits_and_scales.py
../.venv/bin/python3 05_subplots.py
../.venv/bin/python3 06_log_scale_drops_nonpositive.py
../.venv/bin/python3 07_legends.py
../.venv/bin/python3 08_figure_leak.py
../.venv/bin/python3 09_vector_versus_raster.py
cd ..
.venv/bin/pytest examples -q -p no:cacheprovider
Run them from inside examples/, because they import plotting.py from
beside themselves.
Then the full harness:
bash tests/run_tests.sh
echo "exit=$?"
What the commands do
| Command | What it does |
|---|---|
python3 -m venv .venv |
Creates a virtual environment inside the lab, so nothing here can affect the rest of your machine. rm -rf .venv is a complete undo. |
.venv/bin/pip install -r requirements/requirements.txt |
Installs matplotlib 3.11.1, numpy 2.5.2 and pytest 9.1.1. The one command that uses the network. |
.venv/bin/pytest starter -q |
Your running score. Unattempted exercises skip; wrong answers fail with both values printed. |
01_the_two_apis.py |
Two plt.* calls land on one figure with two lines; two fig, ax calls produce two figures with one line each. |
02_data_round_trip.py |
Plots an array, reads it back off the Line2D artist, and checks exact equality. |
03_pixel_arithmetic.py |
Saves the same figure at three DPI values and reads each PNG's pixel size from its own file header. |
04_labels_limits_and_scales.py |
Compares autoscaled y-limits against an explicit set_ylim on the same Axes. |
05_subplots.py |
Builds a 2x3 grid, checks its shape, and confirms a label on one cell never appears on another. |
06_log_scale_drops_nonpositive.py |
Plots data containing a zero, switches to a log y-scale, and inspects where the zero point ends up. |
07_legends.py |
Plots two labelled series and checks the legend's text and order. |
08_figure_leak.py |
Opens figures without closing them, triggers matplotlib's own >20-figures warning, then closes everything. |
09_vector_versus_raster.py |
Saves the same figure as PNG and SVG and searches each file's bytes for the axis label. |
.venv/bin/pytest examples -q -p no:cacheprovider |
The 19 reference tests. -p no:cacheprovider stops pytest writing a .pytest_cache directory. |
bash tests/run_tests.sh |
The 34-check harness: versions, every script, both suites, a deliberate self-failure, and a clean-disk check. |
Expected output
The captured files live in expected-output/. The harness ends with:
34 checks, 0 failure(s).
and exits 0. The reference suite ends with 19 passed, and an untouched
starter with 1 passed, 13 skipped.
The result worth recognising before you meet it, from exercise 1:
pyplot-style: plt.get_fignums() = [1]
pyplot-style: lines on that one figure = 2
pyplot-style: BOTH calls landed on the same current figure -- this is
the bug. Two experiments' curves, overlaid, with nobody asking for that.
object-style: plt.get_fignums() = [1, 2]
object-style: lines on figure A = 1, on figure B = 1
expected-output/FIELDS.md records exactly which captured numbers are
version-specific and will differ, in documented ways, on your machine.
Validation steps
bash tests/run_tests.sh; echo "exit=$?"prints34 checks, 0 failure(s).andexit=0..venv/bin/pytest examples -q -p no:cacheproviderprints19 passed..venv/bin/pytest starter -q -p no:cacheproviderprints14 passedonce you have finished, and never prints a failure you have not been shown.- Each of the nine reference scripts ends with
every assertion held. find . -path ./.venv -prune -o -type f \( -name '*.png' -o -name '*.svg' -o -name '*.pdf' \) -printprints nothing after a full run.
Tests
tests/run_tests.sh runs 34 checks in six sections:
- Versions — reads the installed matplotlib and compares it against
requirements/requirements.txt, confirms it is matplotlib 3 or later, and confirms the backend is the headless Agg. - The nine reference scripts — each must exit 0 and print that every one of its internal assertions held.
- The reference pytest suite — must exit 0, report no failures, and have collected at least 15 tests, so a collection error cannot pass as success.
- The starter suite — must exit 0 on an untouched checkout with
skips rather than failures; and collecting both suites at once must not
turn any of those skips into passes, which is a real hazard here
because both directories contain a module called
plotting. - A deliberate failure — the harness re-runs the legend exercise with the reference function's label order monkeypatched to be wrong, and asserts the re-run reports the named failure and exits non-zero. A green suite proves nothing until you have watched it go red.
- A clean disk — no
__pycache__, no.pytest_cache, and no.png/.svg/.pdffile left anywhere outside.venv, and no source file that opens a network connection.
Before section 1, the harness clears any __pycache__ and .pytest_cache
that an earlier command left behind, pruning .venv as it goes. This
matters more than it sounds. The README above tells you to run
.venv/bin/pytest starter -q, and that command legitimately writes
starter/__pycache__ and .pytest_cache. Without the pre-run clear,
section 6 would then report those as litter — failing you for following
the instructions in this file. Clearing them at the start makes the final
check measure what this run left behind.
The harness was confirmed to exit 0 on a fresh lab-local .venv created by
the documented setup commands, and to correctly report a non-zero exit and
a named failure when section 5 deliberately breaks one assertion. .venv
is the documented setup, not a stray file, and nothing in the suite treats
it as one or deletes anything inside it.
Cleanup
find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
rm -rf .venv # optional: removes the lab virtual environment
git checkout -- starter/ # optional: resets your work
The lab's own commands leave none of the above behind; every image file
it saves lives in a tempfile.TemporaryDirectory() that deletes itself,
and section 6 of the harness fails if a stray one appears. It deliberately
does not look inside .venv, because the bytecode caches shipped with
matplotlib, NumPy and pytest are theirs, not yours.
Troubleshooting
See troubleshooting.md. It covers wrong-directory import errors, the
starter tests that keep skipping because a function still raises
NotImplementedError, the bbox_inches='tight' mistake that breaks the
pixel-arithmetic exercise, the fig.canvas.draw() step a log-scale
readback needs, the __pycache__ search that must prune .venv, and the
import collision the two conftest.py files prevent. All of them were
hit while building this lab or are named by a test.
Security notes
See security.md. In short: this lab draws and saves charts to a
temporary directory that deletes itself, opens no connection after the
one-time install, needs no credentials and no sudo, and all the plotted
data is invented. One point there is worth carrying away: a plotting
helper written against the pyplot state machine is a shared-mutable-state
bug wearing a data-visualization costume — it draws into "whichever
figure is current" and silently overlays unrelated results when called
more than once, which is exactly the training-curve and evaluation-plot
mistake the lesson's AI thread is about.
Extension exercises
- Measure the SVG size cost of
bbox_inches='tight'. Save the same figure as SVG with and withoutbbox_inches='tight', and compare the resultingviewBoxand file size. Confirm which one actually changes and which stays fixed. - Find the smallest figure count that reliably triggers the
too-many-open-figures warning on your machine. Exercise 8 uses 22;
binary-search
matplotlib.rcParams['figure.max_open_warning']to confirm the threshold is exactly one more than that rcParam's value. - Build a fourth API-mixing bug. Write a function that creates
fig, ax = plt.subplots()but then callsplt.xlabel(...)(the pyplot function, notax.set_xlabel) after a second figure has been created elsewhere. Assert which Axes actually receives the label, and explain why in a comment. - Add a tenth exercise:
constrained_layoutversustight_layout. Build aplt.subplots(2, 2)grid with long titles that overlap by default, apply each layout engine in turn, and assert onfig.get_constrained_layout()or the Axes' bounding boxes to show the overlap is resolved. - Measure PNG size versus dpi. Save the same figure at five dpi
values from 50 to 400, record each file's byte size alongside its
pixel dimensions from
png_dimensions, and confirm file size grows roughly with pixel count while staying far from a simple linear relationship (PNG compression varies with image content).
Navigation
- Previous day: Day 127 — Why We Visualize, and Choosing the Right Chart
- Next day: Day 129 — Statistical Plots with seaborn
- Week 19: Data Visualization
- Section: Mathematics, Statistics and Data
Expected output
01-the-two-apis.txt
pyplot-style: plt.get_fignums() = [1]
pyplot-style: lines on that one figure = 2
pyplot-style: BOTH calls landed on the same current figure -- this is the bug. Two experiments' curves, overlaid, with nobody asking for that.
object-style: plt.get_fignums() = [1, 2]
object-style: lines on figure A = 1, on figure B = 1
object-style: two figures, one line each -- each call named exactly where it drew.
01_the_two_apis.py: every assertion held.
02-data-round-trip.txt
input x = [0. 1.5 3. 4.5 7.25]
stored x = [0. 1.5 3. 4.5 7.25]
input y = [ 2. -1. 0.5 7.25 -3.5 ]
stored y = [ 2. -1. 0.5 7.25 -3.5 ]
02_data_round_trip.py: every assertion held.
03-pixel-arithmetic.txt
figsize=(6, 4) inches, dpi=100 -> (600, 400) pixels
figsize=(6, 4) inches, dpi=200 -> (1200, 800) pixels
figsize=(6, 4) inches, dpi=50 -> (300, 200) pixels
03_pixel_arithmetic.py: every assertion held.
04-labels-limits-and-scales.txt
autoscaled ylim before configure_axes: (np.float64(-5.0), np.float64(105.0))
xlabel = 'trial number', title = 'Before the override'
ylim after set_ylim(-5, 5): (np.float64(-5.0), np.float64(5.0))
04_labels_limits_and_scales.py: every assertion held.
05-subplots.txt
type(axes) = ndarray
axes.shape = (2, 3)
xlabels across the grid: {(0, 0): 'only axes[0, 0]', (0, 1): '', (0, 2): '', (1, 0): '', (1, 1): '', (1, 2): ''}
titles across the grid: {(0, 0): '', (0, 1): '', (0, 2): '', (1, 0): '', (1, 1): '', (1, 2): 'only axes[1, 2]'}
05_subplots.py: every assertion held.
06-log-scale-drops-nonpositive.txt
y data plotted: [0, 1, 4, 9, 16]
ax.get_yscale() = 'log'
ax.get_ylim() = (0.8706, 18.3792)
stored data still contains the zero point: [0.0, 0.0]
the y=0 point is present in the data but excluded from the visible range -- it renders as nothing, with no error and no visible gap marker, which is exactly what makes this easy to miss in a report.
06_log_scale_drops_nonpositive.py: every assertion held.
07-legends.txt
legend texts, in order: ['measured', 'predicted']
07_legends.py: every assertion held.
08-figure-leak.txt
after opening 5 figures without closing any: plt.get_fignums() has 5 entries
after closing each of the 5: plt.get_fignums() = []
opening 22 figures without closing triggered 1 RuntimeWarning(s):
More than 20 figures have been opened. Figures created through the pyplot interface (`matplotlib.pyplot.figure`) are retained until explicitly closed and may consume too much memory. (To control this warning, see the rcParam `figure.max_open_warning`). Consider using `matplotlib.pyplot.close()`.
after plt.close('all'): plt.get_fignums() = []
08_figure_leak.py: every assertion held.
09-vector-versus-raster.txt
SVG file size: 25,591 characters
PNG file size: 24,000 bytes
'depth (m)' found as text inside the SVG: True
b'depth (m)' found as bytes inside the PNG: False
09_vector_versus_raster.py: every assertion held.
FIELDS.md
# What may legitimately differ on your machine
Every file in this directory was captured from a real run on the authoring
machine on 2026-08-20: Python 3.14.0, matplotlib 3.11.1, numpy 2.5.2,
pytest 9.1.1, macOS 26.5.2 (Apple Silicon, arm64), through a lab-local
`.venv` created by the documented setup commands.
## Exact and identical everywhere
- **Pixel dimensions** (`03-pixel-arithmetic.txt`): `(600, 400)` at
`figsize=(6, 4)`, `dpi=100`; `(1200, 800)` at `dpi=200`; `(300, 200)` at
`dpi=50`. This is exact integer arithmetic — `figsize[i] * dpi`, rounded
by matplotlib's own rasteriser the same way on every platform — not a
measurement with any sampling noise in it.
- **The data round-trip** (`02-data-round-trip.txt`): the stored x and y
arrays are byte-for-byte the input arrays. No floating-point
recomputation happens between `ax.plot()` and `get_xydata()`.
- **Figure counts** (`01-the-two-apis.txt`, `08-figure-leak.txt`):
`plt.get_fignums()` lengths (1, 2, 5, 22, 0) are exact integers, not
measurements.
- **Legend text and order** (`07-legends.txt`): `['measured', 'predicted']`
is a direct readback of what was passed as `label=`, not a computed
value.
- **Subplot grid shape** (`05-subplots.txt`): `(2, 3)` and the per-cell
label/title dictionaries are exact structural facts about what
`plt.subplots(2, 3)` returns.
- **SVG-contains-text / PNG-does-not** (`09-vector-versus-raster.txt`):
the boolean outcomes (`True` / `False`) are guaranteed by the file
formats themselves — SVG is XML markup, PNG is a raster format with no
text layer — on any correctly functioning matplotlib install.
## Version-specific — will differ across matplotlib versions
- **The exact log-scale y-limits** in `06-log-scale-drops-nonpositive.txt`
(`(0.8706, 18.3792)`) come from matplotlib's internal margin-and-locator
logic for log axes, which has changed between major versions in the
past. The property that matters — the lower limit is strictly greater
than zero, so the zero-valued point falls outside the rendered range —
is what the lab's tests assert, not the specific numbers.
- **The exact byte sizes** in `09-vector-versus-raster.txt` (SVG character
count, PNG byte count) depend on matplotlib's SVG/PNG serialisation,
which has changed across versions (metadata blocks, compression
settings). The lab's tests assert presence/absence of the label text,
never a specific file size.
- **The "More than 20 figures" warning text** in `08-figure-leak.txt` is
matplotlib's own message string, sourced from `matplotlib.pyplot`'s
`_pylab_helpers` module; the *threshold* (20) and the *fact that it
fires* are what the lab's test checks (`pytest.warns(..., match="More
than 20 figures")`), which is stable across the 3.x series but is not a
documented public contract.
## Machine-dependent
- **`platform`** in `test-run.txt` section 1 (`macOS-26.5.2-arm64-...`)
reflects the authoring machine's OS and architecture. Linux and Windows
report differently; nothing in the lab depends on the exact string.
- Nothing in this lab is randomly sampled — no `numpy.random` calls appear
anywhere in `examples/` or `starter/` — so there is no seed-dependent
output to track, unlike several earlier days in this section.
test-run.txt
Day 128 — Matplotlib Fundamentals
1. The tools and the versions this lab was written against
python 3.14.0
matplotlib 3.11.1
numpy 2.5.2
pytest 9.1.1
platform macOS-26.5.2-arm64-arm-64bit-Mach-O
exe python3
ok: installed matplotlib matches requirements.txt
ok: matplotlib is version 3 or later
ok: matplotlib runs on the headless Agg backend
2. Every reference script runs and every assertion inside it holds
ok: 01_the_two_apis.py exits 0
ok: 01_the_two_apis.py reports every assertion held
ok: 02_data_round_trip.py exits 0
ok: 02_data_round_trip.py reports every assertion held
ok: 03_pixel_arithmetic.py exits 0
ok: 03_pixel_arithmetic.py reports every assertion held
ok: 04_labels_limits_and_scales.py exits 0
ok: 04_labels_limits_and_scales.py reports every assertion held
ok: 05_subplots.py exits 0
ok: 05_subplots.py reports every assertion held
ok: 06_log_scale_drops_nonpositive.py exits 0
ok: 06_log_scale_drops_nonpositive.py reports every assertion held
ok: 07_legends.py exits 0
ok: 07_legends.py reports every assertion held
ok: 08_figure_leak.py exits 0
ok: 08_figure_leak.py reports every assertion held
ok: 09_vector_versus_raster.py exits 0
ok: 09_vector_versus_raster.py reports every assertion held
3. The reference pytest suite: real artist state, real exceptions
................... [100%]
19 passed in 0.55s
ok: pytest examples exits 0
ok: no test in the reference suite failed
ok: the reference suite ran at least 15 tests (ran 19)
4. The starter suite skips unattempted work instead of failing it
.sssssssssssss [100%]
1 passed, 13 skipped in 0.18s
ok: pytest starter exits 0 on an untouched checkout
ok: the starter suite reports no failures
ok: unwritten exercises are reported as skipped, not passed
ok: collecting both suites at once does not turn skips into passes
5. The harness can actually fail
ok: a deliberately swapped label order makes script 07 exit non-zero (1)
ok: the failing assertion is named in the output with both values
6. Nothing was left behind
ok: no __pycache__ directory left by the lab's own code
ok: no .pytest_cache directory left under the lab
ok: no image file (.png/.svg/.pdf) left by the lab's own code
ok: no lab source opens a network connection
34 checks, 0 failure(s).
Source files
examples/01_the_two_apis.py (2169 bytes)
"""Exercise 1 -- the two APIs, and the failure that motivates the whole day.
draw_line_pyplot_style routes every instruction through plt.* -- the state
machine that always draws into whichever figure is "current". Called twice
in a row, with nothing in between asking for a new figure, both calls land
on the SAME figure. draw_line_object_style instead names a figure and axes
explicitly with fig, ax = plt.subplots() and calls methods on that specific
ax -- called twice, it is structurally impossible for the two calls to
collide, because each call created its own figure.
"""
import matplotlib.pyplot as plt
import plotting as P
plt.close("all")
# --- the pyplot state machine, called twice ---
P.draw_line_pyplot_style([0, 1, 2, 3], [0, 1, 4, 9], "run A")
P.draw_line_pyplot_style([0, 1, 2, 3], [9, 4, 1, 0], "run B")
pyplot_fignums = plt.get_fignums()
pyplot_fig = plt.figure(pyplot_fignums[0])
pyplot_lines = len(pyplot_fig.axes[0].lines)
print(f"pyplot-style: plt.get_fignums() = {pyplot_fignums}")
print(f"pyplot-style: lines on that one figure = {pyplot_lines}")
print(
"pyplot-style: BOTH calls landed on the same current figure -- this is"
" the bug. Two experiments' curves, overlaid, with nobody asking for that."
)
assert pyplot_fignums == [1] or len(pyplot_fignums) == 1, (
f"expected exactly one figure from two plt.* calls, got {pyplot_fignums}"
)
assert pyplot_lines == 2, f"expected 2 lines on the one figure, got {pyplot_lines}"
plt.close("all")
# --- the object API, called twice ---
fig_a, ax_a = P.draw_line_object_style([0, 1, 2, 3], [0, 1, 4, 9], "run A")
fig_b, ax_b = P.draw_line_object_style([0, 1, 2, 3], [9, 4, 1, 0], "run B")
object_fignums = plt.get_fignums()
print(f"\nobject-style: plt.get_fignums() = {object_fignums}")
print(f"object-style: lines on figure A = {len(ax_a.lines)}, on figure B = {len(ax_b.lines)}")
print("object-style: two figures, one line each -- each call named exactly where it drew.")
assert len(object_fignums) == 2, f"expected two figures, got {object_fignums}"
assert len(ax_a.lines) == 1 and len(ax_b.lines) == 1
plt.close("all")
print("\n01_the_two_apis.py: every assertion held.")
examples/02_data_round_trip.py (844 bytes)
"""Exercise 2 -- data round-trip.
ax.plot(x, y) does not transform the data before storing it on the Line2D
artist. ax.lines[0].get_xydata() should return exactly the arrays that
went in -- an exact equality check, not an approximate one, because nothing
about plotting a line involves floating-point recomputation of the points
themselves.
"""
import numpy as np
import plotting as P
x = np.array([0.0, 1.5, 3.0, 4.5, 7.25])
y = np.array([2.0, -1.0, 0.5, 7.25, -3.5])
fig, ax = P.make_line_axes(x, y)
xy = ax.lines[0].get_xydata()
print(f"input x = {x}")
print(f"stored x = {xy[:, 0]}")
print(f"input y = {y}")
print(f"stored y = {xy[:, 1]}")
assert np.array_equal(xy[:, 0], x), "x did not round-trip exactly"
assert np.array_equal(xy[:, 1], y), "y did not round-trip exactly"
print("\n02_data_round_trip.py: every assertion held.")
examples/03_pixel_arithmetic.py (1660 bytes)
"""Exercise 3 -- pixel arithmetic.
savefig's output size in pixels is figsize (inches) times dpi -- exactly,
as long as bbox_inches='tight' is not used to trim the output afterward.
A 6x4 inch figure at 100 dpi is 600x400 pixels; doubling the dpi to 200
doubles both dimensions to 1200x800. This script proves both claims by
reading the saved PNG's own header, not by trusting the arithmetic.
"""
import os
import tempfile
import matplotlib.pyplot as plt
import plotting as P
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([0, 1, 2, 3], [0, 1, 4, 9])
ax.set_xlabel("x")
ax.set_ylabel("y")
with tempfile.TemporaryDirectory(prefix="d128-") as d:
p100 = os.path.join(d, "fig_100dpi.png")
p200 = os.path.join(d, "fig_200dpi.png")
p50 = os.path.join(d, "fig_50dpi.png")
P.save_at_size_and_dpi(fig, p100, dpi=100)
P.save_at_size_and_dpi(fig, p200, dpi=200)
P.save_at_size_and_dpi(fig, p50, dpi=50)
dims100 = P.png_dimensions(p100)
dims200 = P.png_dimensions(p200)
dims50 = P.png_dimensions(p50)
print(f"figsize=(6, 4) inches, dpi=100 -> {dims100} pixels")
print(f"figsize=(6, 4) inches, dpi=200 -> {dims200} pixels")
print(f"figsize=(6, 4) inches, dpi=50 -> {dims50} pixels")
assert dims100 == (600, 400), f"expected (600, 400) at 100dpi, got {dims100}"
assert dims200 == (1200, 800), f"expected (1200, 800) at 200dpi, got {dims200}"
assert dims50 == (300, 200), f"expected (300, 200) at 50dpi, got {dims50}"
assert dims200 == (dims100[0] * 2, dims100[1] * 2), "doubling dpi should exactly double pixel dimensions"
plt.close(fig)
print("\n03_pixel_arithmetic.py: every assertion held.")
examples/04_labels_limits_and_scales.py (1347 bytes)
"""Exercise 4 -- labels, limits, ticks and scales.
Two claims: configure_axes sets exactly the label and title it is given,
and an explicit set_ylim OVERRIDES autoscaling rather than being merged
with it or ignored. The data plotted below ranges from 0 to 100 -- if
autoscale were still in charge after configure_axes runs, the y-limits
would reflect that range, not the (-5, 5) window this script asks for.
"""
import matplotlib.pyplot as plt
import plotting as P
fig, ax = plt.subplots()
ax.plot([0, 1, 2, 3], [0, 100, 5, 80])
fig.canvas.draw()
autoscaled_ylim = ax.get_ylim()
print(f"autoscaled ylim before configure_axes: {autoscaled_ylim}")
P.configure_axes(ax, xlabel="trial number", title="Before the override", ylim=None)
assert ax.get_xlabel() == "trial number"
assert ax.get_title() == "Before the override"
print(f"xlabel = {ax.get_xlabel()!r}, title = {ax.get_title()!r}")
P.configure_axes(ax, xlabel="trial number", title="After the override", ylim=(-5, 5))
final_ylim = ax.get_ylim()
print(f"ylim after set_ylim(-5, 5): {final_ylim}")
assert final_ylim == (-5, 5), f"expected (-5, 5), got {final_ylim}"
assert final_ylim != autoscaled_ylim, "the explicit ylim should differ from the autoscaled one"
assert ax.get_title() == "After the override"
plt.close(fig)
print("\n04_labels_limits_and_scales.py: every assertion held.")
examples/05_subplots.py (1442 bytes)
"""Exercise 5 -- subplots.
plt.subplots(nrows, ncols) with either dimension greater than 1 returns a
genuine 2-D numpy array of Axes objects, shaped exactly (nrows, ncols).
Each entry is its own object with its own state: labelling one Axes must
leave every other Axes in the grid untouched.
"""
import matplotlib.pyplot as plt
import numpy as np
import plotting as P
fig, axes = P.make_grid(2, 3)
print(f"type(axes) = {type(axes).__name__}")
print(f"axes.shape = {axes.shape}")
assert isinstance(axes, np.ndarray), f"expected a numpy array, got {type(axes)}"
assert axes.shape == (2, 3), f"expected shape (2, 3), got {axes.shape}"
axes[0, 0].set_xlabel("only axes[0, 0]")
axes[1, 2].set_title("only axes[1, 2]")
labels = {(r, c): axes[r, c].get_xlabel() for r in range(2) for c in range(3)}
titles = {(r, c): axes[r, c].get_title() for r in range(2) for c in range(3)}
print(f"xlabels across the grid: {labels}")
print(f"titles across the grid: {titles}")
for r in range(2):
for c in range(3):
if (r, c) != (0, 0):
assert axes[r, c].get_xlabel() == "", f"axes[{r},{c}] picked up a label it was never given"
if (r, c) != (1, 2):
assert axes[r, c].get_title() == "", f"axes[{r},{c}] picked up a title it was never given"
assert axes[0, 0].get_xlabel() == "only axes[0, 0]"
assert axes[1, 2].get_title() == "only axes[1, 2]"
plt.close(fig)
print("\n05_subplots.py: every assertion held.")
examples/06_log_scale_drops_nonpositive.py (1628 bytes)
"""Exercise 6 -- log scale silently drops non-positive data.
set_yscale('log') on data containing a zero does not raise. It also does
not warn, in this version, when at least one value in the series is
positive -- matplotlib only emits its "Data has no positive values, and
therefore cannot be log-scaled" warning when EVERY value is non-positive.
What actually happens with a mix, measured here on matplotlib 3.11.1: the
rendered y-limits are silently narrowed to exclude the non-positive point,
while the underlying line data is untouched. The zero-valued point is
still in ax.lines[0].get_xydata() -- it simply never gets drawn, because
log(0) has no y-pixel to draw it at.
"""
import matplotlib.pyplot as plt
import plotting as P
x = [0, 1, 2, 3, 4]
y = [0, 1, 4, 9, 16]
fig, ax = P.plot_with_log_yscale(x, y)
yscale = ax.get_yscale()
ymin, ymax = ax.get_ylim()
xy = ax.lines[0].get_xydata()
print(f"y data plotted: {y}")
print(f"ax.get_yscale() = {yscale!r}")
print(f"ax.get_ylim() = ({ymin:.4f}, {ymax:.4f})")
print(f"stored data still contains the zero point: {xy[0].tolist()}")
print(
"the y=0 point is present in the data but excluded from the visible"
" range -- it renders as nothing, with no error and no visible gap"
" marker, which is exactly what makes this easy to miss in a report."
)
assert yscale == "log", f"expected yscale 'log', got {yscale!r}"
assert ymin > 0, f"expected the log axis's lower limit to be > 0, got {ymin}"
assert xy[0, 1] == 0, "the original data should still contain the y=0 point"
plt.close(fig)
print("\n06_log_scale_drops_nonpositive.py: every assertion held.")
examples/07_legends.py (844 bytes)
"""Exercise 7 -- legends, the label-then-legend pattern.
Give every artist that should appear in the legend a label= at plot time,
then call ax.legend() once. The legend's entries come out in the order
the artists were plotted, matching the labels supplied.
"""
import matplotlib.pyplot as plt
import plotting as P
x = [0, 1, 2, 3]
measured = [2.1, 3.4, 3.9, 5.2]
predicted = [2.0, 3.2, 4.1, 5.0]
fig, ax = P.plot_two_series_with_legend(x, measured, "measured", predicted, "predicted")
legend = ax.get_legend()
texts = [t.get_text() for t in legend.get_texts()]
print(f"legend texts, in order: {texts}")
assert legend is not None, "expected ax.legend() to have created a Legend"
assert texts == ["measured", "predicted"], f"expected ['measured', 'predicted'], got {texts}"
plt.close(fig)
print("\n07_legends.py: every assertion held.")
examples/08_figure_leak.py (1903 bytes)
"""Exercise 8 -- the figure lifecycle, and the leak that follows from
ignoring it.
Every figure opened through pyplot lives in a global registry until
plt.close() (or plt.close('all')) removes it. A function that plots in a
loop and returns without closing leaks one figure per call -- harmless for
five iterations, expensive for five thousand in a long-running report
job. matplotlib's own defence is a RuntimeWarning once more than 20
figures are open at once; this script triggers it for real and captures
the message, then proves plt.close() on each figure empties the registry
completely.
"""
import warnings
import matplotlib.pyplot as plt
import plotting as P
plt.close("all")
# --- five unclosed figures: a small, silent leak ---
five_figs = P.open_figures_without_closing(5)
print(f"after opening 5 figures without closing any: plt.get_fignums() has {len(plt.get_fignums())} entries")
assert len(plt.get_fignums()) == 5
for fig in five_figs:
plt.close(fig)
assert plt.get_fignums() == [], "closing each figure individually should empty the registry"
print(f"after closing each of the 5: plt.get_fignums() = {plt.get_fignums()}")
# --- the real warning, triggered for real ---
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
many_figs = P.open_figures_without_closing(22)
messages = [str(w.message) for w in caught if issubclass(w.category, RuntimeWarning)]
print(f"\nopening 22 figures without closing triggered {len(messages)} RuntimeWarning(s):")
for m in messages:
print(f" {m}")
assert len(plt.get_fignums()) == 22
assert any("More than 20 figures" in m for m in messages), (
"expected matplotlib's own too-many-open-figures warning to fire"
)
plt.close("all")
assert plt.get_fignums() == []
print(f"\nafter plt.close('all'): plt.get_fignums() = {plt.get_fignums()}")
print("\n08_figure_leak.py: every assertion held.")
examples/09_vector_versus_raster.py (1632 bytes)
"""Exercise 9 -- vector versus raster, made testable.
An SVG is markup: text elements are literal <text> tags, so the axis label
appears in the file as searchable characters. A PNG is pixels: the same
label is rendered into a grid of colour values, and the string that
produced it does not appear anywhere in the file's bytes. This is the
entire argument for shipping SVG or PDF instead of PNG for anything that
will be printed, zoomed, or edited later -- made into two assertions
instead of a claim to take on faith.
"""
import os
import tempfile
import matplotlib.pyplot as plt
import plotting as P
fig, ax = plt.subplots()
ax.plot([0, 1, 2, 3], [10, 15, 13, 18])
ax.set_xlabel("depth (m)")
ax.set_title("Sensor reading by depth")
with tempfile.TemporaryDirectory(prefix="d128-") as d:
png_path = os.path.join(d, "reading.png")
svg_path = os.path.join(d, "reading.svg")
P.save_png_and_svg(fig, png_path, svg_path)
svg_text = open(svg_path, encoding="utf-8").read()
png_bytes = open(png_path, "rb").read()
svg_has_label = "depth (m)" in svg_text
png_has_label = b"depth (m)" in png_bytes
print(f"SVG file size: {len(svg_text):,} characters")
print(f"PNG file size: {len(png_bytes):,} bytes")
print(f"'depth (m)' found as text inside the SVG: {svg_has_label}")
print(f"b'depth (m)' found as bytes inside the PNG: {png_has_label}")
assert svg_has_label, "expected the axis label to appear as text in the SVG"
assert not png_has_label, "the axis label should not appear as raw bytes in the PNG"
plt.close(fig)
print("\n09_vector_versus_raster.py: every assertion held.")
examples/conftest.py (1005 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain a module called `plotting`, and
pytest imports test files by putting their directory on `sys.path`.
Without this file, running `pytest` across both directories at once would
import whichever copy was seen first and reuse it for the other -- so the
starter tests would silently pass against the reference solution instead
of skipping. That is a wrong answer with a green tick on it, which is the
worst kind.
So: put this directory first on the import path, and drop any
already-imported module of that name that came from somewhere else.
"""
import sys
from pathlib import Path
HERE = str(Path(__file__).parent.resolve())
if HERE in sys.path:
sys.path.remove(HERE)
sys.path.insert(0, HERE)
for name in ("plotting",):
module = sys.modules.get(name)
origin = getattr(module, "__file__", "") or ""
if module is not None and not origin.startswith(HERE):
del sys.modules[name]
examples/plotting.py (8680 bytes)
"""Reference implementation for Day 128 — Matplotlib Fundamentals.
Nine exercises, each a plain function that draws or measures a chart, with
no dependency beyond matplotlib and the standard library. Every function
is designed to be called from a test that asserts on the returned Figure
or Axes object's *state* — never on rendered image bytes, except in
exercise 9, where the whole point is comparing raster bytes to vector
markup.
Matplotlib is forced onto the non-interactive Agg backend at import time,
before pyplot is imported, so this module never opens a window and never
calls plt.show(). Every script and test in this lab imports plotting
first, which is what makes that guarantee hold everywhere.
"""
from __future__ import annotations
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402 (must follow matplotlib.use)
# ---------------------------------------------------------------------------
# Exercise 1 — the two APIs
# ---------------------------------------------------------------------------
def draw_line_pyplot_style(x, y, label):
"""Draw one line using the pyplot state machine.
Every call routes through whichever figure and axes are currently
"current" — plt.gcf() and plt.gca() — rather than naming one. Call this
twice in a row without an intervening plt.figure() and both lines land
on the SAME figure, because nothing here ever asked for a new one.
"""
plt.plot(x, y, label=label)
plt.xlabel("x")
plt.ylabel("y")
plt.title("drawn with the pyplot state machine")
plt.legend()
def draw_line_object_style(x, y, label):
"""Draw one line using the object API.
fig, ax = plt.subplots() creates a genuinely new Figure and Axes every
call, and every following instruction is a method call on that specific
ax — there is no "current" anything to get confused about. Call this
twice and you get two independent figures, guaranteed by construction
rather than by remembering to call plt.figure() first.
"""
fig, ax = plt.subplots()
ax.plot(x, y, label=label)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title("drawn with the object API")
ax.legend()
return fig, ax
# ---------------------------------------------------------------------------
# Exercise 2 — data round-trip
# ---------------------------------------------------------------------------
def make_line_axes(x, y):
"""Plot x, y on a fresh Axes and return (fig, ax).
Nothing here transforms the data — no normalisation, no sorting, no
resampling. What goes onto the Axes is exactly what was passed in,
which is a claim ax.lines[0].get_xydata() can check exactly, not
approximately.
"""
fig, ax = plt.subplots()
ax.plot(x, y)
return fig, ax
# ---------------------------------------------------------------------------
# Exercise 3 — pixel arithmetic
# ---------------------------------------------------------------------------
def png_dimensions(path):
"""Read a PNG file's (width, height) in pixels from its IHDR chunk.
Deliberately avoids adding an image-reading dependency: every PNG
starts with an 8-byte signature, then a 4-byte chunk length, a 4-byte
chunk type ("IHDR" for the first chunk always), then width and height
as big-endian 4-byte integers. That is a fixed, documented format, not
a guess — reading 24 bytes is enough.
"""
with open(path, "rb") as f:
header = f.read(24)
if header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR":
raise ValueError(f"{path} is not a PNG file with a leading IHDR chunk")
width = int.from_bytes(header[16:20], "big")
height = int.from_bytes(header[20:24], "big")
return width, height
def save_at_size_and_dpi(fig, path, dpi):
"""Save fig to path at the given dpi, with no bbox trimming.
bbox_inches='tight' is deliberately NOT used here: it crops the saved
image to the drawn content's bounding box, which means the output size
is no longer figsize * dpi exactly — it is figsize * dpi minus
whatever margin got trimmed. Pixel arithmetic needs the untrimmed size,
so this function saves with matplotlib's default bounding box.
"""
fig.savefig(path, dpi=dpi)
# ---------------------------------------------------------------------------
# Exercise 4 — labels, limits, ticks, scales
# ---------------------------------------------------------------------------
def configure_axes(ax, xlabel, title, ylim=None):
"""Apply a label, a title, and — optionally — an explicit y-limit.
set_ylim, when given, OVERRIDES autoscaling: matplotlib's default
behaviour is to pick y-limits that fit the plotted data with a small
margin, but a caller who calls set_ylim afterwards is asking for
exactly those bounds, data be damned. That is worth asserting
explicitly, because it is easy to assume autoscale always wins.
"""
ax.set_xlabel(xlabel)
ax.set_title(title)
if ylim is not None:
ax.set_ylim(*ylim)
# ---------------------------------------------------------------------------
# Exercise 5 — subplots
# ---------------------------------------------------------------------------
def make_grid(nrows, ncols):
"""Return (fig, axes) for an nrows x ncols grid of independent Axes.
plt.subplots(nrows, ncols) with either dimension greater than 1 returns
a 2-D numpy array of Axes objects, shaped (nrows, ncols) — not a flat
list, and not a single Axes. Each entry is its own object: setting a
label on axes[0, 0] never touches axes[0, 1].
"""
fig, axes = plt.subplots(nrows, ncols)
return fig, axes
# ---------------------------------------------------------------------------
# Exercise 6 — log scale and non-positive data
# ---------------------------------------------------------------------------
def plot_with_log_yscale(x, y):
"""Plot x, y, then switch the y-axis to a log scale, and return ax.
A logarithmic scale has no representation for zero or negative values
(log(0) is undefined, log of a negative number is not real), and
matplotlib does not raise an error over this — it silently narrows the
rendered y-limits to exclude non-positive values. The underlying line
data is untouched (ax.lines[0].get_xydata() still returns the original
array, zero included); only the VISIBLE range changes, which is what
makes this failure mode easy to miss in a real report.
"""
fig, ax = plt.subplots()
ax.plot(x, y, marker="o")
ax.set_yscale("log")
# Force a draw so the axes limits are actually recomputed for the new
# scale rather than left at whatever the linear autoscale produced.
fig.canvas.draw()
return fig, ax
# ---------------------------------------------------------------------------
# Exercise 7 — legends
# ---------------------------------------------------------------------------
def plot_two_series_with_legend(x, y1, label1, y2, label2):
"""Plot two labelled series and call legend() once, at the end.
The label-then-legend pattern: every artist that should appear in the
legend gets a label= at creation time, and a single ax.legend() call
afterwards collects them, in the order they were plotted.
"""
fig, ax = plt.subplots()
ax.plot(x, y1, label=label1)
ax.plot(x, y2, label=label2)
ax.legend()
return fig, ax
# ---------------------------------------------------------------------------
# Exercise 8 — figure lifecycle
# ---------------------------------------------------------------------------
def open_figures_without_closing(n):
"""Open n figures via plt.subplots() and return them without closing any.
Every open figure lives in pyplot's global registry until plt.close()
(or plt.close('all')) removes it — a loop that plots in a function and
returns without closing leaks one figure per iteration. This function
exists to make that leak reproducible and countable via
plt.get_fignums(), not to recommend the pattern.
"""
figs = []
for _ in range(n):
fig, ax = plt.subplots()
ax.plot([0, 1], [0, 1])
figs.append(fig)
return figs
# ---------------------------------------------------------------------------
# Exercise 9 — vector versus raster
# ---------------------------------------------------------------------------
def save_png_and_svg(fig, png_path, svg_path):
"""Save the same figure as PNG (raster) and SVG (vector)."""
fig.savefig(png_path, format="png")
fig.savefig(svg_path, format="svg")
examples/test_reference.py (9276 bytes)
"""Reference test suite for Day 128 — "Plots You Can Assert On".
Every test asserts on artist state — labels, limits, line data, the
number of open figures, legend text, file bytes — never on rendered
pixels compared to a golden image. A chart is an object graph, and object
graphs are testable.
Run from the lab directory:
.venv/bin/pytest examples -q -p no:cacheprovider
"""
from __future__ import annotations
import os
import tempfile
import matplotlib.pyplot as plt
import numpy as np
import pytest
import plotting as P
@pytest.fixture(autouse=True)
def _close_all_figures_between_tests():
"""Every test starts and ends with zero open figures, so one test's
figures can never leak into the next test's fignum count."""
plt.close("all")
yield
plt.close("all")
@pytest.fixture
def tmp_out_dir():
with tempfile.TemporaryDirectory(prefix="d128-") as d:
yield d
# ---------------------------------------------------------------------------
# Exercise 1 — the two APIs
# ---------------------------------------------------------------------------
def test_pyplot_style_puts_both_calls_on_one_figure():
P.draw_line_pyplot_style([0, 1, 2], [0, 1, 4], "first")
P.draw_line_pyplot_style([0, 1, 2], [2, 1, 0], "second")
fignums = plt.get_fignums()
assert len(fignums) == 1, f"expected one figure, got {len(fignums)}: {fignums}"
current = plt.figure(fignums[0])
assert len(current.axes[0].lines) == 2
def test_object_style_produces_two_independent_figures():
fig1, ax1 = P.draw_line_object_style([0, 1, 2], [0, 1, 4], "first")
fig2, ax2 = P.draw_line_object_style([0, 1, 2], [2, 1, 0], "second")
fignums = plt.get_fignums()
assert len(fignums) == 2, f"expected two figures, got {len(fignums)}: {fignums}"
assert len(ax1.lines) == 1
assert len(ax2.lines) == 1
assert fig1.number != fig2.number
def test_titles_use_the_exact_specified_strings():
_, ax_obj = P.draw_line_object_style([0, 1], [0, 1], "x")
assert ax_obj.get_title() == "drawn with the object API"
P.draw_line_pyplot_style([0, 1], [0, 1], "x")
assert plt.gca().get_title() == "drawn with the pyplot state machine"
# ---------------------------------------------------------------------------
# Exercise 2 — data round-trip
# ---------------------------------------------------------------------------
def test_line_data_round_trips_exactly():
x = np.array([0.0, 1.5, 3.0, 4.5])
y = np.array([2.0, -1.0, 0.5, 7.25])
_, ax = P.make_line_axes(x, y)
xy = ax.lines[0].get_xydata()
assert np.array_equal(xy[:, 0], x)
assert np.array_equal(xy[:, 1], y)
# ---------------------------------------------------------------------------
# Exercise 3 — pixel arithmetic
# ---------------------------------------------------------------------------
def test_600x400_at_100dpi():
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([0, 1, 2], [0, 1, 0])
with tempfile.TemporaryDirectory(prefix="d128-") as d:
path = os.path.join(d, "a.png")
P.save_at_size_and_dpi(fig, path, dpi=100)
assert P.png_dimensions(path) == (600, 400)
def test_doubling_dpi_doubles_pixel_dimensions():
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([0, 1, 2], [0, 1, 0])
with tempfile.TemporaryDirectory(prefix="d128-") as d:
p100 = os.path.join(d, "p100.png")
p200 = os.path.join(d, "p200.png")
P.save_at_size_and_dpi(fig, p100, dpi=100)
P.save_at_size_and_dpi(fig, p200, dpi=200)
w100, h100 = P.png_dimensions(p100)
w200, h200 = P.png_dimensions(p200)
assert (w200, h200) == (w100 * 2, h100 * 2)
# ---------------------------------------------------------------------------
# Exercise 4 — labels, limits, ticks, scales
# ---------------------------------------------------------------------------
def test_configure_axes_sets_label_and_title():
fig, ax = plt.subplots()
P.configure_axes(ax, xlabel="depth (m)", title="Ocean profile")
assert ax.get_xlabel() == "depth (m)"
assert ax.get_title() == "Ocean profile"
def test_explicit_ylim_overrides_autoscale():
fig, ax = plt.subplots()
ax.plot([0, 1, 2, 3], [0, 100, 5, 80])
fig.canvas.draw()
autoscaled = ax.get_ylim()
# the data ranges 0-100; autoscale should NOT already be (-5, 5)
assert not (abs(autoscaled[0] - (-5)) < 1e-9 and abs(autoscaled[1] - 5) < 1e-9)
P.configure_axes(ax, xlabel="x", title="t", ylim=(-5, 5))
assert ax.get_ylim() == (-5, 5)
# ---------------------------------------------------------------------------
# Exercise 5 — subplots
# ---------------------------------------------------------------------------
def test_grid_shape_is_nrows_by_ncols():
fig, axes = P.make_grid(2, 3)
assert axes.shape == (2, 3)
def test_each_axes_in_grid_is_independent():
fig, axes = P.make_grid(2, 2)
axes[0, 0].set_xlabel("only here")
assert axes[0, 0].get_xlabel() == "only here"
assert axes[0, 1].get_xlabel() == ""
assert axes[1, 0].get_xlabel() == ""
assert axes[1, 1].get_xlabel() == ""
# ---------------------------------------------------------------------------
# Exercise 6 — log scale and non-positive data
# ---------------------------------------------------------------------------
def test_log_yscale_is_applied():
fig, ax = P.plot_with_log_yscale([0, 1, 2, 3, 4], [0, 1, 4, 9, 16])
assert ax.get_yscale() == "log"
def test_log_yscale_excludes_the_zero_valued_point_from_view():
fig, ax = P.plot_with_log_yscale([0, 1, 2, 3, 4], [0, 1, 4, 9, 16])
ymin, ymax = ax.get_ylim()
# A log axis cannot include zero or below: the lower rendered limit
# must sit strictly above zero, which means the (x=0, y=0) point --
# still present in the line's own data -- falls outside the visible
# range. The data itself is not dropped; only what gets drawn is.
assert ymin > 0, f"expected the log-scale lower limit to exceed 0, got {ymin}"
xy = ax.lines[0].get_xydata()
assert xy[0, 1] == 0, "the original data should still contain the zero point"
# ---------------------------------------------------------------------------
# Exercise 7 — legends
# ---------------------------------------------------------------------------
def test_legend_text_matches_labels_in_order():
_, ax = P.plot_two_series_with_legend(
[0, 1, 2], [0, 1, 2], "measured", [0, 1, 2], "predicted"
)
legend = ax.get_legend()
assert legend is not None
texts = [t.get_text() for t in legend.get_texts()]
assert texts == ["measured", "predicted"]
# ---------------------------------------------------------------------------
# Exercise 8 — figure lifecycle
# ---------------------------------------------------------------------------
def test_unclosed_figures_accumulate():
figs = P.open_figures_without_closing(5)
assert len(figs) == 5
assert len(plt.get_fignums()) == 5
def test_closing_each_figure_empties_the_registry():
figs = P.open_figures_without_closing(4)
assert len(plt.get_fignums()) == 4
for fig in figs:
plt.close(fig)
assert plt.get_fignums() == []
def test_opening_more_than_twenty_figures_warns():
with pytest.warns(RuntimeWarning, match="More than 20 figures"):
figs = P.open_figures_without_closing(22)
assert len(plt.get_fignums()) == 22
for fig in figs:
plt.close(fig)
# ---------------------------------------------------------------------------
# Exercise 9 — vector versus raster
# ---------------------------------------------------------------------------
def test_svg_contains_the_axis_label_as_searchable_text():
fig, ax = plt.subplots()
ax.plot([0, 1, 2], [0, 1, 0])
ax.set_xlabel("depth (m)")
with tempfile.TemporaryDirectory(prefix="d128-") as d:
png_path = os.path.join(d, "a.png")
svg_path = os.path.join(d, "a.svg")
P.save_png_and_svg(fig, png_path, svg_path)
svg_text = open(svg_path, encoding="utf-8").read()
assert "depth (m)" in svg_text
def test_png_does_not_contain_the_axis_label_as_bytes():
fig, ax = plt.subplots()
ax.plot([0, 1, 2], [0, 1, 0])
ax.set_xlabel("depth (m)")
with tempfile.TemporaryDirectory(prefix="d128-") as d:
png_path = os.path.join(d, "a.png")
svg_path = os.path.join(d, "a.svg")
P.save_png_and_svg(fig, png_path, svg_path)
png_bytes = open(png_path, "rb").read()
assert b"depth (m)" not in png_bytes
# ---------------------------------------------------------------------------
# Housekeeping — the lab must leave no image files behind
# ---------------------------------------------------------------------------
def test_no_image_files_left_in_the_lab_directory():
lab_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
leftovers = []
for root, _dirs, files in os.walk(lab_dir):
if ".venv" in root or ".pytest_cache" in root or "__pycache__" in root:
continue
for name in files:
if name.endswith((".png", ".svg", ".pdf")):
leftovers.append(os.path.join(root, name))
assert leftovers == [], f"image files left behind: {leftovers}"
metadata.yml (3885 bytes)
lesson_id: D128
day: 128
kind: guided-build
languages: [python, bash]
setup_commands:
- cd labs/sections/math-statistics-and-data/day-128-matplotlib-fundamentals
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/python3 -c "import matplotlib; print(matplotlib.__version__)"
run_commands:
- 'cd examples && ../.venv/bin/python3 01_the_two_apis.py && cd ..'
- 'cd examples && ../.venv/bin/python3 02_data_round_trip.py && cd ..'
- 'cd examples && ../.venv/bin/python3 03_pixel_arithmetic.py && cd ..'
- 'cd examples && ../.venv/bin/python3 04_labels_limits_and_scales.py && cd ..'
- 'cd examples && ../.venv/bin/python3 05_subplots.py && cd ..'
- 'cd examples && ../.venv/bin/python3 06_log_scale_drops_nonpositive.py && cd ..'
- 'cd examples && ../.venv/bin/python3 07_legends.py && cd ..'
- 'cd examples && ../.venv/bin/python3 08_figure_leak.py && cd ..'
- 'cd examples && ../.venv/bin/python3 09_vector_versus_raster.py && cd ..'
- .venv/bin/pytest examples -q -p no:cacheprovider
- .venv/bin/pytest starter -q -p no:cacheprovider
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- "find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +"
- rm -rf .pytest_cache
- 'rm -rf .venv # optional: removes the lab virtual environment'
- 'git checkout -- starter/ # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 35
last_executed: '2026-08-20'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, matplotlib 3.11.1, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 34 checks, 0 failure(s), exit 0; pytest examples -> 19 passed; pytest starter -> 1 passed, 13 skipped on an untouched checkout, and 14 passed against a fully solved copy of starter/plotting.py (verified by temporarily copying the reference examples/plotting.py into starter/, confirming all 14 tests passed, then restoring the blank skeleton -- the skip count after restoring was confirmed back to 13, and collecting both suites together also reports 13 skipped and 33 total passes with the solved copy in place, proving the two conftest.py import guards work). All nine reference scripts exit 0 with every internal assertion holding. Everything was run through a real lab-local .venv created by the documented setup commands, not through an authoring environment; pip install used the network exactly once, as documented. Section 5 of the harness re-runs script 07 (legends) with the label order deliberately swapped by monkeypatching plot_two_series_with_legend, confirms the run exits non-zero with the named AssertionError showing both the expected and actual label order, and does not modify the real plotting.py file on disk -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Two honesty notes from this run. FIRST: seaborn is genuinely installed in the authoring environment (0.13.2) but this lab does not import it -- Day 129 owns seaborn; plotnine and plotly are not installed anywhere in this authoring environment and are described from their public documentation in the lesson''s Tools section only, with no output from either reproduced anywhere in this lab or its lesson. SECOND: nothing in this lab is randomly sampled -- every value (pixel dimensions, figure counts, legend text, log-scale limits, byte sizes) comes from a single deterministic run, so unlike several earlier days in this section there is no seed or tolerance band involved; expected-output/FIELDS.md instead separates exact-everywhere values (pixel arithmetic, data round-trips, figure counts) from version-specific ones (the precise log-scale y-limits and file byte sizes, which depend on matplotlib''s internal margin logic and serialisation and are not part of any documented public contract).'
requirements/README.md (2920 bytes)
# What is installed, why, and what it costs
Three packages, all free and open source, all installed into a lab-local
virtual environment that `rm -rf .venv` completely undoes.
| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `matplotlib` | 3.11.1 | PSF-derived (matplotlib licence, BSD-compatible) | Every chart in this lab: the object API, `savefig`, subplots, scales, legends, and the Agg backend that lets all of it run headless. |
| `numpy` | 2.5.2 | BSD 3-Clause | Small arrays for exercise 2's exact data round-trip check. Not central to this lab the way it was to earlier days -- matplotlib is the subject here. |
| `pytest` | 9.1.1 | MIT | The reference suite (19 tests) and your running score in `starter/`. |
There is no paid tier of anything in this lab, no account, no key and no
signup, personally or commercially.
## The one time the network is needed
```bash
.venv/bin/pip install -r requirements/requirements.txt
```
That is the only command in the lab that opens a connection. Section 6 of
`tests/run_tests.sh` greps every source file in `examples/` and `starter/`
to prove that nothing else does.
## What is deliberately *not* installed
**`seaborn`** builds statistical plots (distributions, categorical
comparisons, regression fits) on top of matplotlib's Axes objects — every
`sns.lineplot(..., ax=ax)` call returns the same kind of Axes this lab's
tests assert on. It is genuinely installed in the authoring environment
and used for real in Day 129, which owns it; this lab's own tests and
scripts do not import it, so no seaborn output is captured here.
**`plotnine`** is a grammar-of-graphics library (the `ggplot2` model,
ported to Python): charts are built by adding layers — `ggplot(df) +
aes(x=..., y=...) + geom_point() + facet_wrap(...)` — rather than by
calling methods on a named Axes. It is not installed here and **no output
from it is reproduced anywhere** in this lab or its lesson; the lesson's
Tools section describes it from its public documentation only.
**`plotly`** builds interactive, browser-rendered charts (`plotly.express`
and `plotly.graph_objects`) with zoom, hover tooltips and export to
static images through a separate `kaleido` dependency. It is not
installed here either, and is described from documentation only, with the
same "not run here" note.
## If you cannot install anything at all
matplotlib is the one package this lab cannot do without — every exercise
is about the Figure/Axes/Artist object model matplotlib defines, and there
is no meaningful stand-in for it using only the standard library. If
matplotlib genuinely cannot be installed, the ideas in this lesson (the
two APIs, `savefig`'s pixel arithmetic, testing a chart by asserting on
its artists rather than its pixels) can still be read and reasoned about,
but this lab's exercises and tests are not written against any other path.
requirements/requirements.txt (46 bytes)
matplotlib==3.11.1
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (3197 bytes)
# Day 128 lab brief — "Plots You Can Assert On"
Nine exercises. Write each function in `plotting.py`, then check yourself:
```bash
cd starter # if you are not already there
../.venv/bin/pytest . -q
```
An unattempted function's test **skips** — that means "not written yet,"
not "wrong." A wrong answer **fails**, and prints both the value your code
produced and the value the test expected.
Everything in this lab runs headless (`matplotlib.use("Agg")`, already
done at the top of `plotting.py` — never add `plt.show()`) and writes
files only to a temporary directory that the tests clean up after
themselves. You do not need network access, `sudo`, or any file outside
this lab.
## The nine exercises
1. **The two APIs.** Write `draw_line_pyplot_style` using only `plt.*`
calls, and `draw_line_object_style` using `fig, ax = plt.subplots()`
then `ax.*` calls. Call each twice with different data and compare
`plt.get_fignums()`: the pyplot version should put both lines on ONE
figure; the object version should produce TWO figures, one line each.
2. **Data round-trip.** `make_line_axes(x, y)` should plot exactly what it
is given — `ax.lines[0].get_xydata()` should equal the input arrays,
not an approximation of them.
3. **Pixel arithmetic.** `png_dimensions(path)` reads a PNG's width and
height from its file header — no imaging library needed, just 24 bytes
and two big-endian integers. `save_at_size_and_dpi(fig, path, dpi)`
saves without a tight bounding box, so `figsize * dpi` predicts the
saved pixel dimensions exactly.
4. **Labels and limits.** `configure_axes(ax, xlabel, title, ylim=None)`
sets a label and a title, and — when given — an explicit `ylim` that
overrides whatever autoscaling would otherwise have chosen.
5. **Subplots.** `make_grid(nrows, ncols)` returns the Figure and the
array of Axes from `plt.subplots(nrows, ncols)`, untouched. Each Axes
in that array is independent: a label set on one must not appear on
any other.
6. **Log scale and non-positive data.** `plot_with_log_yscale(x, y)`
switches the y-axis to `'log'` and forces a draw. Data containing zero
does not raise an error — it silently narrows the rendered range. Find
out, by inspecting `ax.get_ylim()`, whether the zero point ends up
inside or outside the visible range.
7. **Legends.** `plot_two_series_with_legend` plots two labelled series
and calls `ax.legend()` once. The legend's text should match the two
labels, in the order they were plotted.
8. **Figure lifecycle.** `open_figures_without_closing(n)` opens `n`
figures and returns them without calling `plt.close()` on any of
them — the leak is the point of this exercise, not a bug to fix here.
9. **Vector versus raster.** `save_png_and_svg(fig, png_path, svg_path)`
saves the same figure as both formats. An SVG is markup — its axis
label appears as searchable text in the file. A PNG is pixels — the
same label does not appear as bytes anywhere in the file.
Read the docstring on each function in `plotting.py` before writing it —
it states the exact API calls and, where it matters, the exact strings
the tests check for.
starter/conftest.py (1005 bytes)
"""Make this directory's own modules the ones its tests import.
Both `examples/` and `starter/` contain a module called `plotting`, and
pytest imports test files by putting their directory on `sys.path`.
Without this file, running `pytest` across both directories at once would
import whichever copy was seen first and reuse it for the other -- so the
starter tests would silently pass against the reference solution instead
of skipping. That is a wrong answer with a green tick on it, which is the
worst kind.
So: put this directory first on the import path, and drop any
already-imported module of that name that came from somewhere else.
"""
import sys
from pathlib import Path
HERE = str(Path(__file__).parent.resolve())
if HERE in sys.path:
sys.path.remove(HERE)
sys.path.insert(0, HERE)
for name in ("plotting",):
module = sys.modules.get(name)
origin = getattr(module, "__file__", "") or ""
if module is not None and not origin.startswith(HERE):
del sys.modules[name]
starter/plotting.py (5502 bytes)
"""Starter — Day 128 — Matplotlib Fundamentals — "Plots You Can Assert On".
Nine functions, one per exercise in `00_brief.md`. Each currently raises
NotImplementedError. Read the docstring, write the body, and check yourself
with:
../.venv/bin/pytest . -q (run from inside starter/)
An unattempted function skips its test (not a failure). A wrong answer
fails and prints both your value and the expected one.
matplotlib is forced onto the Agg backend before pyplot is imported, so
every function you write here runs headless. Never call plt.show().
"""
from __future__ import annotations
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402 (must follow matplotlib.use)
# ---------------------------------------------------------------------------
# Exercise 1 — the two APIs
# ---------------------------------------------------------------------------
def draw_line_pyplot_style(x, y, label):
"""Draw one line using the pyplot state machine: plt.plot, plt.xlabel,
plt.ylabel, plt.title (with the exact string 'drawn with the pyplot
state machine'), plt.legend(). Every call should go through plt.*, not
an ax you create yourself — that is the point of this exercise.
"""
raise NotImplementedError
def draw_line_object_style(x, y, label):
"""Create fig, ax = plt.subplots(), then call ax.plot, ax.set_xlabel,
ax.set_ylabel, ax.set_title (with the exact string 'drawn with the
object API'), ax.legend(). Return (fig, ax).
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 2 — data round-trip
# ---------------------------------------------------------------------------
def make_line_axes(x, y):
"""Create fig, ax = plt.subplots(), plot x, y with ax.plot(x, y), and
return (fig, ax). Do not transform x or y in any way.
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 3 — pixel arithmetic
# ---------------------------------------------------------------------------
def png_dimensions(path):
"""Read a PNG file's (width, height) in pixels without any imaging
library. A PNG file is: an 8-byte signature, then a 4-byte chunk
length, a 4-byte chunk type (always b"IHDR" for the first chunk), then
width and height as big-endian 4-byte unsigned integers — 24 bytes
total to read. Return (width, height) as a tuple of ints.
"""
raise NotImplementedError
def save_at_size_and_dpi(fig, path, dpi):
"""Save fig to path at the given dpi. Do NOT pass bbox_inches='tight'
— this exercise is about exact figsize * dpi pixel arithmetic, and
tight bounding boxes trim the output to the drawn content instead.
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 4 — labels, limits, ticks, scales
# ---------------------------------------------------------------------------
def configure_axes(ax, xlabel, title, ylim=None):
"""Call ax.set_xlabel(xlabel) and ax.set_title(title). If ylim is not
None, call ax.set_ylim(*ylim) too — this should override whatever
autoscaling would otherwise have picked.
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 5 — subplots
# ---------------------------------------------------------------------------
def make_grid(nrows, ncols):
"""Return (fig, axes) from plt.subplots(nrows, ncols). Do not flatten
or reshape the returned axes array.
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 6 — log scale and non-positive data
# ---------------------------------------------------------------------------
def plot_with_log_yscale(x, y):
"""Create fig, ax = plt.subplots(), plot x, y (use marker="o" so the
points are visible), call ax.set_yscale("log"), force a draw with
fig.canvas.draw() so the axes limits are recomputed, and return
(fig, ax).
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 7 — legends
# ---------------------------------------------------------------------------
def plot_two_series_with_legend(x, y1, label1, y2, label2):
"""Plot y1 then y2 against x, each with its label= set at plot time,
then call ax.legend() once at the end. Return (fig, ax).
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 8 — figure lifecycle
# ---------------------------------------------------------------------------
def open_figures_without_closing(n):
"""Call plt.subplots() n times, plot something trivial on each ax
(e.g. ax.plot([0, 1], [0, 1])), and return a list of the n figures.
Do not call plt.close() anywhere in this function — the leak is the
exercise.
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Exercise 9 — vector versus raster
# ---------------------------------------------------------------------------
def save_png_and_svg(fig, png_path, svg_path):
"""Save fig to png_path with format="png" and to svg_path with
format="svg".
"""
raise NotImplementedError
starter/test_starter.py (7256 bytes)
"""Your running score. Unattempted work SKIPS; wrong work FAILS with both
values.
Run from the lab directory:
.venv/bin/pytest starter -q
On an untouched checkout this reports one pass and everything else skipped.
A skip means "not attempted". A failure means "attempted and wrong."
"""
from __future__ import annotations
import os
import tempfile
import matplotlib.pyplot as plt
import numpy as np
import pytest
import plotting as P
@pytest.fixture(autouse=True)
def _close_all_figures_between_tests():
plt.close("all")
yield
plt.close("all")
def attempt(fn, what):
"""Call something that may not be written yet, and skip if it is not."""
try:
result = fn()
except NotImplementedError:
pytest.skip(f"not attempted yet: {what}")
return result
def test_the_suite_itself_runs():
"""One test that always passes, so a green run is distinguishable from
a collection error that quietly ran nothing at all."""
assert P.plt is plt
# ---------------------------------------------------------------------------
# Exercise 1
# ---------------------------------------------------------------------------
def test_pyplot_style_puts_both_calls_on_one_figure():
attempt(
lambda: P.draw_line_pyplot_style([0, 1, 2], [0, 1, 4], "first"),
"draw_line_pyplot_style",
)
attempt(
lambda: P.draw_line_pyplot_style([0, 1, 2], [2, 1, 0], "second"),
"draw_line_pyplot_style",
)
fignums = plt.get_fignums()
if not fignums:
pytest.skip("not attempted yet: draw_line_pyplot_style")
assert len(fignums) == 1
def test_object_style_produces_two_independent_figures():
result1 = attempt(
lambda: P.draw_line_object_style([0, 1, 2], [0, 1, 4], "first"),
"draw_line_object_style",
)
result2 = attempt(
lambda: P.draw_line_object_style([0, 1, 2], [2, 1, 0], "second"),
"draw_line_object_style",
)
fig1, ax1 = result1
fig2, ax2 = result2
assert len(plt.get_fignums()) == 2
assert len(ax1.lines) == 1
assert len(ax2.lines) == 1
# ---------------------------------------------------------------------------
# Exercise 2
# ---------------------------------------------------------------------------
def test_line_data_round_trips_exactly():
x = np.array([0.0, 1.5, 3.0, 4.5])
y = np.array([2.0, -1.0, 0.5, 7.25])
_, ax = attempt(lambda: P.make_line_axes(x, y), "make_line_axes")
xy = ax.lines[0].get_xydata()
assert np.array_equal(xy[:, 0], x)
assert np.array_equal(xy[:, 1], y)
# ---------------------------------------------------------------------------
# Exercise 3
# ---------------------------------------------------------------------------
def test_600x400_at_100dpi():
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([0, 1, 2], [0, 1, 0])
with tempfile.TemporaryDirectory(prefix="d128-") as d:
path = os.path.join(d, "a.png")
attempt(
lambda: P.save_at_size_and_dpi(fig, path, dpi=100),
"save_at_size_and_dpi",
)
if not os.path.exists(path):
pytest.skip("not attempted yet: save_at_size_and_dpi")
dims = attempt(lambda: P.png_dimensions(path), "png_dimensions")
assert dims == (600, 400)
# ---------------------------------------------------------------------------
# Exercise 4
# ---------------------------------------------------------------------------
def test_configure_axes_sets_label_and_title():
fig, ax = plt.subplots()
attempt(
lambda: P.configure_axes(ax, xlabel="depth (m)", title="Ocean profile"),
"configure_axes",
)
assert ax.get_xlabel() == "depth (m)"
assert ax.get_title() == "Ocean profile"
def test_explicit_ylim_overrides_autoscale():
fig, ax = plt.subplots()
ax.plot([0, 1, 2, 3], [0, 100, 5, 80])
attempt(
lambda: P.configure_axes(ax, xlabel="x", title="t", ylim=(-5, 5)),
"configure_axes",
)
if ax.get_ylim() != (-5, 5):
pytest.skip("not attempted yet: configure_axes(ylim=...)")
assert ax.get_ylim() == (-5, 5)
# ---------------------------------------------------------------------------
# Exercise 5
# ---------------------------------------------------------------------------
def test_grid_shape_is_nrows_by_ncols():
fig, axes = attempt(lambda: P.make_grid(2, 3), "make_grid")
assert axes.shape == (2, 3)
def test_each_axes_in_grid_is_independent():
fig, axes = attempt(lambda: P.make_grid(2, 2), "make_grid")
axes[0, 0].set_xlabel("only here")
assert axes[0, 1].get_xlabel() == ""
# ---------------------------------------------------------------------------
# Exercise 6
# ---------------------------------------------------------------------------
def test_log_yscale_excludes_the_zero_valued_point_from_view():
fig, ax = attempt(
lambda: P.plot_with_log_yscale([0, 1, 2, 3, 4], [0, 1, 4, 9, 16]),
"plot_with_log_yscale",
)
assert ax.get_yscale() == "log"
ymin, _ = ax.get_ylim()
assert ymin > 0
# ---------------------------------------------------------------------------
# Exercise 7
# ---------------------------------------------------------------------------
def test_legend_text_matches_labels_in_order():
_, ax = attempt(
lambda: P.plot_two_series_with_legend(
[0, 1, 2], [0, 1, 2], "measured", [0, 1, 2], "predicted"
),
"plot_two_series_with_legend",
)
legend = ax.get_legend()
if legend is None:
pytest.skip("not attempted yet: plot_two_series_with_legend")
texts = [t.get_text() for t in legend.get_texts()]
assert texts == ["measured", "predicted"]
# ---------------------------------------------------------------------------
# Exercise 8
# ---------------------------------------------------------------------------
def test_unclosed_figures_accumulate():
figs = attempt(
lambda: P.open_figures_without_closing(5), "open_figures_without_closing"
)
assert len(figs) == 5
assert len(plt.get_fignums()) == 5
def test_closing_each_figure_empties_the_registry():
figs = attempt(
lambda: P.open_figures_without_closing(4), "open_figures_without_closing"
)
for fig in figs:
plt.close(fig)
assert plt.get_fignums() == []
# ---------------------------------------------------------------------------
# Exercise 9
# ---------------------------------------------------------------------------
def test_svg_has_label_text_png_does_not():
fig, ax = plt.subplots()
ax.plot([0, 1, 2], [0, 1, 0])
ax.set_xlabel("depth (m)")
with tempfile.TemporaryDirectory(prefix="d128-") as d:
png_path = os.path.join(d, "a.png")
svg_path = os.path.join(d, "a.svg")
attempt(
lambda: P.save_png_and_svg(fig, png_path, svg_path), "save_png_and_svg"
)
if not (os.path.exists(png_path) and os.path.exists(svg_path)):
pytest.skip("not attempted yet: save_png_and_svg")
svg_text = open(svg_path, encoding="utf-8").read()
png_bytes = open(png_path, "rb").read()
assert "depth (m)" in svg_text
assert b"depth (m)" not in png_bytes
tests/run_tests.sh (12201 bytes)
#!/usr/bin/env bash
# Tests for the Day 128 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and reading real
# artist state, never by reading source or diffing image bytes:
#
# * the two APIs -- plt.* called twice puts both lines on one figure;
# fig, ax = plt.subplots() called twice produces two independent
# figures, one line each;
# * data round-trips exactly through ax.lines[0].get_xydata();
# * savefig's pixel arithmetic -- a 6x4 inch figure at 100 dpi saves a
# 600x400 PNG, and doubling the dpi exactly doubles both dimensions;
# * labels, titles and an explicit set_ylim that overrides autoscaling;
# * plt.subplots(2, 3) returns a (2, 3) array of independent Axes;
# * set_yscale('log') on data containing a zero silently narrows the
# rendered range rather than raising, leaving the zero point in the
# data but outside ax.get_ylim();
# * a legend's text matches the labels supplied, in order;
# * figures accumulate until closed, matplotlib's own warning fires past
# 20 open figures, and plt.close() empties the registry;
# * an SVG carries its axis label as searchable text; the same label
# never appears as bytes in the PNG;
# * nothing is left behind on disk.
#
# Everything after the one-time install runs offline. Nothing binds a port,
# nothing writes outside a temporary directory, nothing needs a key.
# Deterministic, non-interactive, exits 0 only if every check passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
export MPLBACKEND=Agg
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Bytecode left by an EARLIER command is not this run's litter. The README
# documents `pytest starter -q`, and running it writes .pyc files that would
# then fail the cleanliness check at the end of this script -- failing the
# reader for following the instructions. Clearing them here makes that final
# check measure what it claims to: what THIS run left behind. `.venv` is
# untouched, because the packages' own bytecode is theirs, not ours.
find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -exec rm -rf {} + 2>/dev/null || true
failures=0
checks=0
check() {
local label="$1" ok="$2"
checks=$((checks + 1))
if [ "${ok}" = "yes" ]; then
echo " ok: ${label}"
else
echo " FAIL: ${label}"
failures=$((failures + 1))
fi
}
check_eq() {
# check_eq <label> <expected> <actual>
if [ "$2" = "$3" ]; then
check "$1" "yes"
else
check "$1 (expected [$2], got [$3])" "no"
fi
}
# Resolve pytest: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with instructions rather than silently skipping checks.
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
echo "FAIL: pytest not found." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this suite at an existing pytest:" >&2
echo " PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
exit 1
}
python_bin="$(dirname "${pytest_bin}")/python3"
if [ ! -x "${python_bin}" ]; then
python_bin="$(command -v python3 || true)"
fi
if [ -z "${python_bin}" ]; then
echo "FAIL: python3 not found on PATH." >&2
exit 1
fi
if ! "${python_bin}" -c "import matplotlib" >/dev/null 2>&1; then
echo "FAIL: matplotlib is not importable from ${python_bin}." >&2
echo " Install the lab's dependencies with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
exit 1
fi
echo "Day 128 — Matplotlib Fundamentals"
echo
# --------------------------------------------------------------------------
echo "1. The tools and the versions this lab was written against"
# --------------------------------------------------------------------------
versions="$("${python_bin}" - <<'PY'
import platform
import sys
from importlib.metadata import version
print(f"python {platform.python_version()}")
for name in ("matplotlib", "numpy", "pytest"):
print(f"{name:<10} {version(name)}")
print(f"platform {platform.platform()}")
print(f"exe {sys.executable.rsplit('/', 3)[-1]}")
PY
)"
echo "${versions}" | sed 's/^/ /'
pinned_mpl="$(grep -E '^matplotlib==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
installed_mpl="$("${python_bin}" -c "from importlib.metadata import version; print(version('matplotlib'))")"
check_eq "installed matplotlib matches requirements.txt" "${pinned_mpl}" "${installed_mpl}"
major="$("${python_bin}" -c "import matplotlib; print(matplotlib.__version__.split('.')[0])")"
check_eq "matplotlib is version 3 or later" "3" "${major}"
backend="$("${python_bin}" -c "import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot as plt; print(plt.get_backend())")"
check_eq "matplotlib runs on the headless Agg backend" "agg" "$(echo "${backend}" | tr '[:upper:]' '[:lower:]')"
# --------------------------------------------------------------------------
echo
echo "2. Every reference script runs and every assertion inside it holds"
# --------------------------------------------------------------------------
for script in 01_the_two_apis 02_data_round_trip 03_pixel_arithmetic \
04_labels_limits_and_scales 05_subplots 06_log_scale_drops_nonpositive \
07_legends 08_figure_leak 09_vector_versus_raster; do
out="$(cd "${lab_dir}/examples" && "${python_bin}" "${script}.py" 2>&1)"
status=$?
if [ "${status}" -ne 0 ]; then
check "${script}.py exits 0" "no"
echo "${out}" | tail -5 | sed 's/^/ /'
else
check "${script}.py exits 0" "yes"
fi
case "${out}" in
*"${script}.py: every assertion held."*)
check "${script}.py reports every assertion held" "yes" ;;
*) check "${script}.py reports every assertion held" "no" ;;
esac
done
# --------------------------------------------------------------------------
echo
echo "3. The reference pytest suite: real artist state, real exceptions"
# --------------------------------------------------------------------------
ref_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
ref_status=$?
echo "${ref_out}" | tail -3 | sed 's/^/ /'
if [ "${ref_status}" -eq 0 ]; then
check "pytest examples exits 0" "yes"
else
check "pytest examples exits 0" "no"
fi
case "${ref_out}" in
*" failed"*) check "no test in the reference suite failed" "no" ;;
*) check "no test in the reference suite failed" "yes" ;;
esac
ref_passed="$(printf '%s\n' "${ref_out}" | grep -o '[0-9][0-9]* passed' | head -1 | cut -d' ' -f1)"
if [ "${ref_passed:-0}" -ge 15 ]; then
check "the reference suite ran at least 15 tests (ran ${ref_passed})" "yes"
else
check "the reference suite ran at least 15 tests (ran ${ref_passed:-0})" "no"
fi
# --------------------------------------------------------------------------
echo
echo "4. The starter suite skips unattempted work instead of failing it"
# --------------------------------------------------------------------------
start_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
start_status=$?
echo "${start_out}" | tail -3 | sed 's/^/ /'
if [ "${start_status}" -eq 0 ]; then
check "pytest starter exits 0 on an untouched checkout" "yes"
else
check "pytest starter exits 0 on an untouched checkout" "no"
fi
case "${start_out}" in
*" failed"*) check "the starter suite reports no failures" "no" ;;
*) check "the starter suite reports no failures" "yes" ;;
esac
case "${start_out}" in
*skipped*) check "unwritten exercises are reported as skipped, not passed" "yes" ;;
*) check "unwritten exercises are reported as skipped, not passed" "no" ;;
esac
# The import guard. Both directories contain a module called `plotting`,
# and pytest imports test files by putting their directory on sys.path --
# so collecting both suites at once would otherwise let the starter tests
# import the REFERENCE solution and report unwritten exercises as passing.
# Each directory's conftest.py prevents that. This check proves it still
# does: across both suites, the skip count must be unchanged.
both_out="$(cd "${lab_dir}" && "${pytest_bin}" -q -p no:cacheprovider 2>&1)"
start_skipped="$(printf '%s\n' "${start_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
both_skipped="$(printf '%s\n' "${both_out}" | grep -o '[0-9][0-9]* skipped' | head -1 | cut -d' ' -f1)"
check_eq "collecting both suites at once does not turn skips into passes" \
"${start_skipped:-none}" "${both_skipped:-none}"
# --------------------------------------------------------------------------
echo
echo "5. The harness can actually fail"
# --------------------------------------------------------------------------
# A green test suite proves nothing until you have watched it go red. This
# section re-runs the reference legend test with the reference function's
# label order deliberately swapped, and asserts that the re-run reports the
# failure and exits non-zero. If this section passes, section 2 is not
# decorative.
if [ -z "${D128_SELF_TEST:-}" ]; then
self_out="$(cd "${lab_dir}/examples" && D128_SELF_TEST=1 "${python_bin}" -c "
import plotting as P
_orig = P.plot_two_series_with_legend
def _broken(x, y1, label1, y2, label2):
# deliberately swap the label order to break the assertion
return _orig(x, y1, label2, y2, label1)
P.plot_two_series_with_legend = _broken
exec(open('07_legends.py').read())
" 2>&1)"
self_status=$?
if [ "${self_status}" -ne 0 ]; then
check "a deliberately swapped label order makes script 07 exit non-zero (${self_status})" "yes"
else
check "a deliberately swapped label order makes script 07 exit non-zero" "no"
fi
case "${self_out}" in
*"AssertionError"*"expected ['measured', 'predicted']"*)
check "the failing assertion is named in the output with both values" "yes" ;;
*) check "the failing assertion is named in the output with both values" "no" ;;
esac
else
echo " (self-test run: section 5 does not recurse)"
fi
# --------------------------------------------------------------------------
echo
echo "6. Nothing was left behind"
# --------------------------------------------------------------------------
# `.venv` is pruned from both searches below. The virtual environment ships
# matplotlib's, NumPy's and pytest's own precompiled bytecode -- hundreds of
# __pycache__ directories that came with the packages and have nothing to do
# with whether THIS lab tidied up after itself.
if find "${lab_dir}" -name '.venv' -prune -o -type d -name '__pycache__' -print -quit 2>/dev/null | grep -q .; then
check "no __pycache__ directory left by the lab's own code" "no"
else
check "no __pycache__ directory left by the lab's own code" "yes"
fi
if find "${lab_dir}" -name '.venv' -prune -o -type d -name '.pytest_cache' -print -quit 2>/dev/null | grep -q .; then
check "no .pytest_cache directory left under the lab" "no"
else
check "no .pytest_cache directory left under the lab" "yes"
fi
if find "${lab_dir}" -name '.venv' -prune -o -type f \( -name '*.png' -o -name '*.svg' -o -name '*.pdf' \) -print -quit 2>/dev/null | grep -q .; then
check "no image file (.png/.svg/.pdf) left by the lab's own code" "no"
else
check "no image file (.png/.svg/.pdf) left by the lab's own code" "yes"
fi
if grep -rqE 'urlopen|requests\.|socket\.|http://|https://' \
"${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null; then
check "no lab source opens a network connection" "no"
else
check "no lab source opens a network connection" "yes"
fi
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting
Every entry below was hit while building this lab, or is named by a test that exists because of it.
ModuleNotFoundError: No module named 'plotting'
You ran a reference script from the lab directory instead of from inside
examples/. The scripts import plotting from beside themselves.
cd examples
../.venv/bin/python3 01_the_two_apis.py
cd ..
The pytest suites do not have this problem, because pytest puts the test file's own directory on the import path.
ModuleNotFoundError: No module named 'matplotlib'
You are running the system python3 rather than the lab's. Everything in
this lab goes through .venv/bin/python3:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
If you would rather use an interpreter you already have, the harness accepts one:
PYTEST=/path/to/pytest bash tests/run_tests.sh
The starter tests all skip and I have written code
A skip means the function still raises NotImplementedError. Replace the
raise NotImplementedError line with your own body — leaving it in place
above your code still raises before your return statement is ever reached.
A window tries to open, or the process hangs
Something imported matplotlib.pyplot before matplotlib.use("Agg") ran,
or called plt.show(). Every file in this lab sets the Agg backend at the
very top, before import matplotlib.pyplot as plt — if you add a new
file, keep that order, and never call plt.show() anywhere in this lab.
tests/run_tests.sh also exports MPLBACKEND=Agg as a second line of
defence.
AssertionError: expected (600, 400) at 100dpi, got (...)
Check that save_at_size_and_dpi does not pass bbox_inches='tight'.
A tight bounding box crops the saved image to the drawn content, which
means the output size is figsize * dpi minus whatever margin got
trimmed — not the exact product this exercise is testing. The reference
solution passes no bbox_inches at all, which keeps matplotlib's default,
untrimmed canvas.
My png_dimensions function raises or returns the wrong numbers
A PNG file starts with an 8-byte signature (\x89PNG\r\n\x1a\n), then its
first chunk, which is always IHDR: a 4-byte length, the 4-byte type
string IHDR, then width and height as big-endian 4-byte unsigned
integers — 24 bytes to read in total, at fixed offsets. A common mistake
is reading the integers as little-endian, which produces a huge, wrong
number rather than a clean error; if your reported dimensions look
absurd (millions of pixels), check the byte order first.
ax.get_ylim() after set_yscale('log') still includes zero or a
negative number
You called set_yscale('log') but never forced a redraw. matplotlib
recomputes an Axes' limits from its scale lazily, on the next draw — call
fig.canvas.draw() (as the reference solution does) before reading
ax.get_ylim(), or the limits you read back may still reflect the
previous linear scale.
My legend text is in the wrong order, or is empty
ax.get_legend() returns None until ax.legend() has actually been
called — check you called it, and called it after both ax.plot() calls
with their label= arguments set, not before. The order of
legend.get_texts() follows plotting order, so if you plot series B
before series A, the legend will read [B's label, A's label] regardless
of what order you intended.
plt.get_fignums() grows across an entire pytest run, not just one test
Every test in examples/test_reference.py runs against an autouse
fixture that calls plt.close("all") before and after each test. If you
add a new test file, add the same fixture (or call plt.close("all")
directly) — otherwise figures opened by one test leak into the next
test's plt.get_fignums() count, and a genuinely correct implementation
can appear to fail a figure-count assertion that has nothing to do with
its own logic.
__pycache__, .pytest_cache, or a stray .png/.svg/.pdf appears
and section 6 fails
Run the cleanup:
find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache
Every image this lab saves goes into a tempfile.TemporaryDirectory()
that deletes itself automatically — if a .png, .svg or .pdf file is
found anywhere under the lab directory (outside .venv) after a run, it
means a test or script was edited to save somewhere else, and section 6
of the harness will flag exactly that.
Note the -path ./.venv -prune in the __pycache__ cleanup command, and
note that the harness uses the same prune. matplotlib, NumPy and pytest
ship hundreds of their own __pycache__ directories inside the virtual
environment; those are theirs, not litter you created. .venv itself is
the documented setup and is never treated as a stray file.
Running pytest with no arguments gives me a different skip count
It should not, and there is a check for exactly that. Both examples/
and starter/ contain a module called plotting. Without the
conftest.py in each directory, collecting both suites at once would
import whichever copy was seen first and reuse it for the other — so your
unwritten starter exercises would silently pass against the reference
solution. A wrong answer with a green tick on it is the worst kind of
wrong answer.
If you delete or edit either conftest.py, section 4 of the harness will
notice: it compares the skip count from pytest starter against the skip
count from pytest with no arguments and requires them to be identical.
Windows
Not run here, and this file will not pretend otherwise. Use the Windows
Subsystem for Linux and follow the Linux instructions, or use Git Bash
with .venv\Scripts\python.exe in place of .venv/bin/python3. Nothing in
the lab is platform-specific — but "should work" and "was run" are
different claims and only the second one is worth making.
Security notes
Security notes
What this lab does
It draws charts, saves them to a temporary directory, reads state back off
the returned Figure and Axes objects, and deletes the temporary directory
when each test finishes. It opens no network connection after the
one-time pip install, needs no credentials, no sudo and no elevated
permissions, and touches nothing outside its own directory and the
system's temporary-file area. All plotted data is invented and is written
out directly in each script.
Section 6 of tests/run_tests.sh greps every source file in examples/
and starter/ for urlopen, requests., socket., http:// and
https://, and fails if any of them appears. It also checks that no
.png, .svg or .pdf file is left anywhere under the lab directory
after a full run — every image this lab produces lives in a
tempfile.TemporaryDirectory() that is deleted automatically when its
with block exits, matching the lesson's own claim that the lab writes no
generated image files to disk.
The virtual environment
python3 -m venv .venv creates the environment inside the lab directory,
so nothing installed here can affect the rest of your machine, and
rm -rf .venv is a complete undo. The three packages are pinned to exact
versions in requirements/requirements.txt, and section 1 of the harness
reads the installed version back and compares it against that file rather
than trusting that the install did what it said.
The one thing worth carrying away from this particular day
A plotting helper that draws into "whichever figure is current" is a
shared-mutable-state bug wearing a data-visualization costume. Exercise
1's draw_line_pyplot_style function is not contrived — it is the natural
shape of code written against plt.plot/plt.xlabel/plt.title, and it
silently overlays whatever was drawn last onto whatever gets drawn next
unless something remembers to call plt.figure() first. In a training
loop or an evaluation script, that "something" is easy to forget under
deadline pressure, and the failure mode is not a crash — it is a report
where two experiments' curves sit on the same axes with nobody having
asked for that, and nothing in the output flags it as wrong. The object
API's fig, ax = plt.subplots() removes the failure mode structurally: a
function that returns its own fig and ax cannot silently draw into
someone else's, because there is no "someone else's" it could reach
without being handed the object explicitly.
What this lab deliberately does not claim
seaborn is genuinely installed in this authoring environment but is not
imported anywhere in this lab — Day 129 owns statistical plotting with
seaborn, and this lab's tests, scripts and lesson text do not reproduce
any seaborn output. plotnine and plotly are not installed anywhere in
this authoring environment; both are described from their public
documentation in the lesson's Tools section, explicitly marked as not run
here, and no output attributed to either appears anywhere in this lab or
its lesson.