#!/usr/bin/env python3
"""Experiment 05 — the data model: descriptors, MRO, attribute interception.

    python3 exp05_datamodel.py
"""

from __future__ import annotations

import functools


def section(title: str) -> None:
    print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")


def claim(text: str) -> None:
    print(f"\nCLAIM: {text}")


# ---------------------------------------------------------------------------
section("1. Attribute lookup order, demonstrated")
# ---------------------------------------------------------------------------

claim("Order is: DATA descriptor on the type -> instance __dict__ -> NON-DATA "
      "descriptor on the type -> class attrs up the MRO -> __getattr__.")


class DataDescriptor:
    """Defines __set__, so it is a DATA descriptor and outranks the instance dict."""

    def __get__(self, obj, objtype=None):
        return "data-descriptor"

    def __set__(self, obj, value):
        obj.__dict__["_stored"] = value


class NonDataDescriptor:
    """Only __get__, so the instance dict outranks it."""

    def __get__(self, obj, objtype=None):
        return "non-data-descriptor"


class Demo:
    data = DataDescriptor()
    nondata = NonDataDescriptor()

    def __getattr__(self, name):
        return f"__getattr__({name})"


demo = Demo()
demo.__dict__["data"] = "instance-dict"
demo.__dict__["nondata"] = "instance-dict"

print(f"  instance dict has both, yet:")
print(f"    demo.data     -> {demo.data!r}   <-- data descriptor WINS")
print(f"    demo.nondata  -> {demo.nondata!r}   <-- instance dict WINS")
print(f"    demo.absent   -> {demo.absent!r}   <-- fallback")


# ---------------------------------------------------------------------------
section("2. Why @property cannot be shadowed but a method can")
# ---------------------------------------------------------------------------

claim("property is a data descriptor. A plain function is a non-data descriptor. "
      "That single fact explains both behaviours.")


class Account:
    def __init__(self):
        self._balance = 100

    @property
    def balance(self):
        return self._balance

    def describe(self):
        return "the real method"


acct = Account()
try:
    acct.balance = 5
except AttributeError as exc:
    print(f"  acct.balance = 5   -> AttributeError: {exc}")

acct.describe = lambda: "monkeypatched"
print(f"  acct.describe = ... -> {acct.describe()!r}   <-- instance dict won")
print(f"  Account.describe(acct) -> {Account.describe(acct)!r}   (class attr still there)")
print("\n  This is also WHY methods work at all: `self` is bound because functions")
print("  are non-data descriptors whose __get__ returns a bound method.")
print(f"  Account.__dict__['describe'].__get__ exists -> "
      f"{hasattr(Account.__dict__['describe'], '__get__')}")


# ---------------------------------------------------------------------------
section("3. __getattr__ vs __getattribute__ — and the recursion bug")
# ---------------------------------------------------------------------------

claim("__getattribute__ runs for EVERY access. __getattr__ runs only when normal "
      "lookup raised AttributeError.")

calls = {"getattribute": 0, "getattr": 0}


class Instrumented:
    def __init__(self):
        self.present = 1

    def __getattribute__(self, name):
        calls["getattribute"] += 1
        return object.__getattribute__(self, name)  # delegate — see below

    def __getattr__(self, name):
        calls["getattr"] += 1
        return f"synthesized:{name}"


inst = Instrumented()
calls.update(getattribute=0, getattr=0)
_ = inst.present
_ = inst.present
_ = inst.missing
print(f"  two hits and one miss -> {calls}")
print("  __getattribute__ fired on all three; __getattr__ only on the miss.")

claim("The classic bug: touching self.__dict__ inside __getattribute__ re-enters it.")


class Recursive:
    def __getattribute__(self, name):
        return self.__dict__[name]  # self.__dict__ calls __getattribute__ again


try:
    Recursive().anything
except RecursionError:
    print("  self.__dict__[name] inside __getattribute__ -> RecursionError")
print("  The fix is to delegate: object.__getattribute__(self, name).")
print("  Prefer __getattr__ for proxies and lazy attributes — it runs only on")
print("  misses, so it costs nothing on the hot path.")


# ---------------------------------------------------------------------------
section("4. MRO and what super() actually resolves to")
# ---------------------------------------------------------------------------

claim("super() does not mean 'the parent class'. It means 'the next class in the "
      "MRO of type(self)' — which depends on the INSTANCE, not the definition site.")

order = []


class Base:
    def go(self):
        order.append("Base")


class Left(Base):
    def go(self):
        order.append("Left")
        super().go()


class Right(Base):
    def go(self):
        order.append("Right")
        super().go()


class Diamond(Left, Right):
    def go(self):
        order.append("Diamond")
        super().go()


print(f"  MRO: {' -> '.join(c.__name__ for c in Diamond.__mro__)}")
Diamond().go()
print(f"  call order: {' -> '.join(order)}")
print("  Left.go's super() reached RIGHT, not Base — because the MRO is computed")
print("  from Diamond. This is C3 linearization, and it is why cooperative")
print("  multiple inheritance requires every class in the chain to call super().")


# ---------------------------------------------------------------------------
section("5. Context managers — __exit__'s return value suppresses")
# ---------------------------------------------------------------------------

claim("Returning a truthy value from __exit__ swallows the exception. "
      "Returning None (the default) lets it propagate.")


class Suppressing:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        print(f"    __exit__ saw {exc_type.__name__ if exc_type else None}")
        return True  # swallow


class Propagating:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return None


with Suppressing():
    raise ValueError("this never escapes")
print("  ... execution continued past the `with` block")

try:
    with Propagating():
        raise ValueError("this escapes")
except ValueError as exc:
    print(f"  Propagating -> ValueError propagated: {exc}")
print("\n  A bare `return True` in __exit__ is how a context manager silently eats")
print("  every bug inside it. Reviewers should treat it as a red flag.")


# ---------------------------------------------------------------------------
section("6. lru_cache — the key is the argument tuple")
# ---------------------------------------------------------------------------

claim("lru_cache keys on the arguments, so unhashable arguments raise, and "
      "positional vs keyword forms are DIFFERENT keys.")


@functools.lru_cache(maxsize=128)
def add(a, b):
    return a + b


add(1, 2)
add(1, 2)
add(1, b=2)
print(f"  add(1,2) x2 then add(1,b=2) -> {add.cache_info()}")
print("  Two misses, one hit: `add(1, b=2)` is a different cache key from `add(1, 2)`.")

try:
    add([1], [2])
except TypeError as exc:
    print(f"  add([1],[2]) -> TypeError: {exc}")
print("\n  And the trap nobody sees coming: lru_cache on a METHOD keys on `self`,")
print("  so the cache holds a strong reference to every instance it has ever seen.")
print("  That is an unbounded leak on a long-lived class. Use a per-instance cache,")
print("  or functools.cached_property, or key on an id you control.")


print("\n" + "=" * 70)
print("Interview form of every claim above lives in ../README.md § B5.")
print("=" * 70)
