{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Validate attributes on assignment\n",
    "\n",
    "**The question.** You want to stop an invalid value getting onto an instance — not just at construction, but every time somebody writes to the attribute. The classic example is a `Rectangle` whose `width` should always be positive; you want `r.width = -1` to fail, not quietly corrupt the state.\n",
    "\n",
    "The three tools for this are `@property` (single-field, every assignment), `__post_init__` (dataclass-only, once at construction), and `__setattr__` (cross-field invariants). For the common case — \"check this single field on every write\" — `@property` is the answer."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Rectangle:\n",
    "    def __init__(self, width, height):\n",
    "        self.width = width     # triggers the setter below\n",
    "        self.height = height   # triggers the setter below\n",
    "\n",
    "    @property\n",
    "    def width(self):\n",
    "        return self._width\n",
    "\n",
    "    @width.setter\n",
    "    def width(self, value):\n",
    "        if value <= 0:\n",
    "            raise ValueError('width must be positive')\n",
    "        self._width = value\n",
    "\n",
    "    @property\n",
    "    def height(self):\n",
    "        return self._height\n",
    "\n",
    "    @height.setter\n",
    "    def height(self, value):\n",
    "        if value <= 0:\n",
    "            raise ValueError('height must be positive')\n",
    "        self._height = value\n",
    "\n",
    "    def __repr__(self):\n",
    "        return f'Rectangle({self._width}, {self._height})'\n",
    "\n",
    "\n",
    "r = Rectangle(3, 4)\n",
    "print(r)\n",
    "\n",
    "try:\n",
    "    r.width = -1                 # setter raises — state stays valid\n",
    "except ValueError as e:\n",
    "    print(f'{type(e).__name__}: {e}')\n",
    "\n",
    "try:\n",
    "    Rectangle(-1, 4)             # __init__ assignment also runs the setter\n",
    "except ValueError as e:\n",
    "    print(f'{type(e).__name__}: {e}')\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Variant: one-time validation with @dataclass __post_init__\n",
    "from dataclasses import dataclass\n",
    "\n",
    "@dataclass\n",
    "class RectangleDC:\n",
    "    width: float\n",
    "    height: float\n",
    "\n",
    "    def __post_init__(self):\n",
    "        if self.width <= 0 or self.height <= 0:\n",
    "            raise ValueError('sides must be positive')\n",
    "\n",
    "print(RectangleDC(3, 4))\n",
    "try:\n",
    "    RectangleDC(-1, 4)\n",
    "except ValueError as e:\n",
    "    print(f'{type(e).__name__}: {e}')\n",
    "\n",
    "# Note: this guards construction, not later assignment.\n",
    "# r = RectangleDC(3, 4); r.width = -1   # won't be caught\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Variant: cross-field invariants via __setattr__\n",
    "class DateRange:\n",
    "    def __init__(self, start, end):\n",
    "        self.start = start\n",
    "        self.end = end\n",
    "\n",
    "    def __setattr__(self, name, value):\n",
    "        if name in ('start', 'end'):\n",
    "            other = 'end' if name == 'start' else 'start'\n",
    "            other_value = getattr(self, other, None)\n",
    "            if other_value is not None:\n",
    "                if name == 'start' and value > other_value:\n",
    "                    raise ValueError('start cannot be after end')\n",
    "                if name == 'end' and value < other_value:\n",
    "                    raise ValueError('end cannot be before start')\n",
    "        super().__setattr__(name, value)\n",
    "\n",
    "\n",
    "dr = DateRange(1, 5)\n",
    "try:\n",
    "    dr.end = 0\n",
    "except ValueError as e:\n",
    "    print(f'{type(e).__name__}: {e}')\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Why it works\n",
    "\n",
    "`@property` redefines attribute access as method calls. Reading `r.width` runs the getter; writing `r.width = 3` runs the setter. The real value lives under the underscored name `self._width`, which is an ordinary attribute — the setter is the only place that touches it, and the check happens in exactly one spot.\n",
    "\n",
    "Crucially, `__init__` assigning `self.width = width` goes through the setter too, so construction is validated by the same code as later assignment. No duplicated logic between *\"check the inputs\"* and *\"check on every write\"*.\n",
    "\n",
    "`__post_init__` is the dataclass-only shortcut: it runs once, after the generated `__init__`, and is enough when the value type is mostly read-only after construction. `__setattr__` is the heavy-hammer option — it intercepts **every** attribute assignment, which is exactly what you want for cross-field invariants but a sledgehammer for single-field rules."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Trade-offs\n",
    "\n",
    "**Each field gets its own getter/setter pair.** For a class with a dozen validated fields, that's a lot of boilerplate. At that size, `__setattr__` with a dispatch table starts to look cleaner, or — more often — you should be using Pydantic or a `dataclass` with `__post_init__` that re-validates.\n",
    "\n",
    "**`__setattr__` catches everything, including your own writes.** Inside `__setattr__`, `self.x = value` would recurse forever — that's why the pattern ends with `super().__setattr__(name, value)` to actually store. A misplaced `self.x = ...` or a typo that skips the `super()` call turns the class into a silent black hole.\n",
    "\n",
    "**`@property` with no behaviour is noise.** A getter that just returns `self._width` and a setter that just stores the value isn't adding anything — delete both and use a plain attribute. You can always promote to a property later; Python's attribute access is uniform, so callers won't notice.\n",
    "\n",
    "**Consider Pydantic.** `pydantic.BaseModel` declares fields with types and constraints (`x: int = Field(gt=0)`) and handles all of the above out of the box — including type coercion and serialisation. If you're already using Pydantic for API data, use it here too."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Related reading\n",
    "\n",
    "- [Choose between @dataclass, NamedTuple, and a plain class](https://agilearn.co.uk/guides/classes-and-objects/recipes/choose-between-dataclass-namedtuple-class) — because the \"do I even need a plain class here?\" question often comes first.\n",
    "- [Avoid common class mistakes](https://agilearn.co.uk/guides/classes-and-objects/recipes/avoid-common-class-mistakes) — including the \"`@property` wraps nothing\" antipattern.\n",
    "- [Validate function arguments](https://agilearn.co.uk/guides/functions/recipes/validate-function-arguments) — the same check-and-raise pattern, on the function side.\n"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}