{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Create custom exceptions\n",
    "\n",
    "**The question.** A function in your project can fail in several distinct ways and you want callers to handle each failure differently — payment declined versus insufficient funds versus invalid order. Raising `ValueError` everywhere doesn't let callers tell them apart.\n",
    "\n",
    "The answer: define a small exception hierarchy. A project-level base exception, with subclasses for each distinct failure mode. Callers catch the specific subclass they care about, or the base class to mop up anything from your module.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# A small hierarchy: one base, three specific failure modes.\n",
    "# Callers can catch any subclass, or the base to handle all order failures.\n",
    "class OrderError(Exception):\n",
    "    '''Base exception for all order-processing failures.'''\n",
    "\n",
    "\n",
    "class OutOfStockError(OrderError):\n",
    "    '''Raised when a requested item isn't in stock in the quantity asked for.'''\n",
    "    def __init__(self, item: str, requested: int, available: int) -> None:\n",
    "        self.item = item\n",
    "        self.requested = requested\n",
    "        self.available = available\n",
    "        super().__init__(\n",
    "            f'{item}: requested {requested}, only {available} available'\n",
    "        )\n",
    "\n",
    "\n",
    "class InvalidOrderError(OrderError):\n",
    "    '''Raised when an order's data is malformed.'''\n",
    "\n",
    "\n",
    "class PaymentDeclinedError(OrderError):\n",
    "    '''Raised when a payment method was rejected.'''\n",
    "    def __init__(self, reason: str) -> None:\n",
    "        self.reason = reason\n",
    "        super().__init__(f'Payment declined: {reason}')\n",
    "\n",
    "\n",
    "def place_order(item: str, quantity: int, stock: int) -> str:\n",
    "    if quantity <= 0:\n",
    "        raise InvalidOrderError(f'quantity must be positive, got {quantity}')\n",
    "    if quantity > stock:\n",
    "        raise OutOfStockError(item, quantity, stock)\n",
    "    return f'Order placed: {quantity}x {item}'\n",
    "\n",
    "\n",
    "# Specific handling — the caller inspects structured attributes on the exception.\n",
    "try:\n",
    "    place_order('Keyboard', 5, 2)\n",
    "except OutOfStockError as exc:\n",
    "    print(f'only {exc.available} of {exc.item} left — wanted {exc.requested}')\n",
    "\n",
    "# Coarse handling — catch the base class for 'anything that could go wrong with orders'.\n",
    "try:\n",
    "    place_order('Webcam', 0, 5)\n",
    "except OrderError as exc:\n",
    "    print(f'order failed: {exc}')\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Variant: a single `__init__` that carries several attributes\n",
    "\n",
    "For exceptions with more than one or two fields, a consistent `__init__` pattern keeps the boilerplate tolerable. You can't cleanly use `@dataclass` on an `Exception` subclass, so this hand-rolled pattern is the usual style.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class HttpError(Exception):\n",
    "    '''Raised when an HTTP request fails.'''\n",
    "\n",
    "    def __init__(\n",
    "        self,\n",
    "        status_code: int,\n",
    "        url: str,\n",
    "        method: str = 'GET',\n",
    "        body: str | None = None,\n",
    "    ) -> None:\n",
    "        self.status_code = status_code\n",
    "        self.url = url\n",
    "        self.method = method\n",
    "        self.body = body\n",
    "        super().__init__(f'{method} {url} returned {status_code}')\n",
    "\n",
    "\n",
    "try:\n",
    "    raise HttpError(404, 'https://api.example.com/users/42')\n",
    "except HttpError as exc:\n",
    "    print(f'Request failed: {exc}')\n",
    "    print(f'Status: {exc.status_code}, URL: {exc.url}')\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Variant: verifying the hierarchy\n",
    "\n",
    "Useful when you're not sure the subclass chain is what you think it is — especially after refactoring.\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Sanity checks — the built-in way to ask 'is this exception caught by that except clause?'\n",
    "print(issubclass(OutOfStockError, OrderError))    # True\n",
    "print(issubclass(OutOfStockError, Exception))     # True\n",
    "print(issubclass(OrderError, OutOfStockError))    # False — parent isn't a child\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Why this works\n",
    "\n",
    "`except OutOfStockError` catches only that specific failure; `except OrderError` catches any subclass of it. Same mechanism Python uses for its own hierarchy — `except OSError` catches `FileNotFoundError`, `PermissionError`, and friends. Callers pick the level of granularity they actually need.\n",
    "\n",
    "Putting structured data on the exception (`item`, `requested`, `available`) means the caller doesn't have to parse the message string to recover. That's the difference between a user-facing error UI that shows 'only 2 left' versus one that shows 'something went wrong'.\n",
    "\n",
    "`super().__init__(readable_message)` is the important bit — it's what makes `str(exc)` sensible and what makes the default repr in a traceback say something useful. Without it you'd get `OutOfStockError()` and no hint about what happened.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Trade-offs\n",
    "\n",
    "Don't design the hierarchy up front. Start with a single base exception plus one or two subclasses; add more as real callers say 'I need to handle X differently from Y'. A deep hierarchy you don't need is just clutter.\n",
    "\n",
    "Keep names ending in `Error` — the standard-library convention. Avoid shadowing built-in names (`ValueError`, `TimeoutError`) even in your own namespace, because import confusion at the top of a file is easy to cause and annoying to debug.\n",
    "\n",
    "For projects with many exceptions, put them all in a single `exceptions.py` (or `errors.py`) at the top of your package. One file to look at, one place to import from. The extra cell shows the hierarchy-style custom `__init__` pattern that lets you carry several attributes without boilerplate.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Related reading\n",
    "\n",
    "- [Handle multiple exceptions](https://agilearn.co.uk/guides/error-handling/recipes/handle-multiple-exceptions) — how callers catch the ones you raise.\n",
    "- [Avoid common error handling mistakes](https://agilearn.co.uk/guides/error-handling/recipes/avoid-common-mistakes) — naming, chaining, silent-swallow traps.\n",
    "- [Exception hierarchy reference](https://agilearn.co.uk/guides/error-handling/reference/exception-hierarchy-reference) — the built-in hierarchy to inherit from (or mirror).\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.12.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}