v0.166.0
  1"""
  2Context managers for temporarily changing runtime state in tests.
  3
  4Scope is visible as indentation — state changes enter through `with` blocks,
  5never through injection.
  6"""
  7
  8from collections.abc import Generator, Mapping, MutableMapping
  9from contextlib import contextmanager
 10from typing import Any
 11
 12from .exceptions import require_app
 13
 14__all__ = ["override_settings", "patch"]
 15
 16_MISSING = object()
 17
 18
 19@contextmanager
 20def override_settings(**overrides: Any) -> Generator[Any]:
 21    """
 22    Set Plain settings for the duration of the block, restoring the
 23    originals on exit.
 24
 25        with override_settings(DEBUG=True):
 26            ...
 27    """
 28    from plain.runtime import settings
 29
 30    require_app("override_settings")
 31
 32    # Snapshot every original value before applying anything, so an unknown
 33    # setting name raises without leaving earlier overrides applied. The
 34    # apply loop runs inside the try so a rejected value (settings are
 35    # type-checked on assignment) still restores whatever was applied.
 36    original = {name: getattr(settings, name) for name in overrides}
 37    try:
 38        for name, value in overrides.items():
 39            setattr(settings, name, value)
 40        yield settings
 41    finally:
 42        for name, value in original.items():
 43            setattr(settings, name, value)
 44
 45
 46@contextmanager
 47def patch(target: Any, name: str, value: Any) -> Generator[None]:
 48    """
 49    Replace an attribute (or a mapping key, e.g. os.environ) for the
 50    duration of the block.
 51
 52        with patch(billing, "charge_card", fake_charge):
 53            checkout(cart)
 54
 55        with patch(os.environ, "PLAIN_DEBUG", "true"):
 56            ...
 57
 58    When the block ends, the target holds what it held before. For an
 59    attribute, what a target holds is what is in its own `__dict__`, which
 60    isn't always what reading the attribute finds:
 61
 62    - A class that only inherits the attribute holds nothing, so the patch is
 63      deleted and the class inherits again. So does an instance whose method
 64      was patched.
 65    - A class holds a `staticmethod` or `classmethod` object, so that is what
 66      goes back, not the function that reading it returns.
 67
 68    Two kinds of target keep their values somewhere else, and get back the
 69    value that was read before the block:
 70
 71    - A mapping. Its keys are patched, and a key that wasn't there is removed.
 72    - An attribute the target doesn't store in its `__dict__`: a property or
 73      a slot, or any attribute of an object that handles setting itself, as
 74      `plain.runtime.settings` does.
 75    """
 76    if isinstance(target, MutableMapping):
 77        original = target.get(name, _MISSING)
 78        target[name] = value
 79        try:
 80            yield
 81        finally:
 82            if original is _MISSING:
 83                target.pop(name, None)
 84            else:
 85                target[name] = original
 86        return
 87
 88    # Raises AttributeError for a name the target doesn't have.
 89    read_before = getattr(target, name)
 90    held_before = _held_by(target).get(name, _MISSING)
 91
 92    setattr(target, name, value)
 93    # Where that went says where the original has to go back to.
 94    stored_by_the_target = name in _held_by(
 95        target
 96    ) and not _is_set_through_a_descriptor(target, name)
 97
 98    try:
 99        yield
100    finally:
101        if not stored_by_the_target:
102            setattr(target, name, read_before)
103        elif held_before is _MISSING:
104            delattr(target, name)
105        else:
106            setattr(target, name, held_before)
107
108
109def _held_by(target: Any) -> Mapping[str, Any]:
110    """What a target holds itself: its `__dict__`, or nothing if it has none."""
111    try:
112        return vars(target)
113    except TypeError:
114        # No `__dict__`: an instance of a class that declares `__slots__`.
115        return {}
116
117
118def _is_set_through_a_descriptor(target: Any, name: str) -> bool:
119    """
120    Whether setting this attribute is taken over by the target's type: a
121    property, a slot, or anything else that defines `__set__`.
122    """
123    for klass in type(target).__mro__:
124        if name in vars(klass):
125            return hasattr(type(vars(klass)[name]), "__set__")
126    return False