Math, Statistics, and DataData Visualization › Day 128

Hands-on lab — 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-fundamentals when 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(), then ax.plot, ax.set_xlabel) behave differently when a drawing routine is called more than once, and demonstrate the difference with plt.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 figsize and dpi, and explain why bbox_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_ylim overrides 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 inspecting ax.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 pip into a virtual environment.
  • Day 43 — python3 -m venv and pip 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.exe in place of .venv/bin/python3. Not run here; troubleshooting.md says 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.
  • matplotlib 3.11.1, numpy 2.5.2 and pytest 9.1.1, installed into a lab-local virtual environment from requirements/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

  1. bash tests/run_tests.sh; echo "exit=$?" prints 34 checks, 0 failure(s). and exit=0.
  2. .venv/bin/pytest examples -q -p no:cacheprovider prints 19 passed.
  3. .venv/bin/pytest starter -q -p no:cacheprovider prints 14 passed once you have finished, and never prints a failure you have not been shown.
  4. Each of the nine reference scripts ends with every assertion held.
  5. find . -path ./.venv -prune -o -type f \( -name '*.png' -o -name '*.svg' -o -name '*.pdf' \) -print prints nothing after a full run.

Tests

tests/run_tests.sh runs 34 checks in six sections:

  1. 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.
  2. The nine reference scripts — each must exit 0 and print that every one of its internal assertions held.
  3. 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.
  4. 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.
  5. 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.
  6. A clean disk — no __pycache__, no .pytest_cache, and no .png/.svg/.pdf file 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

  1. Measure the SVG size cost of bbox_inches='tight'. Save the same figure as SVG with and without bbox_inches='tight', and compare the resulting viewBox and file size. Confirm which one actually changes and which stays fixed.
  2. 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.
  3. Build a fourth API-mixing bug. Write a function that creates fig, ax = plt.subplots() but then calls plt.xlabel(...) (the pyplot function, not ax.set_xlabel) after a second figure has been created elsewhere. Assert which Axes actually receives the label, and explain why in a comment.
  4. Add a tenth exercise: constrained_layout versus tight_layout. Build a plt.subplots(2, 2) grid with long titles that overlap by default, apply each layout engine in turn, and assert on fig.get_constrained_layout() or the Axes' bounding boxes to show the overlap is resolved.
  5. 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).
  • 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.