{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Python Toolkit — seven tools that make Python click\n",
    "\n",
    "Companion notebook for **[python_toolkit.html](python_toolkit.html)**.\n",
    "\n",
    "You already know functions, loops and lists. These are the next seven tools.\n",
    "Each one solves a problem you will meet soon.\n",
    "\n",
    "| # | Tool | Solves |\n",
    "|---|------|--------|\n",
    "| 1 | `class` | keeping data and behaviour together |\n",
    "| 2 | `yield` (generators) | data too big for memory |\n",
    "| 3 | `@decorator` | adding behaviour without editing a function |\n",
    "| 4 | type hints | saying what goes in and what comes out |\n",
    "| 5 | `pathlib` | file paths that work on every computer |\n",
    "| 6 | `json` | saving and loading data |\n",
    "| 7 | `requests` | fetching web pages and APIs |\n",
    "\n",
    "**How to use this notebook**\n",
    "\n",
    "- Run each cell with **Shift + Enter**.\n",
    "- Run the cells **in order** — later ones use names defined earlier.\n",
    "- Every section ends with an exercise. Try it before looking at the answers in section 10.\n",
    "- Change the code. Break it on purpose. Read the error. That is the fastest way to learn.\n",
    "\n",
    "Only section 7 needs the internet, and one install:\n",
    "\n",
    "```\n",
    "pip install requests\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 1. Classes — data and behaviour in one place\n",
    "\n",
    "With plain variables, two dogs need four variables and nothing links a name to its age:\n",
    "\n",
    "```python\n",
    "dog1_name = \"Somchai\"\n",
    "dog1_age = 3\n",
    "dog2_name = \"Nong Mee\"\n",
    "dog2_age = 5\n",
    "```\n",
    "\n",
    "A **class** is a template. It says what data one dog holds, and what one dog can do."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Dog:\n",
    "    \"\"\"One dog at the shelter.\"\"\"\n",
    "\n",
    "    def __init__(self, name, age):\n",
    "        self.name = name\n",
    "        self.age = age\n",
    "\n",
    "    def bark(self):\n",
    "        return f\"{self.name} says woof!\"\n",
    "\n",
    "    def human_years(self):\n",
    "        return self.age * 7\n",
    "\n",
    "\n",
    "# Each dog made from the template is an \"object\"\n",
    "somchai = Dog(\"Somchai\", 3)\n",
    "nong_mee = Dog(\"Nong Mee\", 5)\n",
    "\n",
    "print(somchai.bark())\n",
    "print(nong_mee.bark())\n",
    "print(nong_mee.name, \"is\", nong_mee.human_years(), \"in human years\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Reading it:\n",
    "\n",
    "- `class Dog:` starts the template. Class names use CapitalCase.\n",
    "- `__init__` runs once when you make a new dog. It is the *constructor*.\n",
    "- `self` means \"this particular dog\". Every method takes it first.\n",
    "- `self.name = name` stores the name **on the object**, so it stays there.\n",
    "- You never pass `self` yourself — `somchai.bark()` passes it for you.\n",
    "\n",
    "Each object keeps its own data. Proving it:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(somchai.age, nong_mee.age)\n",
    "\n",
    "somchai.age = 4          # only this dog changes\n",
    "print(somchai.age, nong_mee.age)\n",
    "\n",
    "print(type(somchai))     # <class '__main__.Dog'>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`__str__` controls what `print(dog)` shows. Without it you get an ugly memory address."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(somchai)           # ugly\n",
    "\n",
    "\n",
    "class Dog:\n",
    "    \"\"\"One dog at the shelter, now printable.\"\"\"\n",
    "\n",
    "    def __init__(self, name, age):\n",
    "        self.name = name\n",
    "        self.age = age\n",
    "\n",
    "    def bark(self):\n",
    "        return f\"{self.name} says woof!\"\n",
    "\n",
    "    def human_years(self):\n",
    "        return self.age * 7\n",
    "\n",
    "    def __str__(self):\n",
    "        return f\"Dog({self.name}, {self.age} years old)\"\n",
    "\n",
    "\n",
    "somchai = Dog(\"Somchai\", 3)\n",
    "print(somchai)           # much better"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Exercise 1\n",
    "\n",
    "Write a class `Student` with:\n",
    "\n",
    "- `__init__` taking a `name` and a list of `scores`\n",
    "- a method `average()` returning the mean score\n",
    "- a method `passed()` returning `True` if the average is 60 or more\n",
    "- a `__str__` so `print(s)` shows something readable\n",
    "\n",
    "Then make a student with scores `[82, 91, 77]` and print the average."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 2. Generators — `yield` for data that will not fit\n",
    "\n",
    "`return` hands back everything at once. For a 20 GB file that means running out of memory.\n",
    "`yield` hands back **one item at a time**.\n",
    "\n",
    "Watch the difference. First, a normal function that returns a list:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def squares_list(n):\n",
    "    result = []\n",
    "    for i in range(n):\n",
    "        result.append(i * i)\n",
    "    return result          # the WHOLE list, all at once\n",
    "\n",
    "\n",
    "def squares_gen(n):\n",
    "    for i in range(n):\n",
    "        yield i * i        # give ONE value, then pause\n",
    "\n",
    "\n",
    "print(squares_list(5))\n",
    "print(squares_gen(5))      # not a list — a generator object!"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Calling a generator function does **not** run it. It returns a promise to produce values later.\n",
    "The code only runs when something asks for a value."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "gen = squares_gen(5)\n",
    "\n",
    "print(next(gen))    # 0  — runs until the first yield, then pauses\n",
    "print(next(gen))    # 1  — wakes up exactly where it stopped\n",
    "print(next(gen))    # 4\n",
    "\n",
    "print(\"the rest:\", list(gen))   # 9, 16 — only what is left"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now the memory difference, measured. `sys.getsizeof` reports how many bytes an object holds."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import sys\n",
    "\n",
    "big_list = [i * i for i in range(1_000_000)]\n",
    "big_gen = (i * i for i in range(1_000_000))   # round brackets = generator expression\n",
    "\n",
    "print(\"list:     \", sys.getsizeof(big_list), \"bytes\")\n",
    "print(\"generator:\", sys.getsizeof(big_gen), \"bytes\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The list grows with the data. The generator does not — it only remembers where it paused.\n",
    "\n",
    "Because values are made on demand, a generator can be **endless**. A list cannot."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def count_from(start):\n",
    "    n = start\n",
    "    while True:        # never ends\n",
    "        yield n\n",
    "        n += 1\n",
    "\n",
    "\n",
    "for n in count_from(10):\n",
    "    print(n)\n",
    "    if n >= 13:\n",
    "        break          # you must break out yourself"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Three rules to remember:**\n",
    "\n",
    "1. Any function containing `yield` is a generator. There is no other keyword.\n",
    "2. A generator is used **once**. Loop over it twice and the second loop gets nothing.\n",
    "3. You cannot use `len()` on it or index it with `[3]`. It does not know its own size.\n",
    "\n",
    "Rule 2 catches everybody. See it happen:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "gen = squares_gen(5)\n",
    "\n",
    "print(\"first loop: \", list(gen))\n",
    "print(\"second loop:\", list(gen))   # empty! the generator is used up"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Exercise 2\n",
    "\n",
    "Write a generator `even_numbers(limit)` that yields even numbers from 0 up to (but not\n",
    "including) `limit`.\n",
    "\n",
    "Then use it to print the even numbers below 10.\n",
    "\n",
    "**Bonus:** write `first_n(gen, n)` that takes any generator and yields only its first `n`\n",
    "values — so `list(first_n(count_from(100), 3))` gives `[100, 101, 102]`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 3. Decorators — `@something` wraps a function\n",
    "\n",
    "You have five functions and want to time all of them. Copying two lines into each is the\n",
    "wrong answer. A **decorator** writes those lines once and wraps them around any function.\n",
    "\n",
    "A decorator is a function that takes a function and returns a new function. That sentence\n",
    "is the whole idea."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import time\n",
    "\n",
    "\n",
    "def timed(fn):\n",
    "    \"\"\"Print how long the wrapped function takes.\"\"\"\n",
    "    def wrapper(*args, **kwargs):\n",
    "        start = time.time()\n",
    "        result = fn(*args, **kwargs)\n",
    "        print(f\"[{fn.__name__} took {time.time() - start:.2f}s]\")\n",
    "        return result          # forget this and your function returns None\n",
    "    return wrapper             # no brackets — return the function itself\n",
    "\n",
    "\n",
    "@timed\n",
    "def slow_add(a, b):\n",
    "    time.sleep(1)\n",
    "    return a + b\n",
    "\n",
    "\n",
    "print(slow_add(2, 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The `@` is only shorthand. These two are exactly the same:\n",
    "\n",
    "```python\n",
    "@timed\n",
    "def slow_add(a, b): ...\n",
    "\n",
    "# ...means:\n",
    "\n",
    "def slow_add(a, b): ...\n",
    "slow_add = timed(slow_add)\n",
    "```\n",
    "\n",
    "Proving it, with no `@` anywhere:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def plain_add(a, b):\n",
    "    time.sleep(0.5)\n",
    "    return a + b\n",
    "\n",
    "\n",
    "plain_add = timed(plain_add)    # exactly what @timed does\n",
    "print(plain_add(10, 20))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`*args, **kwargs` means \"accept any inputs and pass them straight through\". That is what\n",
    "lets one decorator wrap functions with completely different signatures."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "@timed\n",
    "def greet(name, greeting=\"Hello\"):\n",
    "    return f\"{greeting}, {name}!\"\n",
    "\n",
    "\n",
    "print(greet(\"Ploy\"))\n",
    "print(greet(\"Ploy\", greeting=\"Sawasdee\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### One line worth adding\n",
    "\n",
    "Without help, a decorated function forgets its own name and docstring:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(slow_add.__name__)   # \"wrapper\" — wrong!\n",
    "print(slow_add.__doc__)    # the wrapper's docstring, not slow_add's\n",
    "\n",
    "import functools\n",
    "\n",
    "\n",
    "def timed(fn):\n",
    "    \"\"\"Print how long the wrapped function takes.\"\"\"\n",
    "    @functools.wraps(fn)           # <-- the fix\n",
    "    def wrapper(*args, **kwargs):\n",
    "        start = time.time()\n",
    "        result = fn(*args, **kwargs)\n",
    "        print(f\"[{fn.__name__} took {time.time() - start:.2f}s]\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "@timed\n",
    "def slow_add(a, b):\n",
    "    \"\"\"Add two numbers, slowly.\"\"\"\n",
    "    time.sleep(0.2)\n",
    "    return a + b\n",
    "\n",
    "\n",
    "slow_add(1, 2)\n",
    "print(slow_add.__name__)   # \"slow_add\" — correct\n",
    "print(slow_add.__doc__)    # its own docstring"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "You will use decorators from libraries long before you write your own. Here is a real one\n",
    "from the standard library — `@lru_cache` remembers past results:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from functools import lru_cache\n",
    "\n",
    "\n",
    "@lru_cache\n",
    "def fib(n):\n",
    "    \"\"\"The nth Fibonacci number, the slow recursive way.\"\"\"\n",
    "    if n < 2:\n",
    "        return n\n",
    "    return fib(n - 1) + fib(n - 2)\n",
    "\n",
    "\n",
    "start = time.time()\n",
    "print(fib(35))\n",
    "print(f\"{time.time() - start:.4f}s\")   # instant — try deleting @lru_cache and rerunning"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Exercise 3\n",
    "\n",
    "Write a decorator `shout` that turns whatever the function returns into upper case,\n",
    "and adds `\"!!!\"` at the end.\n",
    "\n",
    "```python\n",
    "@shout\n",
    "def greet(name):\n",
    "    return f\"hello {name}\"\n",
    "\n",
    "greet(\"ploy\")     # should give \"HELLO PLOY!!!\"\n",
    "```\n",
    "\n",
    "**Bonus:** write `count_calls`, which prints how many times the function has been called."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 4. Type hints — say what goes in and what comes out\n",
    "\n",
    "Look at `def process(data):` — what is `data`? A list? A file? A number? You have to read\n",
    "the whole body to find out. Type hints answer it in the first line."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def greet(name: str) -> str:\n",
    "    \"\"\"Return a friendly greeting.\"\"\"\n",
    "    return f\"Hello, {name}!\"\n",
    "\n",
    "\n",
    "print(greet(\"Ploy\"))\n",
    "\n",
    "# Python does NOT enforce hints. This still runs:\n",
    "print(greet(42))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Hints are notes for humans and tools, not rules for the interpreter. They are still worth\n",
    "writing, because your editor, `mypy`, and AI coding tools all read them.\n",
    "\n",
    "The types you will actually use:\n",
    "\n",
    "| Hint | Means |\n",
    "|------|-------|\n",
    "| `str`, `int`, `float`, `bool` | text, whole number, decimal, True/False |\n",
    "| `list[str]` | a list of strings |\n",
    "| `dict[str, int]` | dictionary with string keys, number values |\n",
    "| `str \\| None` | a string, or nothing — very common for \"might fail\" |\n",
    "| `Path` | a `pathlib` path |\n",
    "| `-> None` | returns nothing (only prints or saves) |"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "\n",
    "def word_count(text: str) -> dict[str, int]:\n",
    "    \"\"\"Count how many times each word appears.\"\"\"\n",
    "    counts: dict[str, int] = {}\n",
    "    for word in text.lower().split():\n",
    "        counts[word] = counts.get(word, 0) + 1\n",
    "    return counts\n",
    "\n",
    "\n",
    "def find_config(folder: Path) -> Path | None:\n",
    "    \"\"\"Return the config file if it exists, otherwise None.\"\"\"\n",
    "    candidate = folder / \"config.json\"\n",
    "    return candidate if candidate.exists() else None\n",
    "\n",
    "\n",
    "def announce(message: str) -> None:\n",
    "    \"\"\"Print a message. Returns nothing.\"\"\"\n",
    "    print(f\">>> {message}\")\n",
    "\n",
    "\n",
    "print(word_count(\"the cat sat on the mat the end\"))\n",
    "print(find_config(Path(\".\")))     # None, unless you happen to have one\n",
    "announce(\"hints are just notes\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Hints are stored on the function, which is exactly how tools read them:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(greet.__annotations__)\n",
    "print(word_count.__annotations__)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Exercise 4\n",
    "\n",
    "Add type hints to these three functions. Do not change what they do.\n",
    "\n",
    "```python\n",
    "def total(prices):\n",
    "    return sum(prices)\n",
    "\n",
    "def initials(first, last):\n",
    "    return first[0] + last[0]\n",
    "\n",
    "def lookup(names, key):\n",
    "    # returns a name, or None if the key is missing\n",
    "    return names.get(key)\n",
    "```"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 5. pathlib — file paths that work everywhere\n",
    "\n",
    "Old code joins paths with strings and `os.path`. `pathlib` gives you a real **Path object**,\n",
    "and reuses the `/` operator to join paths."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "base = Path(\"toolkit_demo\")\n",
    "notes = base / \"lessons\" / \"notes.txt\"\n",
    "\n",
    "print(notes)              # Windows and Mac each get the right separator\n",
    "print(notes.name)         # notes.txt\n",
    "print(notes.stem)         # notes\n",
    "print(notes.suffix)       # .txt\n",
    "print(notes.parent)       # toolkit_demo/lessons\n",
    "print(notes.exists())     # False — nothing created yet"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Nothing has touched the disk yet. A `Path` is just a description of a location.\n",
    "\n",
    "Now create the folder and write a file. `parents=True` makes any missing parent folders;\n",
    "`exist_ok=True` means \"do not complain if it is already there\"."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "notes.parent.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "notes.write_text(\"Sawasdee from pathlib!\\nLine two.\\n\", encoding=\"utf-8\")\n",
    "\n",
    "print(notes.exists())\n",
    "print(notes.read_text(encoding=\"utf-8\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Always pass `encoding=\"utf-8\"`.** On Windows, Python may otherwise use a local encoding,\n",
    "and Thai or Chinese text comes back broken. One keyword prevents a whole class of bug."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "thai = base / \"lessons\" / \"thai.txt\"\n",
    "thai.write_text(\"ครูสอนภาษาอังกฤษ\\n老师\\n\", encoding=\"utf-8\")\n",
    "print(thai.read_text(encoding=\"utf-8\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`glob` finds files by pattern. `rglob` does the same through every sub-folder.\n",
    "Both return generators — section 2 again."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# make a few more files to search\n",
    "for i in range(3):\n",
    "    (base / \"lessons\" / f\"day{i}.txt\").write_text(f\"day {i}\\n\", encoding=\"utf-8\")\n",
    "\n",
    "print(\"glob is lazy:\", base.rglob(\"*.txt\"))\n",
    "print()\n",
    "\n",
    "for txt in sorted(base.rglob(\"*.txt\")):\n",
    "    size = len(txt.read_text(encoding=\"utf-8\"))\n",
    "    print(f\"{txt.stem:10} {size:3} characters   ({txt})\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Handy paths you did not have to type"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"current folder:\", Path.cwd())\n",
    "print(\"your home:     \", Path.home())\n",
    "print(\"absolute:      \", notes.resolve())\n",
    "print(\"is it a file?  \", notes.is_file())\n",
    "print(\"is it a folder?\", notes.parent.is_dir())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Exercise 5\n",
    "\n",
    "Using `pathlib` only (no `os.path`):\n",
    "\n",
    "1. Make a folder `toolkit_demo/reports`.\n",
    "2. Write three files into it: `jan.txt`, `feb.txt`, `mar.txt`, each containing one line of text.\n",
    "3. Loop over them and print `stem` and the number of characters in each.\n",
    "4. Print the **total** number of characters across all three."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 6. json — save and load data in two lines\n",
    "\n",
    "Your program has a dictionary. You want it to still exist tomorrow. JSON is the standard\n",
    "text format for that. Every language reads it, and every web API speaks it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "from pathlib import Path\n",
    "\n",
    "student = {\n",
    "    \"name\": \"Ploy\",\n",
    "    \"level\": \"B1\",\n",
    "    \"scores\": [82, 91, 77],\n",
    "    \"active\": True,\n",
    "    \"teacher\": None,\n",
    "}\n",
    "\n",
    "out = Path(\"toolkit_demo\") / \"student.json\"\n",
    "\n",
    "# Save it\n",
    "out.write_text(json.dumps(student, indent=2), encoding=\"utf-8\")\n",
    "\n",
    "# Load it back\n",
    "loaded = json.loads(out.read_text(encoding=\"utf-8\"))\n",
    "\n",
    "print(loaded)\n",
    "print(loaded[\"scores\"][1])\n",
    "print(type(loaded))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Look at the file Python actually wrote. Note `true` and `null` in lower case — that is JSON,\n",
    "not Python."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(out.read_text(encoding=\"utf-8\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Four names, easy to mix up\n",
    "\n",
    "| Function | Direction | Remember it as |\n",
    "|---|---|---|\n",
    "| `json.dumps(obj)` | Python → text | dump-**s** = dump to **s**tring |\n",
    "| `json.loads(text)` | text → Python | load-**s** = load from **s**tring |\n",
    "| `json.dump(obj, f)` | Python → open file | no s = works on a file |\n",
    "| `json.load(f)` | open file → Python | no s = works on a file |\n",
    "\n",
    "With `pathlib` you mostly need only the two `s` versions.\n",
    "\n",
    "### The argument that matters for Thai and Chinese"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "lesson = {\"teacher\": \"ครู\", \"subject\": \"老师\"}\n",
    "\n",
    "print(\"default:          \", json.dumps(lesson))\n",
    "print(\"ensure_ascii=False:\", json.dumps(lesson, ensure_ascii=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Both load back correctly, but only one is readable by a human opening the file.\n",
    "\n",
    "### What JSON cannot hold\n",
    "\n",
    "Dates, sets, and your own class objects are not JSON types. Convert them first."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from datetime import date\n",
    "\n",
    "bad = {\"today\": date.today(), \"tags\": {\"a\", \"b\"}}\n",
    "\n",
    "try:\n",
    "    json.dumps(bad)\n",
    "except TypeError as e:\n",
    "    print(\"TypeError:\", e)\n",
    "\n",
    "# Convert first:\n",
    "good = {\"today\": str(date.today()), \"tags\": list({\"a\", \"b\"})}\n",
    "print(json.dumps(good))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Exercise 6\n",
    "\n",
    "1. Build a list of three dictionaries, each with `name` and `score`.\n",
    "2. Save it to `toolkit_demo/class.json` with `indent=2` and `ensure_ascii=False`.\n",
    "3. Load it back into a new variable.\n",
    "4. Print the name of the student with the highest score.\n",
    "\n",
    "**Hint for step 4:** `max(students, key=lambda s: s[\"score\"])`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 7. requests — fetch web pages and APIs\n",
    "\n",
    "The first six tools ship with Python. This one you install:\n",
    "\n",
    "```\n",
    "pip install requests\n",
    "```\n",
    "\n",
    "**This section needs an internet connection.** If a cell fails with a connection error,\n",
    "that is the network, not your code."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import requests\n",
    "\n",
    "r = requests.get(\"https://api.github.com/repos/python/cpython\", timeout=10)\n",
    "r.raise_for_status()\n",
    "data = r.json()\n",
    "\n",
    "print(data[\"name\"], \"has\", data[\"stargazers_count\"], \"stars\")\n",
    "print(\"language:\", data[\"language\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Four lines, and every one earns its place:\n",
    "\n",
    "- `requests.get(url)` asks a server for something and waits.\n",
    "- `timeout=10` gives up after 10 seconds. **Always set it** or your program can hang forever.\n",
    "- `raise_for_status()` raises an error on 404 or 500. Without it, a failed request looks successful.\n",
    "- `r.json()` turns the reply into Python dicts and lists — it runs `json.loads` for you.\n",
    "\n",
    "What comes back:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"status:  \", r.status_code)   # 200 is fine\n",
    "print(\"ok:      \", r.ok)\n",
    "print(\"type:    \", r.headers[\"content-type\"])\n",
    "print(\"text len:\", len(r.text), \"characters of raw JSON\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "See what `raise_for_status()` protects you from:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "bad = requests.get(\"https://api.github.com/repos/python/this-does-not-exist\", timeout=10)\n",
    "\n",
    "print(\"status:\", bad.status_code)     # 404 — but no crash yet!\n",
    "print(\"ok:    \", bad.ok)\n",
    "\n",
    "try:\n",
    "    bad.raise_for_status()\n",
    "except requests.HTTPError as e:\n",
    "    print(\"caught:\", e)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Sending data with the request\n",
    "\n",
    "`params=` builds the `?key=value` part safely. Never glue it on with `+`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "r = requests.get(\"https://dummyjson.com/quotes\", params={\"limit\": 3}, timeout=10)\n",
    "r.raise_for_status()\n",
    "\n",
    "print(\"asked for:\", r.url)\n",
    "print()\n",
    "\n",
    "for q in r.json()[\"quotes\"]:\n",
    "    print(f'\"{q[\"quote\"]}\"')\n",
    "    print(f'   — {q[\"author\"]}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`json=` sends a Python dictionary as a JSON body, and sets the header for you.\n",
    "`headers=` is where API keys go.\n",
    "\n",
    "`httpbin.org` echoes back whatever you send, which makes it perfect for seeing what left\n",
    "your computer. It is a free shared server, so it is sometimes busy — `RequestException` is\n",
    "the parent of every error `requests` can raise, so catching it catches them all."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "try:\n",
    "    r = requests.post(\n",
    "        \"https://httpbin.org/post\",\n",
    "        json={\"student\": \"Ploy\", \"score\": 91},\n",
    "        headers={\"X-Demo-Header\": \"hello\"},\n",
    "        timeout=15,\n",
    "    )\n",
    "    r.raise_for_status()\n",
    "    echo = r.json()\n",
    "\n",
    "    print(\"server saw this body:  \", echo[\"json\"])\n",
    "    print(\"server saw this header:\", echo[\"headers\"].get(\"X-Demo-Header\"))\n",
    "\n",
    "except requests.RequestException as e:\n",
    "    print(\"httpbin.org is busy. That is the server, not your code — try again in a moment.\")\n",
    "    print(\"error:\", e)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Never put an API key in your code.** Read it from the environment instead:\n",
    "\n",
    "```python\n",
    "import os\n",
    "token = os.getenv(\"MY_API_KEY\")\n",
    "```\n",
    "\n",
    "A key pasted into a file will end up on GitHub one day.\n",
    "\n",
    "### requests + pathlib: saving a download"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "r = requests.get(\"https://www.python.org/static/img/python-logo.png\", timeout=30)\n",
    "r.raise_for_status()\n",
    "\n",
    "logo = Path(\"toolkit_demo\") / \"logo.png\"\n",
    "logo.write_bytes(r.content)          # .content is raw bytes, not text\n",
    "\n",
    "print(\"saved\", logo, \"-\", logo.stat().st_size, \"bytes\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Exercise 7\n",
    "\n",
    "The GitHub API can list a user's public repositories:\n",
    "\n",
    "`https://api.github.com/users/USERNAME/repos`\n",
    "\n",
    "1. Fetch the repos for the user `python`.\n",
    "2. Use `params={\"per_page\": 5}` so you only get five.\n",
    "3. Set a `timeout`, and call `raise_for_status()`.\n",
    "4. Print each repo's `name` and `stargazers_count`.\n",
    "5. Save the whole reply to `toolkit_demo/repos.json` — that is section 6 again."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 8. All seven in one small program\n",
    "\n",
    "This script fetches quotes from a public API, saves them, and prints them. Every one of the\n",
    "seven tools appears — the comments mark them.\n",
    "\n",
    "Run it twice. The **first** run fetches from the internet. The **second** reads the file and\n",
    "is instant. That difference is what a cache is."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "import time\n",
    "from pathlib import Path\n",
    "from typing import Iterator\n",
    "\n",
    "import requests\n",
    "\n",
    "CACHE_DIR = Path(\"toolkit_demo\")\n",
    "\n",
    "\n",
    "def timed(fn):                                          # 3. decorator\n",
    "    \"\"\"Print how long the wrapped function takes.\"\"\"\n",
    "    def wrapper(*args, **kwargs):\n",
    "        start = time.time()\n",
    "        result = fn(*args, **kwargs)\n",
    "        print(f\"[{fn.__name__} took {time.time() - start:.2f}s]\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "class Quote:                                            # 1. class\n",
    "    \"\"\"One quote and the person who said it.\"\"\"\n",
    "\n",
    "    def __init__(self, text: str, author: str):         # 4. type hints\n",
    "        self.text = text\n",
    "        self.author = author\n",
    "\n",
    "    def short(self, limit: int = 50) -> str:\n",
    "        \"\"\"The quote, cut to `limit` characters.\"\"\"\n",
    "        if len(self.text) <= limit:\n",
    "            return self.text\n",
    "        return self.text[:limit - 3] + \"...\"\n",
    "\n",
    "\n",
    "@timed\n",
    "def fetch_quotes(count: int) -> list[dict]:             # 7. requests\n",
    "    \"\"\"Ask the API for `count` quotes.\"\"\"\n",
    "    r = requests.get(\n",
    "        \"https://dummyjson.com/quotes\",\n",
    "        params={\"limit\": count},\n",
    "        timeout=10,\n",
    "    )\n",
    "    r.raise_for_status()\n",
    "    return r.json()[\"quotes\"]\n",
    "\n",
    "\n",
    "def read_quotes(path: Path) -> Iterator[Quote]:         # 2. generator\n",
    "    \"\"\"Read the saved file, one Quote at a time.\"\"\"\n",
    "    data = json.loads(path.read_text(encoding=\"utf-8\"))  # 6. json\n",
    "    for item in data:\n",
    "        yield Quote(item[\"quote\"], item[\"author\"])\n",
    "\n",
    "\n",
    "def main() -> None:\n",
    "    CACHE_DIR.mkdir(exist_ok=True)                      # 5. pathlib\n",
    "    cache_file = CACHE_DIR / \"quotes.json\"\n",
    "\n",
    "    if not cache_file.exists():\n",
    "        print(\"No cache. Fetching...\")\n",
    "        quotes = fetch_quotes(5)\n",
    "        cache_file.write_text(\n",
    "            json.dumps(quotes, indent=2, ensure_ascii=False),\n",
    "            encoding=\"utf-8\",\n",
    "        )\n",
    "    else:\n",
    "        print(f\"Reading cache: {cache_file}\")\n",
    "\n",
    "    for quote in read_quotes(cache_file):\n",
    "        print(f\"  {quote.short()} - {quote.author}\")\n",
    "\n",
    "\n",
    "main()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Run the cell above a second time and watch the first line change.\n",
    "\n",
    "### Exercise 8\n",
    "\n",
    "Point the same program at a different API. Change **only**:\n",
    "\n",
    "- the URL inside `fetch_quotes`\n",
    "- the `params`\n",
    "- the keys read in `read_quotes`\n",
    "\n",
    "Try the GitHub repos endpoint from exercise 7, and make `Quote` into a `Repo` class with\n",
    "`name` and `stars`. Almost nothing else needs to change — that is the point of writing it\n",
    "this way."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Your code here"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Clean up\n",
    "\n",
    "When you are finished, this removes the folder the notebook created."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import shutil\n",
    "from pathlib import Path\n",
    "\n",
    "demo = Path(\"toolkit_demo\")\n",
    "if demo.exists():\n",
    "    shutil.rmtree(demo)\n",
    "    print(\"removed\", demo.resolve())\n",
    "else:\n",
    "    print(\"nothing to remove\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 9. One-page cheat sheet\n",
    "\n",
    "```python\n",
    "# 1. class — data + behaviour together\n",
    "class Dog:\n",
    "    def __init__(self, name, age):\n",
    "        self.name = name\n",
    "        self.age = age\n",
    "    def bark(self):\n",
    "        return f\"{self.name} says woof!\"\n",
    "\n",
    "# 2. generator — one item at a time, any size\n",
    "def read_lines(path):\n",
    "    with open(path) as f:\n",
    "        for line in f:\n",
    "            yield line.strip()\n",
    "\n",
    "# 3. decorator — wrap a function\n",
    "def timed(fn):\n",
    "    def wrapper(*args, **kwargs):\n",
    "        ...\n",
    "        return fn(*args, **kwargs)\n",
    "    return wrapper\n",
    "\n",
    "@timed\n",
    "def slow(): ...\n",
    "\n",
    "# 4. type hints — what goes in, what comes out\n",
    "def greet(name: str) -> str: ...\n",
    "def load(p: Path) -> dict[str, int] | None: ...\n",
    "\n",
    "# 5. pathlib — paths that work everywhere\n",
    "p = Path(\"data\") / \"notes.txt\"\n",
    "p.parent.mkdir(parents=True, exist_ok=True)\n",
    "p.write_text(text, encoding=\"utf-8\")\n",
    "for f in Path(\".\").rglob(\"*.txt\"): ...\n",
    "\n",
    "# 6. json — save and load\n",
    "p.write_text(json.dumps(obj, indent=2, ensure_ascii=False), encoding=\"utf-8\")\n",
    "obj = json.loads(p.read_text(encoding=\"utf-8\"))\n",
    "\n",
    "# 7. requests — fetch the web\n",
    "r = requests.get(url, params={...}, timeout=10)\n",
    "r.raise_for_status()\n",
    "data = r.json()\n",
    "```\n",
    "\n",
    "**The five mistakes that cost beginners the most time**\n",
    "\n",
    "1. No `timeout=` on a request — the program hangs forever.\n",
    "2. No `raise_for_status()` — a 404 quietly looks like success.\n",
    "3. No `encoding=\"utf-8\"` — Thai and Chinese text turns to nonsense on Windows.\n",
    "4. Looping over a generator twice — the second loop silently gets nothing.\n",
    "5. Forgetting `return result` in a decorator — the function returns `None`."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "## 10. Answers\n",
    "\n",
    "Try each exercise before reading these. There is more than one right answer.\n",
    "\n",
    "### Answer 1 — `Student` class"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Student:\n",
    "    \"\"\"A student and their scores.\"\"\"\n",
    "\n",
    "    def __init__(self, name, scores):\n",
    "        self.name = name\n",
    "        self.scores = scores\n",
    "\n",
    "    def average(self):\n",
    "        if not self.scores:\n",
    "            return 0\n",
    "        return sum(self.scores) / len(self.scores)\n",
    "\n",
    "    def passed(self):\n",
    "        return self.average() >= 60\n",
    "\n",
    "    def __str__(self):\n",
    "        return f\"{self.name}: {self.average():.1f} ({'pass' if self.passed() else 'fail'})\"\n",
    "\n",
    "\n",
    "ploy = Student(\"Ploy\", [82, 91, 77])\n",
    "print(ploy.average())\n",
    "print(ploy)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Answer 2 — `even_numbers` and `first_n`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def even_numbers(limit):\n",
    "    for n in range(limit):\n",
    "        if n % 2 == 0:\n",
    "            yield n\n",
    "\n",
    "\n",
    "print(list(even_numbers(10)))\n",
    "\n",
    "\n",
    "# Bonus\n",
    "def first_n(gen, n):\n",
    "    for i, value in enumerate(gen):\n",
    "        if i >= n:\n",
    "            return          # 'return' ends a generator early\n",
    "        yield value\n",
    "\n",
    "\n",
    "def count_from(start):\n",
    "    n = start\n",
    "    while True:\n",
    "        yield n\n",
    "        n += 1\n",
    "\n",
    "\n",
    "print(list(first_n(count_from(100), 3)))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Answer 3 — `shout` and `count_calls`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import functools\n",
    "\n",
    "\n",
    "def shout(fn):\n",
    "    @functools.wraps(fn)\n",
    "    def wrapper(*args, **kwargs):\n",
    "        result = fn(*args, **kwargs)\n",
    "        return result.upper() + \"!!!\"\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "@shout\n",
    "def greet(name):\n",
    "    return f\"hello {name}\"\n",
    "\n",
    "\n",
    "print(greet(\"ploy\"))\n",
    "\n",
    "\n",
    "# Bonus\n",
    "def count_calls(fn):\n",
    "    @functools.wraps(fn)\n",
    "    def wrapper(*args, **kwargs):\n",
    "        wrapper.calls += 1\n",
    "        print(f\"{fn.__name__} call #{wrapper.calls}\")\n",
    "        return fn(*args, **kwargs)\n",
    "    wrapper.calls = 0          # the count lives on the wrapper\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "@count_calls\n",
    "def ping():\n",
    "    return \"pong\"\n",
    "\n",
    "\n",
    "ping()\n",
    "ping()\n",
    "ping()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Answer 4 — type hints"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def total(prices: list[float]) -> float:\n",
    "    return sum(prices)\n",
    "\n",
    "\n",
    "def initials(first: str, last: str) -> str:\n",
    "    return first[0] + last[0]\n",
    "\n",
    "\n",
    "def lookup(names: dict[str, str], key: str) -> str | None:\n",
    "    return names.get(key)\n",
    "\n",
    "\n",
    "print(total([1.5, 2.5]))\n",
    "print(initials(\"Ploy\", \"Suwan\"))\n",
    "print(lookup({\"a\": \"Anan\"}, \"b\"))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Answer 5 — pathlib"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "reports = Path(\"toolkit_demo\") / \"reports\"\n",
    "reports.mkdir(parents=True, exist_ok=True)\n",
    "\n",
    "for month in [\"jan\", \"feb\", \"mar\"]:\n",
    "    (reports / f\"{month}.txt\").write_text(f\"Report for {month}.\\n\", encoding=\"utf-8\")\n",
    "\n",
    "total_chars = 0\n",
    "for f in sorted(reports.glob(\"*.txt\")):\n",
    "    text = f.read_text(encoding=\"utf-8\")\n",
    "    print(f\"{f.stem}: {len(text)} characters\")\n",
    "    total_chars += len(text)\n",
    "\n",
    "print(\"total:\", total_chars)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Answer 6 — json"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "from pathlib import Path\n",
    "\n",
    "students = [\n",
    "    {\"name\": \"Ploy\", \"score\": 91},\n",
    "    {\"name\": \"ครูอาทิตย์\", \"score\": 77},\n",
    "    {\"name\": \"Nong Mee\", \"score\": 84},\n",
    "]\n",
    "\n",
    "path = Path(\"toolkit_demo\") / \"class.json\"\n",
    "path.parent.mkdir(exist_ok=True)\n",
    "path.write_text(\n",
    "    json.dumps(students, indent=2, ensure_ascii=False),\n",
    "    encoding=\"utf-8\",\n",
    ")\n",
    "\n",
    "loaded = json.loads(path.read_text(encoding=\"utf-8\"))\n",
    "best = max(loaded, key=lambda s: s[\"score\"])\n",
    "print(\"top student:\", best[\"name\"], \"with\", best[\"score\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Answer 7 — requests"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "import requests\n",
    "from pathlib import Path\n",
    "\n",
    "r = requests.get(\n",
    "    \"https://api.github.com/users/python/repos\",\n",
    "    params={\"per_page\": 5},\n",
    "    timeout=10,\n",
    ")\n",
    "r.raise_for_status()\n",
    "repos = r.json()\n",
    "\n",
    "for repo in repos:\n",
    "    print(f\"{repo['name']:<20} {repo['stargazers_count']:>7} stars\")\n",
    "\n",
    "out = Path(\"toolkit_demo\") / \"repos.json\"\n",
    "out.parent.mkdir(exist_ok=True)\n",
    "out.write_text(json.dumps(repos, indent=2, ensure_ascii=False), encoding=\"utf-8\")\n",
    "print(\"saved\", out)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Answer 8 — the capstone, pointed somewhere else\n",
    "\n",
    "Only three things changed: the URL, the params, and the keys."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "import time\n",
    "from pathlib import Path\n",
    "from typing import Iterator\n",
    "\n",
    "import requests\n",
    "\n",
    "CACHE_DIR = Path(\"toolkit_demo\")\n",
    "\n",
    "\n",
    "def timed(fn):\n",
    "    def wrapper(*args, **kwargs):\n",
    "        start = time.time()\n",
    "        result = fn(*args, **kwargs)\n",
    "        print(f\"[{fn.__name__} took {time.time() - start:.2f}s]\")\n",
    "        return result\n",
    "    return wrapper\n",
    "\n",
    "\n",
    "class Repo:                                             # was Quote\n",
    "    \"\"\"One GitHub repository.\"\"\"\n",
    "\n",
    "    def __init__(self, name: str, stars: int):\n",
    "        self.name = name\n",
    "        self.stars = stars\n",
    "\n",
    "    def line(self) -> str:\n",
    "        return f\"{self.name:<20} {self.stars:>7} stars\"\n",
    "\n",
    "\n",
    "@timed\n",
    "def fetch_repos(count: int) -> list[dict]:\n",
    "    r = requests.get(\n",
    "        \"https://api.github.com/users/python/repos\",   # changed\n",
    "        params={\"per_page\": count},                    # changed\n",
    "        timeout=10,\n",
    "    )\n",
    "    r.raise_for_status()\n",
    "    return r.json()\n",
    "\n",
    "\n",
    "def read_repos(path: Path) -> Iterator[Repo]:\n",
    "    data = json.loads(path.read_text(encoding=\"utf-8\"))\n",
    "    for item in data:\n",
    "        yield Repo(item[\"name\"], item[\"stargazers_count\"])   # changed\n",
    "\n",
    "\n",
    "def main() -> None:\n",
    "    CACHE_DIR.mkdir(exist_ok=True)\n",
    "    cache_file = CACHE_DIR / \"repos_cache.json\"\n",
    "\n",
    "    if not cache_file.exists():\n",
    "        print(\"No cache. Fetching...\")\n",
    "        cache_file.write_text(\n",
    "            json.dumps(fetch_repos(5), indent=2, ensure_ascii=False),\n",
    "            encoding=\"utf-8\",\n",
    "        )\n",
    "    else:\n",
    "        print(f\"Reading cache: {cache_file}\")\n",
    "\n",
    "    for repo in read_repos(cache_file):\n",
    "        print(\"  \" + repo.line())\n",
    "\n",
    "\n",
    "main()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "That is the toolkit. Go back to **[python_toolkit.html](python_toolkit.html)** for the\n",
    "diagrams and the 中文 version, or keep going with `python_apis_async.ipynb` for\n",
    "`async` / `await`."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
