Math, Statistics, and Datapandas and Data Wrangling › Day 124

Hands-on lab — Day 124: Merging and Reshaping

Commands

Setup

cd labs/sections/math-statistics-and-data/day-124-merging-and-reshaping
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"

Run

.venv/bin/pytest examples
.venv/bin/pytest starter

Test

bash tests/run_tests.sh

File tree

examples/conftest.py
examples/data.py
examples/test_merge.py
expected-output/examples-run.txt
expected-output/FIELDS.md
expected-output/starter-run.txt
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/data.py
starter/test_merge.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 124 lab — Joins That Keep Their Shape

Lesson

  • Lesson title: Merging and Reshaping
  • Day number: 124 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-124-merging-and-reshaping
  • 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-124-merging-and-reshaping when the site is running.

Purpose

Nine numbered exercises, each proving one real pandas 3.0.5 merge, concat, or reshape behaviour by running code and reading real values. The throughline is that a join is a claim about cardinality, and pandas will not check it for you unless you ask. Exercise 1 makes the failure concrete before anything else: merging two frames on a key that is duplicated on both sides produces a per-key Cartesian product, not an error. Exercise 2 gives the fix immediately — validate= turns a stated assumption into an enforced one. Every later exercise adds one more piece of the reshaping toolkit: indicator=True, the dtype-mismatch join, the four join types, suffixes and join(), concat alignment, the melt/pivot round trip, and pivot versus pivot_table.

Learning objectives

By the end of this lab you will be able to:

  • Demonstrate that a many-to-many merge produces exactly the product of the per-key group sizes on each side, and read a merge's input and output shapes to catch an unintended row explosion.
  • Use validate='one_to_one' (or 'one_to_many') to make pandas raise a MergeError the instant a stated cardinality assumption is violated.
  • Use indicator=True to see whether each row matched on the left only, the right only, or both, and confirm those three counts reconcile exactly with the input row counts.
  • Demonstrate that a dtype-mismatched join key can fail either silently (zero matching rows, no exception) or loudly (ValueError), depending on the specific dtype mismatch, and know which pandas 3.0.5 catches.
  • State the row counts inner, left, right and outer joins produce on the same pair of frames, and explain why each differs.
  • Use suffixes= to control overlapping non-key column names instead of accepting the default _x/_y, and use on= versus left_on=/ right_on= and .join() on an index correctly.
  • Explain why pd.concat's alignment fills unmatched columns or labels with NaN rather than erroring, on both axis=0 and axis=1.
  • Convert a wide DataFrame to long form with melt and back with pivot, and get the original frame back exactly.
  • Explain the difference between pivot (raises on duplicate index/ column pairs) and pivot_table (aggregates them), and choose correctly between an error and a silently averaged number.

Prerequisites

  • Day 120 — Series and DataFrames, index alignment, and Copy-on-Write. This lab's tables are ordinary DataFrames built the way that day taught.
  • Day 121 — loading and inspecting data, and the type-inference traps that this lab's exercise 4 turns into a join failure.
  • Day 122 — boolean masks and the partition invariant, the same "check the parts reconcile with the whole" habit this lab applies to merges via indicator=True.
  • Day 123 — split-apply-combine and the reconciliation habit this lab extends from groupby to merge.
  • Week 13 (SQL) — this lab's Tools section in the lesson compares merge against SQL joins and sqlite3; you do not need SQL to run anything here.
  • A working python3 on your PATH to create the lab's virtual environment.

Supported operating systems

System Status
macOS (Apple Silicon or Intel) Captured here — macOS 26.5.2, arm64
Linux (any current distribution) Expected identical, given the pinned versions below
Windows Use WSL and follow the Linux path. mktemp -d is used inside tests/run_tests.sh; native Windows was not tested and no output is claimed for it

Hardware requirements

Anything. Every table in this lab is a small hand-built literal, at most a dozen rows. No GPU, no network beyond the one-time install, no meaningful disk use.

Required software

Tool Minimum Used here Why
python3 3.11 3.14.0 Runs everything; standard library venv builds the lab's environment
pandas 3.0.5 exactly 3.0.5 Every merge, concat, melt, pivot and pivot_table call
pyarrow 25.0.1 25.0.1 pandas 3.0's default backend, installed for parity with Days 120-123
numpy 2.5.2 2.5.2 NaN-related comparisons in the concat alignment exercise
pytest 9.1.1 9.1.1 The test harness every exercise is written against
bash 3.2 3.2.57 The outer test harness

Check your Python in one line: python3 --version.

Free and open-source options

Everything here is free.

  • pandas (BSD 3-Clause), NumPy (BSD 3-Clause) and pytest (MIT) are fully open source with no paid tier.
  • PyArrow (Apache 2.0) is the Arrow project's Python bindings, also fully open source.
  • polars (MIT), described from its documentation in the lesson's Tools section rather than run here, offers join with an explicit validate argument as a free alternative.
  • Plain SQLite (public domain), covered in Week 13, is demonstrated in the lesson via the standard library's sqlite3 module and enforces referential integrity pandas cannot — the lesson's Tools section says exactly when to prefer it.

No account, no key, no paid tier, and no part of this lab is degraded without one.

Installation

cd labs/sections/math-statistics-and-data/day-124-merging-and-reshaping
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/python3 -c "import pandas; print(pandas.__version__)"

If your tools live somewhere unusual, tests/run_tests.sh takes an override rather than guessing:

PYTEST=/path/to/pytest bash tests/run_tests.sh

File structure

day-124-merging-and-reshaping/
├── README.md                     this file
├── metadata.yml                  lab metadata and the recorded run
├── security.md                   what this lab does to your machine
├── troubleshooting.md            grouped by the message you actually see
├── requirements/
│   ├── README.md                  versions, and what each package is for
│   └── requirements.txt           pandas==3.0.5, pyarrow==25.0.1, numpy==2.5.2, pytest==9.1.1
├── starter/                      YOUR work happens here
│   ├── 00_brief.md                exercise-by-exercise instructions
│   ├── data.py                    the ten tables every exercise uses
│   ├── conftest.py                fixtures wrapping data.py
│   └── test_merge.py              nine exercises, each a pytest.skip to replace
├── examples/                     the reference. Read AFTER you have tried
│   ├── data.py
│   ├── conftest.py
│   └── test_merge.py              the fully worked, 22-assertion answer key
├── tests/
│   └── run_tests.sh               13 checks of real behaviour
└── expected-output/               captured from a real run on 2026-08-19
    ├── FIELDS.md                   what must match and what may differ
    ├── examples-run.txt            pytest examples -v, captured
    ├── starter-run.txt             pytest starter -v, captured (all skip)
    └── test-run.txt                the full harness run

How to run

## 1. The reference suite. Read this AFTER you have tried the exercises,
##    never before -- it is the answer key.
.venv/bin/pytest examples
.venv/bin/pytest examples -v

## 2. Where you stand on the exercises. An untouched checkout reports
##    22 skipped, 0 failed.
.venv/bin/pytest starter -v

## 3. Your work: open starter/test_merge.py and starter/00_brief.md, and
##    replace each pytest.skip(...) with real assertions.
.venv/bin/pytest starter -v -k test_1
.venv/bin/pytest starter -v -k test_2
## ... and so on through test_9, or just:
.venv/bin/pytest starter -v

## 4. Check everything, including the harness's own proof that it can fail.
bash tests/run_tests.sh

Never run pytest examples starter in one command. Both directories define a module named test_merge.py; pytest imports test modules by their dotted name, and running both together was tested directly in this lab and aborts collection outright with an import file mismatch error before running a single test. Run them as two separate commands, always, as shown above.

What the commands do

.venv/bin/pytest examples runs the fully worked reference suite: 22 tests across the nine exercises, each asserting a real value computed from one of the ten tables in data.py.

.venv/bin/pytest starter runs your own suite. On an untouched checkout, every one of the 22 tests calls pytest.skip(...) and is reported as s, so the run exits 0 with nothing yet proven. Replace a skip with real assertions and delete the skip line; when all 22 are written and passing, the exercise is done.

bash tests/run_tests.sh confirms the installed pandas matches requirements.txt exactly, runs pytest examples and requires 22 passed, runs pytest starter and requires 22 skipped on the checked-in state, then solves every exercise in a scratch copy made with mktemp -d (never touching the real starter/test_merge.py), confirms that copy passes in full, deliberately breaks one assertion inside it, confirms the run now exits non-zero with a failure reported, restores the line, and confirms it passes again — proving the suite can genuinely fail rather than merely claiming to. It finishes by checking no file in examples/ or starter/ contains a URL, and that nothing is left on disk.

Expected output

The harness ends with a real captured line:

13 checks, 0 failure(s)

and exits 0. pytest examples ends with:

22 passed in 0.05s

pytest starter, on the checked-in state, ends with:

22 skipped in 0.02s

The explosion this whole lab opens with, exactly as captured:

left_dup shape  = (6, 3)
right_dup shape = (7, 3)
merged (inner)  = (14, 5)   # 3*2 (key A) + 2*4 (key B)

The full capture of both suites is in expected-output/, and expected-output/FIELDS.md says which values are specific to pandas 3.0.5 or to this machine, and which would not differ on any correctly installed copy of this exact version.

Validation steps

  1. bash tests/run_tests.sh ends with 13 checks, 0 failure(s) and exits 0.
  2. left_dup (6 rows) merged inner against right_dup (7 rows) on cust_id produces exactly 14 rows — 6 from key A, 8 from key B — matching the product of each side's per-key counts.
  3. validate='one_to_one' raises pandas.errors.MergeError on left_dup/right_dup, and raises nothing on left_keys/right_keys.
  4. indicator=True on left_keys/right_keys gives left_only=1, right_only=1, both=3, reconciling exactly with both input row counts (4 and 4) and the merged total (5).
  5. int_keyed (int64) merged against str_keyed (categorical, same digits) returns 0 rows silently; casting str_keyed's key to int64 recovers 3 matching rows. A plain-string key of the same digits, in contrast, makes the merge raise ValueError.
  6. The four join types on left_keys/right_keys give row counts inner=3, left=4, right=4, outer=5.
  7. A plain merge on price_left/price_right produces price_x and price_y; suffixes=('_catalog', '_live') produces price_catalog and price_live instead.
  8. pd.concat with mismatched columns (axis=0) or mismatched index labels (axis=1) fills exactly the unmatched cells with NaN and nothing else.
  9. wide.melt(...) then .pivot(...) round-trips back to wide exactly, once the pivoted frame's index is reset and its columns reordered.
  10. dup_index_col.pivot(...) raises ValueError on the duplicate ('Ann', 'math') pair; .pivot_table(..., aggfunc='mean') returns 85.0 for that same cell instead.

