{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Work with nested structures\n",
    "\n",
    "**The question.** You have flat records — rows from a CSV, JSON entries from an API, dicts read from a log — and you want them organised by one or more categories so you can iterate *\"for each department, for each person, …\"* The flipside is reaching into an already-nested structure without crashing on missing keys.\n",
    "\n",
    "Two patterns cover most of this: **group flat records** using `dict.setdefault(...)`, and **safely access nested values** using chained `dict.get(...)` calls with `{}` fallbacks."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Task: group flat records into a nested {category: [items...]} structure\n",
    "records = [\n",
    "    {'department': 'Engineering', 'name': 'Alice'},\n",
    "    {'department': 'Marketing',   'name': 'Bob'},\n",
    "    {'department': 'Engineering', 'name': 'Charlie'},\n",
    "    {'department': 'Marketing',   'name': 'Diana'},\n",
    "]\n",
    "\n",
    "grouped: dict[str, list[str]] = {}\n",
    "for record in records:\n",
    "    dept = record['department']\n",
    "    grouped.setdefault(dept, []).append(record['name'])\n",
    "\n",
    "for dept, names in grouped.items():\n",
    "    print(f'{dept}: {\", \".join(names)}')\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Safe access into deep nesting, without try/except\n",
    "# data.get(a, {}).get(b, {}).get(c) returns None if any layer is missing\n",
    "school = {\n",
    "    'Year 10': {\n",
    "        'Alice': {'maths': 85, 'science': 90},\n",
    "        'Bob':   {'maths': 72, 'science': 68},\n",
    "    },\n",
    "    'Year 11': {\n",
    "        'Charlie': {'maths': 91, 'science': 88},\n",
    "    },\n",
    "}\n",
    "\n",
    "alice_maths = school.get('Year 10', {}).get('Alice', {}).get('maths')\n",
    "missing     = school.get('Year 12', {}).get('Diana', {}).get('maths')\n",
    "\n",
    "print('Alice maths:', alice_maths)\n",
    "print('missing:', missing)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Why it works\n",
    "\n",
    "`dict.setdefault(key, default)` is the idiomatic \"give me the list at this key, creating it if needed\". It's atomic in intent: one line, no two-step *\"check if it exists, else insert\"* dance. The returned reference is the same object in the dict, so `.append(...)` on it mutates the dict's value in place.\n",
    "\n",
    "For deeper grouping (by two or more keys), `collections.defaultdict` is often cleaner: `defaultdict(lambda: defaultdict(list))` gives you two-level auto-creation, and you can skip the `setdefault` boilerplate entirely. `setdefault` wins for one-level grouping because it avoids the import and the subtle gotcha that printing a `defaultdict` can create missing keys.\n",
    "\n",
    "The safe-access pattern — `d.get(a, {}).get(b, {}).get(c)` — falls out of the fact that `.get(key, default)` never raises `KeyError`, and `{}` chains cleanly with more `.get()` calls. If any layer is missing, the final answer is `None` (or whatever default you pass to the last `.get()`). For code paths where a missing key is genuinely exceptional, a `try/except KeyError` block still reads fine — but the chained `.get()` is what you want when missing is normal."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Trade-offs\n",
    "\n",
    "**`setdefault` runs the default every time.** It's lazy-returned but eagerly constructed — `d.setdefault(k, expensive_call())` calls `expensive_call()` on every iteration, even when the key already exists. For expensive defaults, guard explicitly or use `defaultdict`.\n",
    "\n",
    "**`defaultdict` has side-effects.** Reading a missing key creates it. `d[missing]` is both a read and a write; `len(d)` silently grows. For dictionaries you're iterating and debugging, this can be surprising. Convert to a plain dict (`dict(dd)`) before handing it off.\n",
    "\n",
    "**Chained `.get()` hides the shape.** If \"missing\" for one layer should be an error but missing for another should be `None`, the chain can't express that. Unpack the access step by step when the rules differ.\n",
    "\n",
    "**Nested structures beyond two or three levels usually want a real model.** A `dataclass` or a Pydantic model makes the shape explicit, gives you attribute access (`school.year_10.alice.maths`), and centralises validation. Reach for one once the dicts grow a third level of nesting."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Related reading\n",
    "\n",
    "- [Convert between data structures](https://agilearn.co.uk/guides/data-structures/recipes/convert-between-structures) — the \"transpose records\" example is grouping's flipside.\n",
    "- [Choose the right data structure](https://agilearn.co.uk/guides/data-structures/recipes/choose-the-right-structure) — when to stop nesting dicts and reach for a `dataclass`.\n",
    "- [Merge and compare dictionaries](https://agilearn.co.uk/guides/data-structures/recipes/merge-and-compare-dictionaries) — merging is a special case of nesting.\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.12.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}