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