Tests

bash tests/run_tests.sh
echo "exit code: $?"

13 checks, exit 0 when they all pass and non-zero otherwise. They are value checks, not file-existence checks: the reference suite's 22 assertions are exercised through pytest, the exercise suite is confirmed all-skip on the checked-in state, and a scratch copy proves the suite can genuinely fail and then recover.

Override, if your tools are somewhere unusual:

PYTEST=/path/to/pytest bash tests/run_tests.sh

Cleanup

find . -path ./.venv -prune -o -type d -name '__pycache__' -print -exec rm -rf -- {} +
rm -rf .pytest_cache

tests/run_tests.sh clears __pycache__ and .pytest_cache both before and after it runs, and its scratch copy of the solved suite lives in a mktemp -d directory removed by a trap — so if you only ran the harness, there is nothing left to clean up.

To remove the lab's virtual environment entirely: rm -rf .venv.

To reset your own work and start the exercises again:

git checkout -- starter/

Troubleshooting

troubleshooting.md has the full list, grouped by the message you actually see. The ones you are most likely to meet:

  • pytest examples starter aborts with import file mismatch — do not run both directories in one invocation; they share a module name.
  • Exercise 1's expected row count is not 14 — recompute it from each side's value_counts() rather than hardcoding a number.
  • Exercise 4's dtype-mismatch merge does not return zero rows — confirm str_keyed['id'] is a pandas.Categorical, not a plain string column; plain strings against int64 now raise instead.
  • Exercise 8's round trip does not equal the original — drop the pivoted columns' axis name with .rename_axis(columns=None) and reorder the columns back to the original order before comparing.

Security notes

security.md has the full account. In short: this lab opens the network exactly once, to install its four pinned packages, and everything else runs offline, writes only inside its own .venv, needs no credential, and touches no real data — every table is a small invented literal built by hand in data.py.

Extension exercises

  1. Reproduce the lesson's opening explosion at scale. Build two 100-row frames that each share a single duplicated key value and merge them; confirm you get exactly 10,000 rows, then add validate='one_to_one' and confirm it raises immediately, before any row is materialized.
  2. Feature-engineering angle. Build a small "orders" frame and a "customer lookup" frame where the lookup table has one accidentally duplicated customer ID. Merge without validate=, then with it, and write one paragraph on what a machine-learning feature pipeline built on the unvalidated merge would have silently over-weighted.
  3. Compare .join() against .merge() on three or more frames. Chain three lookup tables together with .join() on a shared index and again with two .merge() calls, and confirm the results agree.
  4. Simulate the SQL alternative. Using Week 13's sqlite3, load left_dup and right_dup into two tables, add a UNIQUE constraint on the column that should not be duplicated, and confirm the database rejects the insert that a pandas validate= would have caught instead — but before any join runs, not at merge time.
  5. pivot_table with a different aggfunc. Repeat exercise 9 with aggfunc='sum', aggfunc='max', and a custom function, and record how each one answers the "what happens to Ann's two math scores" question differently.
  • Previous day: Day 123 — Groupby and Aggregation (labs/sections/math-statistics-and-data/day-123-groupby-and-aggregation/).
  • Next day: Day 125 (labs/sections/math-statistics-and-data/), continuing Week 18.
  • Week 18 project: the week's project directory (labs/sections/math-statistics-and-data/projects/week-18/), "Messy Dataset Rescue" — building directly on the merge and reshape fundamentals from this lab.

Expected output

FIELDS.md

# What in this directory is version-specific to pandas 3.0.5

Captured on macOS, Python 3.14.0, pandas 3.0.5, pyarrow 25.0.1, NumPy
2.5.2, pytest 9.1.1, on 2026-08-19.

## Will differ on another machine, and that is fine

- **pytest's run duration line** (`22 passed in 0.05s` — the count is
  fixed, the seconds are not).
- **`platform darwin`** in `examples-run.txt` and `starter-run.txt` — a
  Linux or Windows run reports its own platform string there instead;
  nothing about the test results depends on it.

## Would differ on an earlier pandas major version, and that is the point

- **Exercise 4's `str_keyed['id']` dtype and the plain-string-key raise.**
  This is the biggest version-specific finding in this lab. On pandas
  3.0.5, a column built from a plain Python `list[str]` infers to
  pandas' own new `str` extension dtype by default (`pandas.StringDtype`,
  printed as `str`), not the historical `object` dtype earlier pandas
  versions would have used. More importantly for this lesson: pandas
  3.0.5's `merge()` explicitly checks for an incompatible key dtype pair
  (a numeric key against a string-like key) **before** joining, and
  raises `ValueError: You are trying to merge on int64 and str columns
  for key 'id'. If you wish to proceed you should use pd.concat` rather
  than silently returning zero rows. Earlier pandas versions (and this
  lab's `str_keyed` fixture, deliberately built as a `pandas.Categorical`
  instead) still reproduce the classic silent failure — a categorical key
  is not caught by that same check, so an int64-vs-categorical merge
  returns 0 rows with no exception and no warning at all. Do not assume
  either behaviour without checking the installed pandas version; this
  lab's exercise 4 tests both cases directly rather than picking one.
- **`validate=`**'s four modes (`'one_to_one'`, `'one_to_many'`,
  `'many_to_one'`, `'many_to_many'`) and `pandas.errors.MergeError` have
  been stable pandas API for a long time; nothing about exercise 2 is new
  to 3.0.5, but it is included here because it is the day's central
  claim and worth confirming directly rather than assuming.
