{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-000",
   "metadata": {},
   "source": [
    "# Lesson 8 (Part A) — Modules, Docstrings & Tests\n",
    "\n",
    "**Companion notebook** for the Lesson 8A slides. In your real project the code lives in\n",
    "`src/foundations/` and tests in `tests/`. Here we build a tiny throwaway package in a\n",
    "temporary folder so you can see every step — including running `pytest` — from inside\n",
    "a notebook."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-001",
   "metadata": {},
   "outputs": [],
   "source": [
    "def check(label, got, expected, tol=1e-6):\n",
    "    \"\"\"Compare your answer with the expected value; prints ✅ or ❌ (never raises).\"\"\"\n",
    "    import math\n",
    "    try:\n",
    "        if isinstance(expected, float) or isinstance(got, float):\n",
    "            ok = math.isclose(float(got), float(expected), rel_tol=tol, abs_tol=tol)\n",
    "        elif isinstance(expected, (list, tuple)) and expected and isinstance(expected[0], str):\n",
    "            ok = list(got) == list(expected)\n",
    "        elif hasattr(expected, \"__len__\") and not isinstance(expected, str):\n",
    "            import numpy as np\n",
    "            ok = np.allclose(np.asarray(got, dtype=float), np.asarray(expected, dtype=float), rtol=tol, atol=tol)\n",
    "        else:\n",
    "            ok = got == expected\n",
    "    except Exception:\n",
    "        ok = False\n",
    "    print((\"✅ \" if ok else \"❌ \") + f\"{label}: got {got!r}, expected {expected!r}\")\n",
    "import subprocess\n",
    "import sys\n",
    "import tempfile\n",
    "import textwrap\n",
    "from pathlib import Path\n",
    "\n",
    "work = Path(tempfile.mkdtemp())\n",
    "pkg = work / \"minifin\"\n",
    "pkg.mkdir()\n",
    "(pkg / \"__init__.py\").write_text(\"\")\n",
    "(work / \"tests\").mkdir()\n",
    "sys.path.insert(0, str(work))\n",
    "print(\"scratch project:\", work)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-002",
   "metadata": {},
   "source": [
    "## 1. A module with Google-style docstrings\n",
    "\n",
    "Writing a file from Python is exactly what your editor does when you save\n",
    "`src/foundations/interest.py`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-003",
   "metadata": {},
   "outputs": [],
   "source": [
    "(pkg / \"interest.py\").write_text(textwrap.dedent('''\n",
    "    \"\"\"Interest-theory building blocks.\"\"\"\n",
    "\n",
    "\n",
    "    def discount_factor(i: float, t: float) -> float:\n",
    "        \"\"\"Present value of 1 due at time t at effective annual rate i.\n",
    "\n",
    "        Args:\n",
    "            i: Effective interest rate per period. Must be greater than -1.\n",
    "            t: Time in periods. May be fractional or negative.\n",
    "\n",
    "        Returns:\n",
    "            The discount factor v**t = (1 + i)**-t.\n",
    "\n",
    "        Raises:\n",
    "            ValueError: If i <= -1.\n",
    "\n",
    "        Examples:\n",
    "            PROMPT round(discount_factor(0.05, 10), 6)\n",
    "            0.613913\n",
    "        \"\"\"\n",
    "        if i <= -1:\n",
    "            raise ValueError(f\"interest rate must be > -1, got {i}\")\n",
    "        return (1 + i) ** -t\n",
    "\n",
    "\n",
    "    def annuity_immediate(n: int, i: float) -> float:\n",
    "        \"\"\"Present value of 1 paid at the end of each of n periods.\"\"\"\n",
    "        if i == 0:\n",
    "            return float(n)\n",
    "        return (1 - discount_factor(i, n)) / i\n",
    "\n",
    "\n",
    "    if __name__ == \"__main__\":\n",
    "        print(annuity_immediate(10, 0.05))\n",
    "''').replace(\"PROMPT\", \">\" * 3))  # IPython would strip a literal doctest prompt from cell input\n",
    "\n",
    "from minifin.interest import annuity_immediate, discount_factor  # noqa: E402\n",
    "\n",
    "help(discount_factor)\n",
    "print(annuity_immediate(10, 0.05))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-004",
   "metadata": {},
   "source": [
    "(In your editor you would simply type the `>>>` prompt in the docstring.)\n",
    "\n",
    "The docstring example is runnable with `doctest`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-005",
   "metadata": {},
   "outputs": [],
   "source": [
    "import doctest\n",
    "\n",
    "import minifin.interest\n",
    "\n",
    "doctest.testmod(minifin.interest, verbose=False)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-006",
   "metadata": {},
   "source": [
    "## 2. Tests with `pytest`\n",
    "\n",
    "A test is a function named `test_*` that asserts something. `pytest.approx` compares\n",
    "floats with a tolerance; `pytest.raises` checks an error is raised; `parametrize` runs\n",
    "one test for many inputs."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-007",
   "metadata": {},
   "outputs": [],
   "source": [
    "(work / \"tests\" / \"test_interest.py\").write_text(textwrap.dedent('''\n",
    "    import pytest\n",
    "\n",
    "    from minifin.interest import annuity_immediate, discount_factor\n",
    "\n",
    "\n",
    "    def test_known_value():\n",
    "        assert discount_factor(0.05, 10) == pytest.approx(0.6139132535)\n",
    "\n",
    "\n",
    "    @pytest.mark.parametrize(\"n\", [0, 1, 10, 30])\n",
    "    def test_zero_interest(n):\n",
    "        assert annuity_immediate(n, 0.0) == n\n",
    "\n",
    "\n",
    "    def test_rejects_bad_rate():\n",
    "        with pytest.raises(ValueError, match=\"must be > -1\"):\n",
    "            discount_factor(-1.5, 3)\n",
    "'''))\n",
    "\n",
    "\n",
    "def run_pytest(*paths):\n",
    "    \"\"\"Run pytest on the scratch project and return (passed, failed, output).\"\"\"\n",
    "    out = subprocess.run([sys.executable, \"-m\", \"pytest\", \"-q\", \"-p\", \"no:cacheprovider\", *paths],\n",
    "                         cwd=work, capture_output=True, text=True, env={\"PYTHONPATH\": str(work)})\n",
    "    text = out.stdout + out.stderr\n",
    "    import re\n",
    "    passed = int((re.search(r\"(\\d+) passed\", text) or [0, 0])[1])\n",
    "    failed = int((re.search(r\"(\\d+) failed\", text) or [0, 0])[1])\n",
    "    return passed, failed, text\n",
    "\n",
    "\n",
    "passed, failed, output = run_pytest(\"tests/test_interest.py\")\n",
    "print(output[-400:])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-008",
   "metadata": {},
   "source": [
    "---\n",
    "## Your turn — test the annuity identity\n",
    "\n",
    "Write **two** tests in the string below:\n",
    "\n",
    "1. `test_annuity_value` — `annuity_immediate(10, 0.05)` is approximately `7.721735`\n",
    "2. `test_due_identity` — for several rates (use `parametrize`), the annuity-due\n",
    "   $(1+i)\\,a_{\\overline{n}|}$ is greater than $a_{\\overline{n}|}$ for positive $i$"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cell-009",
   "metadata": {},
   "outputs": [],
   "source": [
    "my_tests = '''\n",
    "import pytest\n",
    "\n",
    "from minifin.interest import annuity_immediate\n",
    "'''\n",
    "...  # your code here\n",
    "(work / \"tests\" / \"test_mine.py\").write_text(my_tests)\n",
    "passed, failed, output = run_pytest(\"tests/test_mine.py\")\n",
    "print(output[-400:])\n",
    "check(\"your tests pass\", failed == 0 and passed >= 2, True)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