- **`pytest examples starter` in one invocation.** This was tested
  directly in this lab (not merely assumed from the shared authoring
  brief) and does not silently let one directory's `test_merge.py`
  shadow the other's — it aborts collection outright with an `import
  file mismatch` error and pytest exits non-zero before running anything.
  Whether a given pytest version reports a hard collection error or a
  softer silent shadow can depend on pytest's own version and rootdir
  configuration; either failure mode is a reason never to combine the
  two directories in one command, and this lab's README and
  troubleshooting guide describe the error actually observed here rather
  than assuming the softer failure mode.

## Will not differ, because they are exact arithmetic on fixed literals

Every other captured value — the exploded row count in exercise 1 (14,
with 6 from key A and 8 from key B), the `MergeError` raises and passes in
exercise 2, the `indicator=True` counts in exercise 3 (`left_only=1`,
`right_only=1`, `both=3`), the four join-type row counts in exercise 5
(`inner=3`, `left=4`, `right=4`, `outer=5`), the suffix behaviour in
exercise 6, the exact `NaN` placement in exercise 7's two `concat` calls,
the melt/pivot round trip in exercise 8, and the `pivot`/`pivot_table`
contrast in exercise 9 (`85.0`, `91.0`, `70.0`) — are exact arithmetic
over the fixed literal tables in `data.py`, and will reproduce identically
on any machine running pandas 3.0.5 with the same input.

examples-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-124-merging-and-reshaping/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-124-merging-and-reshaping
collecting ... collected 22 items

examples/test_merge.py::test_1_many_to_many_merge_produces_the_per_key_product PASSED [  4%]
examples/test_merge.py::test_2_validate_one_to_one_raises_on_duplicated_keys PASSED [  9%]
examples/test_merge.py::test_2_validate_one_to_one_passes_on_genuinely_unique_keys PASSED [ 13%]
examples/test_merge.py::test_2_validate_one_to_many_raises_when_the_many_side_is_actually_duplicated_on_both PASSED [ 18%]
examples/test_merge.py::test_3_indicator_counts_reconcile_with_the_inputs PASSED [ 22%]
examples/test_merge.py::test_4_dtype_mismatch_join_returns_zero_rows PASSED [ 27%]
examples/test_merge.py::test_4_casting_one_side_fixes_it PASSED          [ 31%]
examples/test_merge.py::test_4_plain_str_key_against_int64_raises_instead_of_matching_nothing PASSED [ 36%]
examples/test_merge.py::test_5_inner_keeps_only_the_overlap PASSED       [ 40%]
examples/test_merge.py::test_5_left_keeps_every_left_row PASSED          [ 45%]
examples/test_merge.py::test_5_right_keeps_every_right_row PASSED        [ 50%]
examples/test_merge.py::test_5_outer_keeps_every_row_from_both PASSED    [ 54%]
examples/test_merge.py::test_5_row_counts_in_one_table PASSED            [ 59%]
examples/test_merge.py::test_6_default_suffixes_are_x_and_y PASSED       [ 63%]
examples/test_merge.py::test_6_explicit_suffixes_rename_as_asked PASSED  [ 68%]
examples/test_merge.py::test_6_on_versus_left_on_right_on_give_the_same_result PASSED [ 72%]
examples/test_merge.py::test_6_join_on_index_matches_merge_on_column PASSED [ 77%]
examples/test_merge.py::test_7_concat_axis_0_with_mismatched_columns_fills_nan PASSED [ 81%]
examples/test_merge.py::test_7_concat_axis_1_with_mismatched_index_fills_nan PASSED [ 86%]
examples/test_merge.py::test_8_melt_then_pivot_round_trips_to_the_original PASSED [ 90%]
examples/test_merge.py::test_9_pivot_raises_on_duplicate_index_column_pairs PASSED [ 95%]
examples/test_merge.py::test_9_pivot_table_aggregates_the_duplicates PASSED [100%]

============================== 22 passed in 0.05s ==============================

starter-run.txt

============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0 -- <repo>/labs/sections/math-statistics-and-data/day-124-merging-and-reshaping/.venv/bin/python3.14
cachedir: .pytest_cache
rootdir: <repo>/labs/sections/math-statistics-and-data/day-124-merging-and-reshaping
collecting ... collected 22 items

starter/test_merge.py::test_1_many_to_many_merge_produces_the_per_key_product SKIPPED [  4%]
starter/test_merge.py::test_2_validate_one_to_one_raises_on_duplicated_keys SKIPPED [  9%]
starter/test_merge.py::test_2_validate_one_to_one_passes_on_genuinely_unique_keys SKIPPED [ 13%]
starter/test_merge.py::test_2_validate_one_to_many_raises_when_the_many_side_is_actually_duplicated_on_both SKIPPED [ 18%]
starter/test_merge.py::test_3_indicator_counts_reconcile_with_the_inputs SKIPPED [ 22%]
starter/test_merge.py::test_4_dtype_mismatch_join_returns_zero_rows SKIPPED [ 27%]
starter/test_merge.py::test_4_casting_one_side_fixes_it SKIPPED (exe...) [ 31%]
starter/test_merge.py::test_4_plain_str_key_against_int64_raises_instead_of_matching_nothing SKIPPED [ 36%]
starter/test_merge.py::test_5_inner_keeps_only_the_overlap SKIPPED (...) [ 40%]
starter/test_merge.py::test_5_left_keeps_every_left_row SKIPPED (exe...) [ 45%]
starter/test_merge.py::test_5_right_keeps_every_right_row SKIPPED (e...) [ 50%]
starter/test_merge.py::test_5_outer_keeps_every_row_from_both SKIPPED    [ 54%]
starter/test_merge.py::test_5_row_counts_in_one_table SKIPPED (exerc...) [ 59%]
starter/test_merge.py::test_6_default_suffixes_are_x_and_y SKIPPED (...) [ 63%]
starter/test_merge.py::test_6_explicit_suffixes_rename_as_asked SKIPPED  [ 68%]
starter/test_merge.py::test_6_on_versus_left_on_right_on_give_the_same_result SKIPPED [ 72%]
starter/test_merge.py::test_6_join_on_index_matches_merge_on_column SKIPPED [ 77%]
starter/test_merge.py::test_7_concat_axis_0_with_mismatched_columns_fills_nan SKIPPED [ 81%]
starter/test_merge.py::test_7_concat_axis_1_with_mismatched_index_fills_nan SKIPPED [ 86%]
starter/test_merge.py::test_8_melt_then_pivot_round_trips_to_the_original SKIPPED [ 90%]
starter/test_merge.py::test_9_pivot_raises_on_duplicate_index_column_pairs SKIPPED [ 95%]
starter/test_merge.py::test_9_pivot_table_aggregates_the_duplicates SKIPPED [100%]

============================= 22 skipped in 0.02s ==============================

test-run.txt

Day 124 — Joins That Keep Their Shape

1. The tools and the versions this lab was written against
python   3.14.0
pandas   3.0.5
pyarrow  25.0.1
numpy    2.5.2
pytest   9.1.1

  ok: installed pandas matches requirements.txt exactly

2. Reference suite -- examples/ must pass in full
......................                                                   [100%]
22 passed in 0.05s
  ok: examples/ exits 0
  ok: examples/ reports 22 passed, 0 failed

3. Exercise suite -- starter/ is all-skip on an untouched checkout
ssssssssssssssssssssss                                                   [100%]
22 skipped in 0.02s
  ok: starter/ (untouched) exits 0
  ok: starter/ (untouched) reports 22 skipped, 0 failed

4. Never run 'pytest examples starter' in one invocation -- same
   module name in both directories means the second collected can
   shadow the first. Documented and checked separately, above.

5. Prove the suite can genuinely FAIL: solve every exercise in a
   scratch copy, confirm green, break one assertion on purpose,
   confirm a non-zero exit and a printed FAIL, then restore.
  ok: scratch copy of the solved suite exits 0
  ok: scratch copy reports 22 passed
  ok: broken scratch copy exits non-zero
  ok: broken scratch copy prints a FAIL/failed line
  ok: restored scratch copy exits 0 again
  ok: restored scratch copy reports 22 passed again

6. Nothing in examples/ or starter/ opens a network connection
  ok: no URLs inside examples/ or starter/

7. Cleanliness -- nothing left behind by THIS run
  ok: no __pycache__ or .pytest_cache left behind

-------------------------------------------------------------
13 checks, 0 failure(s)

Source files

examples/conftest.py (1150 bytes)
"""Shared fixtures. pytest finds this file by itself -- nothing imports it.

Each fixture returns a FRESH copy of its table, so one test's mutation
(there should not be any, but fixtures should not have to trust that) can
never leak into the next test.
"""

import pytest

from data import (
    build_dup_index_col,
    build_int_keyed,
    build_left_dup,
    build_left_keys,
    build_price_left,
    build_price_right,
    build_right_dup,
    build_right_keys,
    build_str_keyed,
    build_wide,
)


@pytest.fixture
def left_dup():
    return build_left_dup()


@pytest.fixture
def right_dup():
    return build_right_dup()


@pytest.fixture
def left_keys():
    return build_left_keys()


@pytest.fixture
def right_keys():
    return build_right_keys()


@pytest.fixture
def int_keyed():
    return build_int_keyed()


@pytest.fixture
def str_keyed():
    return build_str_keyed()


@pytest.fixture
def price_left():
    return build_price_left()


@pytest.fixture
def price_right():
    return build_price_right()


@pytest.fixture
def wide():
    return build_wide()


@pytest.fixture
def dup_index_col():
    return build_dup_index_col()
examples/data.py (5582 bytes)
"""The tables every exercise in this lab is built from.

Nothing here is randomised or loaded from a file -- every table is a small
literal so a reader can check every asserted number by eye against the
source.

`left_dup` / `right_dup` -- exercises 1 and 2. Each has a duplicated key,
so a merge between them is many-to-many by construction.

`left_keys` / `right_keys` -- exercises 3 and 5. Each key is unique on
both sides, so the four join types differ only in which rows survive, not
in how many copies of a row a duplicated key would produce.

`int_keyed` / `str_keyed` -- exercise 4. Same digits, different dtypes.

`price_left` / `price_right` -- exercise 6. Both have a `price` column,
so a plain merge collides on the name.

`wide` -- exercise 8's melt/pivot round trip.

`dup_index_col` -- exercise 9's pivot-versus-pivot_table contrast.
"""

from __future__ import annotations

import pandas as pd

# --------------------------------------------------------------------------
# `left_dup` / `right_dup` -- exercises 1 and 2. Duplicated keys on both
# sides, so an inner merge is a per-key Cartesian product.
#
# Key 'A': 3 rows on the left, 2 on the right -> 3*2 = 6 matched rows.
# Key 'B': 2 rows on the left, 4 on the right -> 2*4 = 8 matched rows.
# Key 'C': 1 row on the left, 0 on the right  -> 0 matched rows.
# Key 'D': 0 rows on the left, 1 on the right -> 0 matched rows.
# Total inner rows: 6 + 8 = 14.
# --------------------------------------------------------------------------


def build_left_dup() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "cust_id": ["A", "A", "A", "B", "B", "C"],
            "order_id": [1, 2, 3, 4, 5, 6],
            "amount": [10.0, 20.0, 30.0, 40.0, 50.0, 60.0],
        }
    )


def build_right_dup() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "cust_id": ["A", "A", "B", "B", "B", "B", "D"],
            "contact_id": [101, 102, 103, 104, 105, 106, 107],
            "channel": ["email", "phone", "email", "phone", "sms", "email", "phone"],
        }
    )


# --------------------------------------------------------------------------
# `left_keys` / `right_keys` -- exercises 3 and 5. Every key is unique on
# both sides: left has A, B, C, D; right has B, C, D, E. Overlap is
# exactly {B, C, D}.
# --------------------------------------------------------------------------


def build_left_keys() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "cust_id": ["A", "B", "C", "D"],
            "region": ["North", "South", "East", "West"],
        }
    )


def build_right_keys() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "cust_id": ["B", "C", "D", "E"],
            "plan": ["basic", "pro", "pro", "basic"],
        }
    )


# --------------------------------------------------------------------------
# `int_keyed` / `str_keyed` -- exercise 4. Same three ids, one column
# int64, the other the same digits stored as a pandas Categorical -- the
# read-one-way, read-another-way trap from Day 121 arriving as a join.
#
# A plain str/object key against an int64 key is caught by pandas 3.0.5's
# own dtype check and RAISES a ValueError -- verified separately below and
# documented as an honest correction to the classic "silent zero rows"
# story. A CATEGORICAL key of the same digits (exactly what you get from
# reading a column with dtype="category", a common CSV-loading choice)
# slips past that check and reproduces the classic silent failure: an
# inner join that matches nothing and raises nothing.
# --------------------------------------------------------------------------


def build_int_keyed() -> pd.DataFrame:
    return pd.DataFrame({"id": pd.array([1001, 1002, 1003], dtype="int64"), "name": ["Ann", "Bo", "Cy"]})


def build_str_keyed() -> pd.DataFrame:
    return pd.DataFrame({"id": pd.Categorical(["1001", "1002", "1003"]), "score": [88, 91, 77]})


# --------------------------------------------------------------------------
# `price_left` / `price_right` -- exercise 6. Both carry a `price` column,
# which collides under a plain merge and needs `suffixes=`.
# --------------------------------------------------------------------------


def build_price_left() -> pd.DataFrame:
    return pd.DataFrame({"sku": ["X1", "X2", "X3"], "price": [9.99, 14.50, 3.25]})


def build_price_right() -> pd.DataFrame:
    return pd.DataFrame({"sku": ["X1", "X2", "X3"], "price": [10.99, 13.00, 3.75]})


# --------------------------------------------------------------------------
# `wide` -- exercise 8. Three measurement columns beside an id column, the
# shape aggregation and plotting want turned into long form and back.
# --------------------------------------------------------------------------


def build_wide() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "student_id": [1, 2, 3],
            "math": [88, 72, 95],
            "reading": [91, 85, 79],
            "science": [76, 90, 83],
        }
    )


# --------------------------------------------------------------------------
# `dup_index_col` -- exercise 9. Two rows share the same (student, subject)
# pair with different scores, so a plain `pivot` cannot place them both --
# `pivot_table` averages them instead.
# --------------------------------------------------------------------------


def build_dup_index_col() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "student": ["Ann", "Ann", "Bo", "Ann"],
            "subject": ["math", "reading", "math", "math"],
            "score": [80.0, 91.0, 70.0, 90.0],
        }
    )
examples/test_merge.py (13104 bytes)
"""The worked reference suite for Day 124 -- "Joins That Keep Their Shape".

Nine exercises, each proving one real pandas 3.0.5 behaviour by running
code and reading real values -- never by reading source. Run it:

    pytest examples

Every table these tests use comes from `data.py`, imported through the
fixtures in `conftest.py`. Read `starter/00_brief.md` for the exercise-by-
exercise explanation; this file is the answer key.
"""

import numpy as np
import pandas as pd
import pytest

# --------------------------------------------------------------------------
# Exercise 1 -- the explosion. A many-to-many merge on duplicated keys
# produces exactly the product of the per-key group sizes, on each side,
# summed across keys. Not an error -- a Cartesian product within each key
# group, exactly as merge is defined to do.
# --------------------------------------------------------------------------


def test_1_many_to_many_merge_produces_the_per_key_product(left_dup, right_dup):
    assert left_dup.shape == (6, 3)
    assert right_dup.shape == (7, 3)

    merged = left_dup.merge(right_dup, on="cust_id", how="inner")

    # Compute the expected row count directly from the per-key group
    # sizes on each side -- the definition of what an inner merge does
    # with a duplicated key, not a number copied from a prior run.
    left_counts = left_dup["cust_id"].value_counts()
    right_counts = right_dup["cust_id"].value_counts()
    common_keys = set(left_counts.index) & set(right_counts.index)
    expected_rows = sum(left_counts[k] * right_counts[k] for k in common_keys)

    assert expected_rows == 14  # 3*2 (key A) + 2*4 (key B)
    assert merged.shape[0] == expected_rows
    assert merged.shape == (14, 5)  # cust_id + 2 left cols + 2 right cols

    # Per-key check: key A alone produces 3*2 = 6 rows.
    assert (merged["cust_id"] == "A").sum() == 6
    # Key B alone produces 2*4 = 8 rows.
    assert (merged["cust_id"] == "B").sum() == 8


# --------------------------------------------------------------------------
# Exercise 2 -- validate=. Stating a cardinality assumption and having
# pandas enforce it is the single best habit in this lesson.
# --------------------------------------------------------------------------


def test_2_validate_one_to_one_raises_on_duplicated_keys(left_dup, right_dup):
    with pytest.raises(pd.errors.MergeError):
        left_dup.merge(right_dup, on="cust_id", how="inner", validate="one_to_one")


def test_2_validate_one_to_one_passes_on_genuinely_unique_keys(left_keys, right_keys):
    # left_keys and right_keys each have a unique cust_id -- validate
    # should raise nothing at all, and the merge proceeds normally.
    result = left_keys.merge(right_keys, on="cust_id", how="inner", validate="one_to_one")
    assert result.shape == (3, 3)  # B, C, D


def test_2_validate_one_to_many_raises_when_the_many_side_is_actually_duplicated_on_both(left_dup, right_dup):
    # left_dup is NOT one-to-many against right_dup either: key A repeats
    # on BOTH sides (3 on the left, 2 on the right), so "one" (left) to
    # "many" (right) is violated on the left side too.
    with pytest.raises(pd.errors.MergeError):
        left_dup.merge(right_dup, on="cust_id", how="inner", validate="one_to_many")


# --------------------------------------------------------------------------
# Exercise 3 -- indicator=True. Adds a _merge column recording match
# provenance; the three counts must reconcile exactly with the inputs.
# --------------------------------------------------------------------------


def test_3_indicator_counts_reconcile_with_the_inputs(left_keys, right_keys):
    result = left_keys.merge(right_keys, on="cust_id", how="outer", indicator=True)

    counts = result["_merge"].value_counts()
    assert counts["left_only"] == 1  # A
    assert counts["right_only"] == 1  # E
    assert counts["both"] == 3  # B, C, D

    # Reconciliation: left_only + both accounts for every left row, since
    # every key on both sides here is unique (no duplication to explode).
    assert counts["left_only"] + counts["both"] == left_keys.shape[0] == 4
    assert counts["right_only"] + counts["both"] == right_keys.shape[0] == 4

    # And the three categories account for every output row exactly.
    assert counts["left_only"] + counts["right_only"] + counts["both"] == result.shape[0] == 5


# --------------------------------------------------------------------------
# Exercise 4 -- the silent dtype-mismatch join. An int64 key merged
# against a str key of the same digits matches nothing, and an inner join
# returns zero rows rather than raising.
# --------------------------------------------------------------------------


def test_4_dtype_mismatch_join_returns_zero_rows(int_keyed, str_keyed):
    assert int_keyed["id"].dtype == np.int64
    assert str(str_keyed["id"].dtype) == "category"

    result = int_keyed.merge(str_keyed, on="id", how="inner")

    assert result.shape[0] == 0  # no exception, no warning -- just nothing
    assert list(result.columns) == ["id", "name", "score"]


def test_4_casting_one_side_fixes_it(int_keyed, str_keyed):
    fixed = str_keyed.astype({"id": "int64"})
    result = int_keyed.merge(fixed, on="id", how="inner")

    assert result.shape[0] == 3
    assert result.loc[result["id"] == 1002, "score"].iloc[0] == 91


def test_4_plain_str_key_against_int64_raises_instead_of_matching_nothing(int_keyed):
    # An honest correction to the "always silent" story: pandas 3.0.5
    # checks a merge key's dtype compatibility BEFORE joining, and a
    # plain str (or legacy object) key against an int64 key raises a
    # clear ValueError rather than silently returning zero rows. The
    # categorical case above is the one that still slips through.
    plain_str_keyed = pd.DataFrame({"id": ["1001", "1002", "1003"], "score": [88, 91, 77]})
    assert str(plain_str_keyed["id"].dtype) == "str"

    with pytest.raises(ValueError, match="You are trying to merge on"):
        int_keyed.merge(plain_str_keyed, on="id", how="inner")


# --------------------------------------------------------------------------
# Exercise 5 -- the four join types on one pair of frames, so the
# differences are visible at a glance. left_keys: A, B, C, D. right_keys:
# B, C, D, E. Overlap: B, C, D.
# --------------------------------------------------------------------------


def test_5_inner_keeps_only_the_overlap(left_keys, right_keys):
    result = left_keys.merge(right_keys, on="cust_id", how="inner")
    assert result.shape[0] == 3
    assert set(result["cust_id"]) == {"B", "C", "D"}


def test_5_left_keeps_every_left_row(left_keys, right_keys):
    result = left_keys.merge(right_keys, on="cust_id", how="left")
    assert result.shape[0] == 4
    assert set(result["cust_id"]) == {"A", "B", "C", "D"}
    assert result.loc[result["cust_id"] == "A", "plan"].isna().all()


def test_5_right_keeps_every_right_row(left_keys, right_keys):
    result = left_keys.merge(right_keys, on="cust_id", how="right")
    assert result.shape[0] == 4
    assert set(result["cust_id"]) == {"B", "C", "D", "E"}
    assert result.loc[result["cust_id"] == "E", "region"].isna().all()


def test_5_outer_keeps_every_row_from_both(left_keys, right_keys):
    result = left_keys.merge(right_keys, on="cust_id", how="outer")
    assert result.shape[0] == 5
    assert set(result["cust_id"]) == {"A", "B", "C", "D", "E"}


def test_5_row_counts_in_one_table(left_keys, right_keys):
    counts = {
        how: left_keys.merge(right_keys, on="cust_id", how=how).shape[0]
        for how in ("inner", "left", "right", "outer")
    }
    assert counts == {"inner": 3, "left": 4, "right": 4, "outer": 5}


# --------------------------------------------------------------------------
# Exercise 6 -- suffixes. The default _x/_y on overlapping non-key
# columns is how price_x ends up in production; explicit suffixes fix it.
# --------------------------------------------------------------------------


def test_6_default_suffixes_are_x_and_y(price_left, price_right):
    result = price_left.merge(price_right, on="sku", how="inner")
    assert "price_x" in result.columns
    assert "price_y" in result.columns
    assert "price" not in result.columns
    assert result.loc[result["sku"] == "X1", "price_x"].iloc[0] == 9.99
    assert result.loc[result["sku"] == "X1", "price_y"].iloc[0] == 10.99


def test_6_explicit_suffixes_rename_as_asked(price_left, price_right):
    result = price_left.merge(price_right, on="sku", how="inner", suffixes=("_catalog", "_live"))
    assert "price_catalog" in result.columns
    assert "price_live" in result.columns
    assert "price_x" not in result.columns
    assert result.loc[result["sku"] == "X2", "price_catalog"].iloc[0] == 14.50
    assert result.loc[result["sku"] == "X2", "price_live"].iloc[0] == 13.00


def test_6_on_versus_left_on_right_on_give_the_same_result(price_left, price_right):
    renamed_right = price_right.rename(columns={"sku": "sku_code"})
    via_on = price_left.merge(price_right, on="sku", how="inner", suffixes=("_l", "_r"))
    via_left_right_on = price_left.merge(
        renamed_right, left_on="sku", right_on="sku_code", how="inner", suffixes=("_l", "_r")
    )
    assert via_on.shape[0] == via_left_right_on.shape[0] == 3
    assert list(via_on["price_l"]) == list(via_left_right_on["price_l"])


def test_6_join_on_index_matches_merge_on_column(price_left, price_right):
    left_indexed = price_left.set_index("sku")
    right_indexed = price_right.set_index("sku")

    via_join = left_indexed.join(right_indexed, how="inner", lsuffix="_l", rsuffix="_r")
    via_merge = price_left.merge(price_right, on="sku", how="inner", suffixes=("_l", "_r")).set_index("sku")

    assert via_join.sort_index().equals(via_merge.sort_index())


# --------------------------------------------------------------------------
# Exercise 7 -- concat. axis=0 stacks rows; axis=1 stacks columns.
# Alignment fills unmatched columns or labels with NaN rather than
# erroring.
# --------------------------------------------------------------------------


def test_7_concat_axis_0_with_mismatched_columns_fills_nan():
    frame_a = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
    frame_b = pd.DataFrame({"b": [5, 6], "c": [7, 8]})

    result = pd.concat([frame_a, frame_b], axis=0, ignore_index=True)

    assert result.shape == (4, 3)
    assert list(result.columns) == ["a", "b", "c"]

    # frame_a's rows (0, 1) have no 'c' -- must be NaN there.
    assert result.loc[0:1, "c"].isna().all()
    # frame_b's rows (2, 3) have no 'a' -- must be NaN there.
    assert result.loc[2:3, "a"].isna().all()
    # 'b' is present in both, so it is never NaN here.
    assert not result["b"].isna().any()
    assert list(result["b"]) == [3, 4, 5, 6]


def test_7_concat_axis_1_with_mismatched_index_fills_nan():
    frame_a = pd.DataFrame({"x": [10, 20]}, index=["r1", "r2"])
    frame_b = pd.DataFrame({"y": [30, 40]}, index=["r2", "r3"])

    result = pd.concat([frame_a, frame_b], axis=1)

    assert result.shape == (3, 2)
    assert result.loc["r1", "y"] != result.loc["r1", "y"]  # NaN != NaN
    assert pd.isna(result.loc["r1", "y"])
    assert pd.isna(result.loc["r3", "x"])
    assert result.loc["r2", "x"] == 20
    assert result.loc["r2", "y"] == 30


# --------------------------------------------------------------------------
# Exercise 8 -- melt to go long, pivot to come back. The round trip must
# recover the original.
# --------------------------------------------------------------------------


def test_8_melt_then_pivot_round_trips_to_the_original(wide):
    long = wide.melt(id_vars="student_id", var_name="subject", value_name="score")

    assert long.shape == (9, 3)  # 3 students x 3 subjects
    assert set(long["subject"]) == {"math", "reading", "science"}

    recovered = (
        long.pivot(index="student_id", columns="subject", values="score")
        .reset_index()
        .rename_axis(columns=None)
    )
    # pivot sorts columns alphabetically -- put the original column order back.
    recovered = recovered[["student_id", "math", "reading", "science"]]

    pd.testing.assert_frame_equal(recovered, wide, check_dtype=True)


# --------------------------------------------------------------------------
# Exercise 9 -- pivot raises on duplicate index/column pairs; pivot_table
# aggregates them instead.
# --------------------------------------------------------------------------


def test_9_pivot_raises_on_duplicate_index_column_pairs(dup_index_col):
    # ("Ann", "math") appears twice -- pivot has nowhere to put both.
    with pytest.raises(ValueError):
        dup_index_col.pivot(index="student", columns="subject", values="score")


def test_9_pivot_table_aggregates_the_duplicates(dup_index_col):
    result = dup_index_col.pivot_table(index="student", columns="subject", values="score", aggfunc="mean")

    # Ann's two math scores, 80.0 and 90.0, average to 85.0.
    assert result.loc["Ann", "math"] == 85.0
    assert result.loc["Ann", "reading"] == 91.0
    assert result.loc["Bo", "math"] == 70.0
metadata.yml (3301 bytes)
lesson_id: D124
day: 124
kind: guided-build
languages: [python, bash]
setup_commands:
  - cd labs/sections/math-statistics-and-data/day-124-merging-and-reshaping
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/python3 -c "import pandas; print(pandas.__version__)"
run_commands:
  - .venv/bin/pytest examples
  - .venv/bin/pytest starter
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: 40
last_executed: '2026-08-19'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, pandas 3.0.5, pyarrow 25.0.1, numpy 2.5.2, pytest 9.1.1, bash 3.2.57 -- bash tests/run_tests.sh -> 13 checks, 0 failure(s), exit 0. pytest examples -> 22 passed. pytest starter -> 22 skipped (untouched checkout). Section 5 of the harness solves every exercise in a scratch copy (22 passed), deliberately breaks exercise 1''s exact row-count assertion (14 -> 999), confirms the run exits non-zero with a printed FAIL, restores the file, and confirms 22 passed again -- so the suite is demonstrated to be capable of failing rather than merely claimed to be. Separately, the coordinator broke the same assertion directly in examples/test_merge.py (not a scratch copy) and re-ran the full harness: 1 failed, 21 passed from pytest, and 6 of the harness''s own 13 checks failed with overall exit 1; restoring the line returned the harness to 13 checks, 0 failure(s), exit 0. Everything was run through a real lab-local .venv created by the documented setup commands. Three honesty notes from this run. FIRST: pandas 3.0.5 now infers a plain Python-string column as its own `str` extension dtype by default (not the historical `object`), and -- more importantly -- pandas 3.0.5''s merge() explicitly detects an int64-vs-string/object key mismatch and RAISES `ValueError: You are trying to merge on int64 and str columns...` rather than silently returning zero rows; this is a real, verified correction to the classic "silent empty join" story the day brief described, and exercise 4 covers both: a genuinely silent zero-row case (int64 vs a pandas Categorical of the same digits, which is NOT caught by that dtype check) and the now-loud plain-string case, side by side. SECOND: running `pytest examples starter` in one invocation was tested directly in this lab and does NOT silently let one directory''s tests shadow the other -- it aborts collection outright with `import file mismatch` (both directories define a module named test_merge.py) and pytest exits non-zero before running anything, which is an even stronger reason never to do it, stated accurately rather than assuming the softer "silent shadow" failure mode without checking. THIRD: matplotlib, scipy and polars are not installed in this environment; polars'' join and its explicit validate argument are described in the lesson''s Tools section from public documentation only, and no output attributed to polars, scipy or matplotlib is reproduced anywhere in this lab or its lesson.'
requirements/README.md (1989 bytes)
# What is installed, why, and what it costs

Four packages, all free and open source, installed into a lab-local
virtual environment that `rm -rf .venv` completely undoes.

| Package | Version pinned | Licence | What this lab uses it for |
| --- | --- | --- | --- |
| `pandas` | 3.0.5 | BSD 3-Clause | Every `merge`, `concat`, `melt`, `pivot` and `pivot_table` call in this lab. |
| `pyarrow` | 25.0.1 | Apache 2.0 | pandas 3.0's default backend for several nullable and string dtypes; installed for parity with Days 120-123 even though this lab's tables are plain numeric, object and categorical columns throughout. |
| `numpy` | 2.5.2 | BSD 3-Clause | `np.nan`-related comparisons in the `concat` alignment exercise. |
| `pytest` | 9.1.1 | MIT | The test harness every exercise is written against. |

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. Every script
and test after that runs completely offline.

## What is deliberately *not* installed

**matplotlib**, **scipy** and **polars** are not installed in this
environment. The lesson's Tools section describes polars' `join` and its
explicit `validate` argument from its public documentation as a design
contrast to pandas — no output from polars, scipy or matplotlib is
reproduced anywhere in this lab or its lesson; every place they are
mentioned says so plainly. SQL joins are demonstrated with the standard
library's `sqlite3`, which needs nothing beyond Python itself.

## If you cannot install anything at all

pandas is not in the Python standard library, and there is no reduced
path through this lab without it. If pandas genuinely cannot be
installed, read the lesson's captured output and `expected-output/`
directory instead; every number there came from a real run and is not
invented.
requirements/requirements.txt (57 bytes)
pandas==3.0.5
pyarrow==25.0.1
numpy==2.5.2
pytest==9.1.1
starter/00_brief.md (6481 bytes)
# Day 124 lab — the brief

Nine exercises, in order. Work top to bottom in `test_merge.py`. Every
table comes from a fixture defined in `conftest.py` (`left_dup`,
`right_dup`, `left_keys`, `right_keys`, `int_keyed`, `str_keyed`,
`price_left`, `price_right`, `wide`, `dup_index_col`) — read `data.py`
once to see exactly what each one contains before you start.

Check yourself at any point:

```bash
.venv/bin/pytest starter -v
```

On an untouched checkout that prints `22 skipped`. A **skip** means "not
attempted". Replace a `pytest.skip(...)` line with real assertions and
delete it — when every skip is gone and the suite is green, you are
finished:

```bash
.venv/bin/pytest starter -q
echo $?
```

Assert exact values everywhere in this lab. Unlike Day 123, nothing here
is timing-dependent.

---

## Exercise 1 — the explosion (`left_dup`, `right_dup`)

`left_dup` has key `A` three times and key `B` twice. `right_dup` has key
`A` twice and key `B` four times. An **inner merge on `cust_id`** is a
per-key Cartesian product: key `A` alone produces `3 * 2 = 6` rows, key
`B` alone produces `2 * 4 = 8` rows. Compute the expected total from each
side's `.value_counts()` — do not hardcode `14`, derive it — and assert
the merged shape matches. Then assert the per-key row counts (6 for `A`,
8 for `B`) directly.

## Exercise 2 — `validate=` (`left_dup`, `right_dup`, `left_keys`, `right_keys`)

`validate='one_to_one'` on `left_dup`/`right_dup` must raise
`pandas.errors.MergeError` — key `A` and key `B` are each duplicated on
both sides, violating "one-to-one" from either direction. The same
`validate='one_to_one'` on `left_keys`/`right_keys` (every key unique on
both sides) must raise nothing and return 3 rows. A third test:
`validate='one_to_many'` on `left_dup`/`right_dup` must **also** raise —
key `A` repeats on the LEFT side (3 times), which violates "one" even
though you asked for "many" on the right.

## Exercise 3 — `indicator=True` (`left_keys`, `right_keys`)

`left_keys` has keys A, B, C, D. `right_keys` has keys B, C, D, E. Outer
merge with `indicator=True`, then read the `_merge` column's
`.value_counts()`: `left_only` should be 1 (key A), `right_only` should be
1 (key E), `both` should be 3 (B, C, D). Assert the reconciliation
explicitly: `left_only + both` equals `left_keys`' row count (4);
`right_only + both` equals `right_keys`' row count (4); all three sum to
the merged row count (5).

## Exercise 4 — the silent dtype-mismatch join (`int_keyed`, `str_keyed`)

`int_keyed['id']` is `int64`. `str_keyed['id']` is a pandas
**Categorical** of the same digits as strings. Inner-merging them on `id`
returns **0 rows** — no exception, no warning. Confirm both dtypes, run
the merge, assert `result.shape[0] == 0`. Then cast `str_keyed['id']` to
`int64` with `.astype({'id': 'int64'})`, merge again, and assert 3 rows
come back with id `1002`'s score equal to `91`.

**A third test, and it matters:** build a fresh frame with a *plain* `str`
`id` column (not categorical) holding the same digits, and merge it
against `int_keyed`. In pandas 3.0.5 this does **not** silently return
zero rows — it **raises `ValueError`**, telling you plainly that you are
merging incompatible key dtypes. Assert that raise. This is the honest
correction: the categorical case above is the one that still slips
through silently; the plain-string case is now caught for you.

## Exercise 5 — the four join types (`left_keys`, `right_keys`)

Same pair of frames as exercise 3. Write four tests asserting the row
count and surviving keys for `how='inner'` (3 rows, B/C/D),
`how='left'` (4 rows, A's `plan` all `NaN`), `how='right'` (4 rows, E's
`region` all `NaN`), and `how='outer'` (5 rows, A through E). A fifth test
builds a `{how: row_count}` dict for all four in one place and asserts it
equals `{'inner': 3, 'left': 4, 'right': 4, 'outer': 5}`.

## Exercise 6 — suffixes, `on`/`left_on`/`right_on`, and `join()` (`price_left`, `price_right`)

Both frames have a `price` column. A plain `merge(on='sku')` produces
`price_x` and `price_y` — assert both exist, `price` does not, and X1's
values are `9.99`/`10.99`. Repeat with `suffixes=('_catalog', '_live')`
and assert those names instead. Third test: rename `price_right`'s `sku`
to `sku_code`, merge once with `on='sku'` (after renaming back, or just
merge `price_left` against the *unrenamed* `price_right` for the `on=`
case and against the *renamed* copy for `left_on`/`right_on`) and confirm
both give the same row count and values. Fourth test: `set_index('sku')`
on both and use `.join(how='inner', lsuffix='_l', rsuffix='_r')`; assert
it matches the equivalent `.merge(on='sku')` result once both are sorted
by index.

## Exercise 7 — `concat` alignment

Build two small frames by hand with partially overlapping columns.
`pd.concat([frame_a, frame_b], axis=0, ignore_index=True)`: assert the
combined shape, and assert **exactly which cells** are `NaN` — the column
missing from `frame_a` is `NaN` on `frame_a`'s rows, and vice versa; the
shared column is never `NaN`. Then build two frames with partially
overlapping **index labels** and `pd.concat([...], axis=1)`: assert the
combined shape and exactly which cells are `NaN` this time.

## Exercise 8 — melt → pivot round trip (`wide`)

`wide` has `student_id`, `math`, `reading`, `science`. `.melt(id_vars=
'student_id', var_name='subject', value_name='score')` gives 9 rows (3
students × 3 subjects). `.pivot(index='student_id', columns='subject',
values='score')` should bring back the original — but you will need to
`.reset_index()`, drop the columns' index name, and reorder the columns
back to `wide`'s original order before `pd.testing.assert_frame_equal`
passes, since `pivot` sorts columns alphabetically.

## Exercise 9 — `pivot` versus `pivot_table` (`dup_index_col`)

`dup_index_col` has two rows for `('Ann', 'math')` — scores 80 and 90.
`.pivot(index='student', columns='subject', values='score')` cannot place
both, and must raise `ValueError`. `.pivot_table(index='student',
columns='subject', values='score', aggfunc='mean')` aggregates them
instead: assert Ann/math is `85.0` (the mean of 80 and 90), Ann/reading is
`91.0`, and Bo/math is `70.0`.

---

Prove your suite is not vacuous once you are green: re-break one assertion
on purpose (flip a comparison, change an expected number), confirm the run
exits non-zero with a printed `FAIL`, then restore it and confirm green
again.
starter/conftest.py (1150 bytes)
"""Shared fixtures. pytest finds this file by itself -- nothing imports it.

Each fixture returns a FRESH copy of its table, so one test's mutation
(there should not be any, but fixtures should not have to trust that) can
never leak into the next test.
"""

import pytest

from data import (
    build_dup_index_col,
    build_int_keyed,
    build_left_dup,
    build_left_keys,
    build_price_left,
    build_price_right,
    build_right_dup,
    build_right_keys,
    build_str_keyed,
    build_wide,
)


@pytest.fixture
def left_dup():
    return build_left_dup()


@pytest.fixture
def right_dup():
    return build_right_dup()


@pytest.fixture
def left_keys():
    return build_left_keys()


@pytest.fixture
def right_keys():
    return build_right_keys()


@pytest.fixture
def int_keyed():
    return build_int_keyed()


@pytest.fixture
def str_keyed():
    return build_str_keyed()


@pytest.fixture
def price_left():
    return build_price_left()


@pytest.fixture
def price_right():
    return build_price_right()


@pytest.fixture
def wide():
    return build_wide()


@pytest.fixture
def dup_index_col():
    return build_dup_index_col()
starter/data.py (5582 bytes)
"""The tables every exercise in this lab is built from.

Nothing here is randomised or loaded from a file -- every table is a small
literal so a reader can check every asserted number by eye against the
source.

`left_dup` / `right_dup` -- exercises 1 and 2. Each has a duplicated key,
so a merge between them is many-to-many by construction.

`left_keys` / `right_keys` -- exercises 3 and 5. Each key is unique on
both sides, so the four join types differ only in which rows survive, not
in how many copies of a row a duplicated key would produce.

`int_keyed` / `str_keyed` -- exercise 4. Same digits, different dtypes.

`price_left` / `price_right` -- exercise 6. Both have a `price` column,
so a plain merge collides on the name.

`wide` -- exercise 8's melt/pivot round trip.

`dup_index_col` -- exercise 9's pivot-versus-pivot_table contrast.
"""

from __future__ import annotations

import pandas as pd

# --------------------------------------------------------------------------
# `left_dup` / `right_dup` -- exercises 1 and 2. Duplicated keys on both
# sides, so an inner merge is a per-key Cartesian product.
#
# Key 'A': 3 rows on the left, 2 on the right -> 3*2 = 6 matched rows.
# Key 'B': 2 rows on the left, 4 on the right -> 2*4 = 8 matched rows.
# Key 'C': 1 row on the left, 0 on the right  -> 0 matched rows.
# Key 'D': 0 rows on the left, 1 on the right -> 0 matched rows.
# Total inner rows: 6 + 8 = 14.
# --------------------------------------------------------------------------


def build_left_dup() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "cust_id": ["A", "A", "A", "B", "B", "C"],
            "order_id": [1, 2, 3, 4, 5, 6],
            "amount": [10.0, 20.0, 30.0, 40.0, 50.0, 60.0],
        }
    )


def build_right_dup() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "cust_id": ["A", "A", "B", "B", "B", "B", "D"],
            "contact_id": [101, 102, 103, 104, 105, 106, 107],
            "channel": ["email", "phone", "email", "phone", "sms", "email", "phone"],
        }
    )


# --------------------------------------------------------------------------
# `left_keys` / `right_keys` -- exercises 3 and 5. Every key is unique on
# both sides: left has A, B, C, D; right has B, C, D, E. Overlap is
# exactly {B, C, D}.
# --------------------------------------------------------------------------


def build_left_keys() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "cust_id": ["A", "B", "C", "D"],
            "region": ["North", "South", "East", "West"],
        }
    )


def build_right_keys() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "cust_id": ["B", "C", "D", "E"],
            "plan": ["basic", "pro", "pro", "basic"],
        }
    )


# --------------------------------------------------------------------------
# `int_keyed` / `str_keyed` -- exercise 4. Same three ids, one column
# int64, the other the same digits stored as a pandas Categorical -- the
# read-one-way, read-another-way trap from Day 121 arriving as a join.
#
# A plain str/object key against an int64 key is caught by pandas 3.0.5's
# own dtype check and RAISES a ValueError -- verified separately below and
# documented as an honest correction to the classic "silent zero rows"
# story. A CATEGORICAL key of the same digits (exactly what you get from
# reading a column with dtype="category", a common CSV-loading choice)
# slips past that check and reproduces the classic silent failure: an
# inner join that matches nothing and raises nothing.
# --------------------------------------------------------------------------


def build_int_keyed() -> pd.DataFrame:
    return pd.DataFrame({"id": pd.array([1001, 1002, 1003], dtype="int64"), "name": ["Ann", "Bo", "Cy"]})


def build_str_keyed() -> pd.DataFrame:
    return pd.DataFrame({"id": pd.Categorical(["1001", "1002", "1003"]), "score": [88, 91, 77]})


# --------------------------------------------------------------------------
# `price_left` / `price_right` -- exercise 6. Both carry a `price` column,
# which collides under a plain merge and needs `suffixes=`.
# --------------------------------------------------------------------------


def build_price_left() -> pd.DataFrame:
    return pd.DataFrame({"sku": ["X1", "X2", "X3"], "price": [9.99, 14.50, 3.25]})


def build_price_right() -> pd.DataFrame:
    return pd.DataFrame({"sku": ["X1", "X2", "X3"], "price": [10.99, 13.00, 3.75]})


# --------------------------------------------------------------------------
# `wide` -- exercise 8. Three measurement columns beside an id column, the
# shape aggregation and plotting want turned into long form and back.
# --------------------------------------------------------------------------


def build_wide() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "student_id": [1, 2, 3],
            "math": [88, 72, 95],
            "reading": [91, 85, 79],
            "science": [76, 90, 83],
        }
    )


# --------------------------------------------------------------------------
# `dup_index_col` -- exercise 9. Two rows share the same (student, subject)
# pair with different scores, so a plain `pivot` cannot place them both --
# `pivot_table` averages them instead.
# --------------------------------------------------------------------------


def build_dup_index_col() -> pd.DataFrame:
    return pd.DataFrame(
        {
            "student": ["Ann", "Ann", "Bo", "Ann"],
            "subject": ["math", "reading", "math", "math"],
            "score": [80.0, 91.0, 70.0, 90.0],
        }
    )
starter/test_merge.py (9589 bytes)
"""YOUR test suite for Day 124 -- "Joins That Keep Their Shape". Nine exercises.

Run it from the lab directory, not from here:

    pytest starter -v

Every exercise below ends in a `pytest.skip(...)` line. pytest reports a
skip as `s` in the dot line and moves on, so an unfinished suite still
exits 0. Replace each skip with real assertions -- deleting the skip line
is part of the exercise. `starter/00_brief.md` explains each exercise in
full; the fixtures you need (`left_dup`, `right_dup`, `left_keys`,
`right_keys`, `int_keyed`, `str_keyed`, `price_left`, `price_right`,
`wide`, `dup_index_col`) come from `conftest.py` and are described there
too.

Assert exact values everywhere in this lab -- there is no timing
assertion here the way Day 123 had one.
"""

import numpy as np
import pandas as pd
import pytest

# --------------------------------------------------------------------------
# EXERCISE 1 -- the explosion. See starter/00_brief.md exercise 1.
#
# Check with:   pytest starter -v -k test_1
# --------------------------------------------------------------------------


def test_1_many_to_many_merge_produces_the_per_key_product(left_dup, right_dup):
    pytest.skip(
        "exercise 1: merge left_dup and right_dup inner on cust_id; compute the "
        "expected row count from each side's per-key value_counts product and "
        "assert the merged shape matches it exactly (14 rows); also assert key "
        "A alone produces 6 rows and key B alone produces 8"
    )


# --------------------------------------------------------------------------
# EXERCISE 2 -- validate=. See starter/00_brief.md exercise 2.
#
# Check with:   pytest starter -v -k test_2
# --------------------------------------------------------------------------


def test_2_validate_one_to_one_raises_on_duplicated_keys(left_dup, right_dup):
    pytest.skip(
        "exercise 2a: assert merging left_dup and right_dup with "
        "validate='one_to_one' raises pandas.errors.MergeError"
    )


def test_2_validate_one_to_one_passes_on_genuinely_unique_keys(left_keys, right_keys):
    pytest.skip(
        "exercise 2b: assert merging left_keys and right_keys with "
        "validate='one_to_one' raises nothing and returns 3 rows"
    )


def test_2_validate_one_to_many_raises_when_the_many_side_is_actually_duplicated_on_both(left_dup, right_dup):
    pytest.skip(
        "exercise 2c: assert validate='one_to_many' on left_dup/right_dup also "
        "raises MergeError, because key A repeats on the LEFT side too"
    )


# --------------------------------------------------------------------------
# EXERCISE 3 -- indicator=True. See starter/00_brief.md exercise 3.
#
# Check with:   pytest starter -v -k test_3
# --------------------------------------------------------------------------


def test_3_indicator_counts_reconcile_with_the_inputs(left_keys, right_keys):
    pytest.skip(
        "exercise 3: outer merge left_keys/right_keys with indicator=True; assert "
        "left_only=1, right_only=1, both=3, and that left_only+both == 4 "
        "(left_keys row count), right_only+both == 4 (right_keys row count), and "
        "all three sum to the merged row count (5)"
    )


# --------------------------------------------------------------------------
# EXERCISE 4 -- the silent dtype-mismatch join. See starter/00_brief.md
# exercise 4.
#
# Check with:   pytest starter -v -k test_4
# --------------------------------------------------------------------------


def test_4_dtype_mismatch_join_returns_zero_rows(int_keyed, str_keyed):
    pytest.skip(
        "exercise 4a: confirm int_keyed['id'] is int64 and str_keyed['id'] is "
        "category; inner-merge them on 'id' and assert the result has 0 rows "
        "and the correct columns, with no exception raised"
    )


def test_4_casting_one_side_fixes_it(int_keyed, str_keyed):
    pytest.skip(
        "exercise 4b: cast str_keyed['id'] to int64 with .astype, merge again, "
        "and assert 3 rows with id 1002's score equal to 91"
    )


def test_4_plain_str_key_against_int64_raises_instead_of_matching_nothing(int_keyed):
    pytest.skip(
        "exercise 4c: build a fresh DataFrame with a plain str 'id' column of "
        "the same digits ('1001','1002','1003') and a 'score' column, and "
        "assert merging it against int_keyed on 'id' raises ValueError -- this "
        "is a real correction to the classic silent-zero-rows story"
    )


# --------------------------------------------------------------------------
# EXERCISE 5 -- the four join types on one pair of frames. See
# starter/00_brief.md exercise 5.
#
# Check with:   pytest starter -v -k test_5
# --------------------------------------------------------------------------


def test_5_inner_keeps_only_the_overlap(left_keys, right_keys):
    pytest.skip("exercise 5a: how='inner' on left_keys/right_keys gives 3 rows, keys B, C, D")


def test_5_left_keeps_every_left_row(left_keys, right_keys):
    pytest.skip(
        "exercise 5b: how='left' gives 4 rows, keys A-D, with A's 'plan' all NaN"
    )


def test_5_right_keeps_every_right_row(left_keys, right_keys):
    pytest.skip(
        "exercise 5c: how='right' gives 4 rows, keys B-E, with E's 'region' all NaN"
    )


def test_5_outer_keeps_every_row_from_both(left_keys, right_keys):
    pytest.skip("exercise 5d: how='outer' gives 5 rows, keys A-E")


def test_5_row_counts_in_one_table(left_keys, right_keys):
    pytest.skip(
        "exercise 5e: build a dict of {how: row_count} for inner/left/right/outer "
        "and assert it equals {'inner': 3, 'left': 4, 'right': 4, 'outer': 5}"
    )


# --------------------------------------------------------------------------
# EXERCISE 6 -- suffixes, on/left_on/right_on, and join(). See
# starter/00_brief.md exercise 6.
#
# Check with:   pytest starter -v -k test_6
# --------------------------------------------------------------------------


def test_6_default_suffixes_are_x_and_y(price_left, price_right):
    pytest.skip(
        "exercise 6a: merge price_left/price_right on 'sku'; assert 'price_x' "
        "and 'price_y' exist and 'price' does not, with X1's values 9.99/10.99"
    )


def test_6_explicit_suffixes_rename_as_asked(price_left, price_right):
    pytest.skip(
        "exercise 6b: repeat with suffixes=('_catalog', '_live'); assert those "
        "column names exist instead and X2's values are 14.50/13.00"
    )


def test_6_on_versus_left_on_right_on_give_the_same_result(price_left, price_right):
    pytest.skip(
        "exercise 6c: rename price_right's 'sku' to 'sku_code', merge once with "
        "on='sku' and once with left_on='sku'/right_on='sku_code', and assert "
        "both give the same row count and the same price_l values"
    )


def test_6_join_on_index_matches_merge_on_column(price_left, price_right):
    pytest.skip(
        "exercise 6d: set_index('sku') on both frames and use .join(how='inner', "
        "lsuffix='_l', rsuffix='_r'); assert it equals the equivalent .merge(on='sku') "
        "result, once both are sorted by index"
    )


# --------------------------------------------------------------------------
# EXERCISE 7 -- concat alignment. See starter/00_brief.md exercise 7.
#
# Check with:   pytest starter -v -k test_7
# --------------------------------------------------------------------------


def test_7_concat_axis_0_with_mismatched_columns_fills_nan():
    pytest.skip(
        "exercise 7a: build frame_a={'a':[1,2],'b':[3,4]} and frame_b={'b':[5,6],'c':[7,8]}; "
        "pd.concat([frame_a, frame_b], axis=0, ignore_index=True); assert shape (4,3), "
        "frame_a's rows have NaN in 'c', frame_b's rows have NaN in 'a', and 'b' is "
        "never NaN"
    )


def test_7_concat_axis_1_with_mismatched_index_fills_nan():
    pytest.skip(
        "exercise 7b: build frame_a indexed ['r1','r2'] and frame_b indexed ['r2','r3']; "
        "pd.concat([frame_a, frame_b], axis=1); assert shape (3,2) and exactly which "
        "cells are NaN (r1's y, r3's x)"
    )


# --------------------------------------------------------------------------
# EXERCISE 8 -- melt then pivot round trip. See starter/00_brief.md
# exercise 8.
#
# Check with:   pytest starter -v -k test_8
# --------------------------------------------------------------------------


def test_8_melt_then_pivot_round_trips_to_the_original(wide):
    pytest.skip(
        "exercise 8: melt wide on id_vars='student_id' to long form (assert shape "
        "(9,3)); pivot it back with index='student_id', columns='subject', "
        "values='score'; reset_index, drop the columns' name, reorder columns to "
        "match wide, and assert pd.testing.assert_frame_equal(recovered, wide)"
    )


# --------------------------------------------------------------------------
# EXERCISE 9 -- pivot versus pivot_table. See starter/00_brief.md
# exercise 9.
#
# Check with:   pytest starter -v -k test_9
# --------------------------------------------------------------------------


def test_9_pivot_raises_on_duplicate_index_column_pairs(dup_index_col):
    pytest.skip(
        "exercise 9a: dup_index_col.pivot(index='student', columns='subject', "
        "values='score') must raise ValueError -- ('Ann','math') appears twice"
    )


def test_9_pivot_table_aggregates_the_duplicates(dup_index_col):
    pytest.skip(
        "exercise 9b: dup_index_col.pivot_table(index='student', columns='subject', "
        "values='score', aggfunc='mean') must return Ann/math = 85.0 (mean of 80 and "
        "90), Ann/reading = 91.0, Bo/math = 70.0"
    )
tests/run_tests.sh (10438 bytes)
#!/usr/bin/env bash
# Tests for the Day 124 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The harness proves the lesson's claims by running code and reading real
# values, never by reading source:
#
#   * a many-to-many merge on duplicated keys produces exactly the product
#     of the per-key group sizes on each side, summed across keys;
#   * validate='one_to_one' raises MergeError the moment that assumption
#     is violated, and passes silently when the assumption genuinely holds;
#   * indicator=True's left_only/right_only/both counts reconcile exactly
#     with the two input row counts;
#   * an int64 key merged against a categorical key of the same digits
#     matches nothing and returns zero rows, silently; casting fixes it;
#     and a plain str key against int64 now RAISES instead, in this
#     pandas version -- an honest correction, checked directly;
#   * inner/left/right/outer row counts on one pair of frames;
#   * default _x/_y suffixes on overlapping columns, and that explicit
#     suffixes rename them as asked; on= vs left_on/right_on agree; join()
#     on the index matches merge() on the column;
#   * concat's axis-0 and axis-1 alignment fills unmatched columns/labels
#     with NaN in exactly the cells that should be NaN, never elsewhere;
#   * melt -> pivot round-trips back to the original frame exactly;
#   * pivot raises on duplicate index/column pairs; pivot_table aggregates
#     them instead, reporting the correct mean;
#   * the reference suite (`examples/`) passes in full;
#   * the exercise suite (`starter/`) is all-skip on an untouched checkout,
#     and the harness proves it can genuinely FAIL by solving every
#     exercise in a scratch copy, breaking one assertion on purpose,
#     confirming a non-zero exit and a printed FAIL, then restoring it;
#   * nothing is left behind on disk.
#
# Everything after the one-time install runs offline. Nothing binds a port,
# nothing writes outside the lab, nothing needs a key. Deterministic,
# non-interactive, exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

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 `pytest examples -q` separately, and
# running either 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 pandas" >/dev/null 2>&1; then
  echo "FAIL: pandas 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 124 — Joins That Keep Their Shape"
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 ("pandas", "pyarrow", "numpy", "pytest"):
    try:
        print(f"{name:<8} {version(name)}")
    except Exception as exc:  # pragma: no cover
        print(f"{name:<8} NOT INSTALLED ({exc})")
PY
)"
echo "${versions}"
echo

pandas_version="$("${python_bin}" -c "import pandas; print(pandas.__version__)" 2>/dev/null || echo "")"
pinned_pandas="$(grep -m1 '^pandas==' "${lab_dir}/requirements/requirements.txt" | cut -d= -f3)"
check_eq "installed pandas matches requirements.txt exactly" "${pinned_pandas}" "${pandas_version}"
echo

# --------------------------------------------------------------------------
echo "2. Reference suite -- examples/ must pass in full"
# --------------------------------------------------------------------------

examples_output="$(cd "${lab_dir}" && "${pytest_bin}" examples -q 2>&1)"
examples_status=$?
echo "${examples_output}" | tail -5
check "examples/ exits 0" "$( [ ${examples_status} -eq 0 ] && echo yes || echo no )"

examples_passed_line="$(echo "${examples_output}" | grep -E '^[0-9]+ passed' || true)"
check "examples/ reports 22 passed, 0 failed" "$( echo "${examples_passed_line}" | grep -qE '^22 passed' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "3. Exercise suite -- starter/ is all-skip on an untouched checkout"
# --------------------------------------------------------------------------

starter_output="$(cd "${lab_dir}" && "${pytest_bin}" starter -q 2>&1)"
starter_status=$?
echo "${starter_output}" | tail -5
check "starter/ (untouched) exits 0" "$( [ ${starter_status} -eq 0 ] && echo yes || echo no )"
check "starter/ (untouched) reports 22 skipped, 0 failed" "$( echo "${starter_output}" | grep -qE '^22 skipped' && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "4. Never run 'pytest examples starter' in one invocation -- same"
echo "   module name in both directories means the second collected can"
echo "   shadow the first. Documented and checked separately, above."
# --------------------------------------------------------------------------
echo

# --------------------------------------------------------------------------
echo "5. Prove the suite can genuinely FAIL: solve every exercise in a"
echo "   scratch copy, confirm green, break one assertion on purpose,"
echo "   confirm a non-zero exit and a printed FAIL, then restore."
# --------------------------------------------------------------------------

scratch_dir="$(mktemp -d "${TMPDIR:-/tmp}/d124-scratch.XXXXXX")"
cleanup_scratch() { rm -rf "${scratch_dir}"; }
trap cleanup_scratch EXIT

cp "${lab_dir}/examples/test_merge.py" "${scratch_dir}/test_merge.py"
cp "${lab_dir}/examples/data.py" "${scratch_dir}/data.py"
cp "${lab_dir}/examples/conftest.py" "${scratch_dir}/conftest.py"

solved_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
solved_status=$?
check "scratch copy of the solved suite exits 0" "$( [ ${solved_status} -eq 0 ] && echo yes || echo no )"
check "scratch copy reports 22 passed" "$( echo "${solved_output}" | grep -qE '^22 passed' && echo yes || echo no )"

# Break test_1's exact row-count assertion on purpose: 14 -> 999.
sed -i.bak 's/assert expected_rows == 14/assert expected_rows == 999/' "${scratch_dir}/test_merge.py"

broken_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
broken_status=$?
check "broken scratch copy exits non-zero" "$( [ ${broken_status} -ne 0 ] && echo yes || echo no )"
check "broken scratch copy prints a FAIL/failed line" "$( echo "${broken_output}" | grep -qiE 'failed|assert' && echo yes || echo no )"

mv "${scratch_dir}/test_merge.py.bak" "${scratch_dir}/test_merge.py"
restored_output="$("${pytest_bin}" "${scratch_dir}" -q 2>&1)"
restored_status=$?
check "restored scratch copy exits 0 again" "$( [ ${restored_status} -eq 0 ] && echo yes || echo no )"
check "restored scratch copy reports 22 passed again" "$( echo "${restored_output}" | grep -qE '^22 passed' && echo yes || echo no )"

cleanup_scratch
trap - EXIT
echo

# --------------------------------------------------------------------------
echo "6. Nothing in examples/ or starter/ opens a network connection"
# --------------------------------------------------------------------------

url_hits="$(grep -rEl 'https?://|ftp://' "${lab_dir}/examples" "${lab_dir}/starter" 2>/dev/null || true)"
check "no URLs inside examples/ or starter/" "$( [ -z "${url_hits}" ] && echo yes || echo no )"
echo

# --------------------------------------------------------------------------
echo "7. Cleanliness -- nothing left behind by THIS run"
# --------------------------------------------------------------------------

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

stray="$(find "${lab_dir}" -name '.venv' -prune -o \( -type d -name '__pycache__' -print -o -type d -name '.pytest_cache' -print \) 2>/dev/null || true)"
check "no __pycache__ or .pytest_cache left behind" "$( [ -z "${stray}" ] && echo yes || echo no )"
echo

echo "-------------------------------------------------------------"
echo "${checks} checks, ${failures} failure(s)"
if [ "${failures}" -gt 0 ]; then
  exit 1
fi
exit 0

Troubleshooting

Troubleshooting

Grouped by the message you actually see.

ModuleNotFoundError: No module named 'pandas'

The lab's dependencies live in its own .venv, not on your system Python.

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Or point the test suite at a Python that already has pandas 3.0.5 installed: PYTHON=/path/to/python3 bash tests/run_tests.sh.

pytest examples starter aborts with import file mismatch

Do not pass both directories to one pytest invocation. starter/ and examples/ both define a module named test_merge.py, and pytest imports test modules by their dotted name. In this lab that collision does not quietly let one directory's tests shadow the other — it aborts collection outright with an import file mismatch error and pytest exits non-zero before running a single test. Either way, the fix is the same: run them as two separate commands, always:

.venv/bin/pytest examples
.venv/bin/pytest starter

Exercise 1's expected row count is not 14

Recompute it from left_dup['cust_id'].value_counts() and right_dup['cust_id'].value_counts() rather than hardcoding a number — key A contributes 3 * 2 = 6 and key B contributes 2 * 4 = 8. If your total does not match, confirm you merged with how='inner' and on 'cust_id', not some other column or join type.

MergeError message does not mention "one_to_one" or "one_to_many"

You are reading the right exception, just from a different keyword than you expected — validate= raises pandas.errors.MergeError (not ValueError) for every one of its four modes ('one_to_one', 'one_to_many', 'many_to_one', 'many_to_many'). Catch that specific class, not a bare Exception.

Exercise 4's dtype-mismatch merge does not return zero rows

Confirm str_keyed['id'] is genuinely a pandas.Categorical (data.py's build_str_keyed), not a plain string column — pandas 3.0.5's merge() explicitly detects an int64-vs-string/object key mismatch and raises ValueError rather than returning nothing; only the categorical case slips past that check silently. If you built your own plain-string frame for the third part of exercise 4, that one should raise — that is the point of that test.

Exercise 6's on= and left_on=/right_on= results do not match

Confirm you renamed only the column used for left_on=/right_on= ('sku' to 'sku_code' on the right side), and that you are comparing the same rows and the same suffix pair between the two calls — a different suffixes= argument between the two merges will produce differently-named columns even though the values agree.

Exercise 7's concat result has NaN in cells you did not expect

Check axis=: axis=0 stacks rows and aligns by column name (a column missing from one frame goes NaN on that frame's rows only); axis=1 stacks columns and aligns by index label (an index label missing from one frame goes NaN in that frame's columns only). Mixing up which axis you meant is the most common way to get a shape that looks right with values in the wrong place.

Exercise 8's round trip does not equal the original

pivot sorts its resulting columns alphabetically, which for wide's columns (math, reading, science) happens to already be alphabetical order for the subject columns but still needs student_id reset out of the index and back into first position, and the pivoted columns' Index carries a name ('subject') that wide's own columns do not — drop it with .rename_axis(columns=None) before comparing.

Exercise 9's pivot does not raise

Confirm dup_index_col genuinely has two rows sharing the same (student, subject) pair — ('Ann', 'math') appears twice in data.py. If you built your own test data and it has no duplicate pair, pivot will succeed instead of raising, which is expected and correct given that input; the assertion is about behaviour on duplicated pairs specifically.

pip install fails or hangs

You are offline, or a corporate proxy is blocking PyPI. This is the only network-dependent step in the entire lab. Retry on a connection that can reach pypi.org, or ask whoever manages your network for a mirror.

Security notes

Security notes

What this lab does to your machine

  • Opens one network connection, ever: pip install -r requirements/requirements.txt, to download pandas, pyarrow, NumPy and pytest from PyPI into this lab's own .venv. Every script and test after that runs completely offline.
  • Writes only inside its own .venv directory (created by you, via python3 -m venv .venv) and transient __pycache__ / .pytest_cache directories that the test harness removes both before and after every run.
  • Never opens a network socket, binds a port, needs sudo, or reads or writes any file outside this lab's own directory.
  • Needs no credential, API key, or account of any kind.

What the data in this lab is

Every table is a small literal built by hand in data.pyleft_dup, right_dup, left_keys, right_keys, int_keyed, str_keyed, price_left, price_right, wide and dup_index_col are each a dozen or fewer rows invented for the exercises. Nothing here is real personal, financial or otherwise sensitive data, and nothing is downloaded from any external dataset.

The design point this day is actually about

merge() will silently multiply your row count whenever a join key is duplicated on both sides — a many-to-many join is a Cartesian product within each key group, by definition, and pandas raises nothing to warn you. validate= turns a stated cardinality assumption into an enforced one: pass validate='one_to_one' (or 'one_to_many') and pandas raises MergeError the instant the assumption is false, instead of silently returning a frame with the wrong number of rows.

The lab's exercise 4 is the same discipline applied to dtypes. A key read one way (an int64 column) and the same values read another way (a str or categorical column) look identical when printed and can fail a join with zero warning — or, as this run measured on pandas 3.0.5, with a loud ValueError in the plain-string case and continued silence only in the categorical case. Either way, the fix is the same: check the dtypes before trusting the join, and prefer validate= on every merge where the row count matters.