python-wrap 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_wrap/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
python_wrap/state.py
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""State safety: `revert_on_failure` and transactions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import contextvars
|
|
7
|
+
import copy
|
|
8
|
+
import functools
|
|
9
|
+
import inspect
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any, Final, NoReturn, ParamSpec, TypeVar, cast, overload
|
|
14
|
+
|
|
15
|
+
from python_wrap import _core
|
|
16
|
+
from python_wrap._core import F, LoggerLike
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"INJECTED",
|
|
20
|
+
"Transaction",
|
|
21
|
+
"current_transaction",
|
|
22
|
+
"require_transaction",
|
|
23
|
+
"revert_on_failure",
|
|
24
|
+
"transactional",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
P = ParamSpec("P")
|
|
28
|
+
R = TypeVar("R")
|
|
29
|
+
|
|
30
|
+
_NO_VALUE: Any = object()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ------------------------------------------------------------------ revert_on_failure
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _copy_value(value: Any, deep: bool) -> Any:
|
|
37
|
+
try:
|
|
38
|
+
return copy.deepcopy(value) if deep else copy.copy(value)
|
|
39
|
+
except Exception:
|
|
40
|
+
return value # not copyable (locks, sockets...): restored by reference
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Snapshot:
|
|
44
|
+
def __init__(self, obj: Any, attrs: tuple[str, ...] | None, deep: bool) -> None:
|
|
45
|
+
self.obj = obj
|
|
46
|
+
self.attrs = attrs
|
|
47
|
+
self.values: dict[str, Any] = {}
|
|
48
|
+
if attrs is None:
|
|
49
|
+
try:
|
|
50
|
+
current = vars(obj)
|
|
51
|
+
except TypeError:
|
|
52
|
+
raise TypeError(
|
|
53
|
+
f"revert_on_failure: {type(obj).__name__!r} has no __dict__ "
|
|
54
|
+
"(it uses __slots__?); pass attrs=[...] to name the attributes"
|
|
55
|
+
) from None
|
|
56
|
+
names: Iterable[str] = [n for n in current if self._wanted(n)]
|
|
57
|
+
else:
|
|
58
|
+
names = attrs
|
|
59
|
+
for name in names:
|
|
60
|
+
try:
|
|
61
|
+
# Classes: take the raw entry so classmethods/properties stay descriptors.
|
|
62
|
+
value = (
|
|
63
|
+
vars(obj)[name]
|
|
64
|
+
if isinstance(obj, type) and name in vars(obj)
|
|
65
|
+
else getattr(obj, name)
|
|
66
|
+
)
|
|
67
|
+
except AttributeError:
|
|
68
|
+
self.values[name] = _NO_VALUE
|
|
69
|
+
else:
|
|
70
|
+
self.values[name] = _copy_value(value, deep)
|
|
71
|
+
|
|
72
|
+
def _wanted(self, name: str) -> bool:
|
|
73
|
+
# Classes: leave dunders (``__dict__``, ``__weakref__``, ``__module__``...) alone.
|
|
74
|
+
return not (isinstance(self.obj, type) and name.startswith("__") and name.endswith("__"))
|
|
75
|
+
|
|
76
|
+
def restore(self) -> None:
|
|
77
|
+
"""Put the attributes back; never raises (failures are logged)."""
|
|
78
|
+
obj = self.obj
|
|
79
|
+
log = _core.get_logger(None)
|
|
80
|
+
if self.attrs is None:
|
|
81
|
+
try:
|
|
82
|
+
extra = [n for n in vars(obj) if n not in self.values and self._wanted(n)]
|
|
83
|
+
except Exception:
|
|
84
|
+
extra = []
|
|
85
|
+
for name in extra:
|
|
86
|
+
try:
|
|
87
|
+
delattr(obj, name)
|
|
88
|
+
except Exception:
|
|
89
|
+
log.warning("revert_on_failure: could not delete %r", name, exc_info=True)
|
|
90
|
+
for name, value in self.values.items():
|
|
91
|
+
try:
|
|
92
|
+
if value is _NO_VALUE:
|
|
93
|
+
with contextlib.suppress(AttributeError):
|
|
94
|
+
delattr(obj, name)
|
|
95
|
+
else:
|
|
96
|
+
setattr(obj, name, value)
|
|
97
|
+
except Exception:
|
|
98
|
+
log.warning("revert_on_failure: could not restore %r", name, exc_info=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _target_resolver(
|
|
102
|
+
func: Callable[..., Any], target: str | int
|
|
103
|
+
) -> Callable[[tuple[Any, ...], dict[str, Any]], Any]:
|
|
104
|
+
sig = inspect.signature(func)
|
|
105
|
+
if isinstance(target, str):
|
|
106
|
+
if target not in sig.parameters:
|
|
107
|
+
raise ValueError(f"revert_on_failure: {func!r} has no parameter {target!r}")
|
|
108
|
+
|
|
109
|
+
def by_name(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
110
|
+
return sig.bind(*args, **kwargs).arguments[target]
|
|
111
|
+
|
|
112
|
+
return by_name
|
|
113
|
+
|
|
114
|
+
def by_index(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
115
|
+
return args[target]
|
|
116
|
+
|
|
117
|
+
return by_index
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@overload
|
|
121
|
+
def revert_on_failure(func: F, /) -> F: ...
|
|
122
|
+
@overload
|
|
123
|
+
def revert_on_failure(
|
|
124
|
+
func: None = None,
|
|
125
|
+
/,
|
|
126
|
+
*,
|
|
127
|
+
target: str | int = 0,
|
|
128
|
+
attrs: Iterable[str] | None = None,
|
|
129
|
+
deep: bool = False,
|
|
130
|
+
) -> Callable[[F], F]: ...
|
|
131
|
+
def revert_on_failure(
|
|
132
|
+
func: Callable[..., Any] | None = None,
|
|
133
|
+
/,
|
|
134
|
+
*,
|
|
135
|
+
target: str | int = 0,
|
|
136
|
+
attrs: Iterable[str] | None = None,
|
|
137
|
+
deep: bool = False,
|
|
138
|
+
) -> Any:
|
|
139
|
+
"""Restore an object's attributes if the function raises.
|
|
140
|
+
|
|
141
|
+
Before the call, the attributes of the argument ``target`` (a positional index or a
|
|
142
|
+
parameter name; the first argument, normally ``self``, by default) are snapshotted:
|
|
143
|
+
all of ``vars(obj)``, or only ``attrs`` (required for ``__slots__`` classes). If the
|
|
144
|
+
function raises, every snapshotted attribute is set back, attributes created during
|
|
145
|
+
the call are deleted (when snapshotting all of ``vars``; with ``attrs`` an attribute
|
|
146
|
+
that did not exist before is deleted), and the exception is re-raised.
|
|
147
|
+
|
|
148
|
+
Copy semantics: each value is copied with ``copy.copy`` (``copy.deepcopy`` with
|
|
149
|
+
``deep=True``) and the *copy* is what gets restored. Therefore in-place mutation of
|
|
150
|
+
a container attribute (``self.items.append(x)``) IS undone, but the attribute ends
|
|
151
|
+
up referring to a new list object, so other references to the old list still see
|
|
152
|
+
the mutation. A shallow copy does not protect the elements: mutating an object
|
|
153
|
+
nested inside the list (``self.items[0].count += 1``) is only undone with
|
|
154
|
+
``deep=True``. Values that cannot be copied are restored by reference.
|
|
155
|
+
|
|
156
|
+
v0.1 reported to revert but its snapshot was always empty, so nothing was restored.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
func: The function (when used bare).
|
|
160
|
+
target: Index or name of the argument whose attributes are protected.
|
|
161
|
+
attrs: Restrict the snapshot to these attribute names.
|
|
162
|
+
deep: Use ``copy.deepcopy`` for the snapshot.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The wrapped function, or a decorator when called with options.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
ValueError: If ``target`` names a parameter the function does not have.
|
|
169
|
+
TypeError: At call time, if no ``attrs`` are given and the object has no
|
|
170
|
+
``__dict__``.
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
>>> class Account:
|
|
174
|
+
... def __init__(self):
|
|
175
|
+
... self.balance = 10
|
|
176
|
+
... @revert_on_failure
|
|
177
|
+
... def withdraw(self, n):
|
|
178
|
+
... self.balance -= n
|
|
179
|
+
... if self.balance < 0:
|
|
180
|
+
... raise ValueError("overdrawn")
|
|
181
|
+
"""
|
|
182
|
+
attr_names = None if attrs is None else tuple(attrs)
|
|
183
|
+
|
|
184
|
+
def decorate(f: Callable[..., Any]) -> Any:
|
|
185
|
+
resolve = _target_resolver(f, target)
|
|
186
|
+
|
|
187
|
+
def begin(args: tuple[Any, ...], kwargs: dict[str, Any]) -> _Snapshot | None:
|
|
188
|
+
try:
|
|
189
|
+
obj = resolve(args, kwargs)
|
|
190
|
+
except (IndexError, KeyError, TypeError):
|
|
191
|
+
return None # bad call: let the function raise its own TypeError
|
|
192
|
+
return _Snapshot(obj, attr_names, deep)
|
|
193
|
+
|
|
194
|
+
if _core.is_async(f):
|
|
195
|
+
|
|
196
|
+
@functools.wraps(f)
|
|
197
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
198
|
+
snap = begin(args, kwargs)
|
|
199
|
+
try:
|
|
200
|
+
return await f(*args, **kwargs)
|
|
201
|
+
except BaseException:
|
|
202
|
+
if snap is not None:
|
|
203
|
+
snap.restore()
|
|
204
|
+
raise
|
|
205
|
+
|
|
206
|
+
return awrapper
|
|
207
|
+
|
|
208
|
+
@functools.wraps(f)
|
|
209
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
210
|
+
snap = begin(args, kwargs)
|
|
211
|
+
try:
|
|
212
|
+
return f(*args, **kwargs)
|
|
213
|
+
except BaseException:
|
|
214
|
+
if snap is not None:
|
|
215
|
+
snap.restore()
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
return wrapper
|
|
219
|
+
|
|
220
|
+
return decorate if func is None else decorate(func)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------- transactions
|
|
224
|
+
|
|
225
|
+
_current: contextvars.ContextVar[Transaction | None] = contextvars.ContextVar(
|
|
226
|
+
"python_wrap_current_transaction", default=None
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def current_transaction() -> Transaction | None:
|
|
231
|
+
"""Return the transaction of the innermost running ``@transactional`` call, if any."""
|
|
232
|
+
return _current.get()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def require_transaction() -> Transaction:
|
|
236
|
+
"""Return the current transaction, raising if there is none.
|
|
237
|
+
|
|
238
|
+
Like `current_transaction` but typed as `Transaction`, so there is no
|
|
239
|
+
``assert tx is not None`` boilerplate.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The transaction of the innermost running ``@transactional`` call.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
RuntimeError: If called outside a ``@transactional`` function.
|
|
246
|
+
|
|
247
|
+
Example:
|
|
248
|
+
>>> @transactional
|
|
249
|
+
... def charge():
|
|
250
|
+
... require_transaction().on_rollback(print, "undo")
|
|
251
|
+
"""
|
|
252
|
+
tx = _current.get()
|
|
253
|
+
if tx is None:
|
|
254
|
+
raise RuntimeError("no active transaction: call this from a @transactional function")
|
|
255
|
+
return tx
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class Transaction:
|
|
259
|
+
"""Collects commit and rollback callbacks and runs the right set once.
|
|
260
|
+
|
|
261
|
+
Use as a context manager (``with`` or ``async with``). On exception the rollback
|
|
262
|
+
callbacks run in reverse registration order; a failing one is logged and the rest
|
|
263
|
+
still run; then the original exception propagates. On success the commit callbacks
|
|
264
|
+
run in order (an exception from one propagates and skips the remaining ones).
|
|
265
|
+
A transaction completes only once: a second ``commit()``/``rollback()`` raises
|
|
266
|
+
``RuntimeError``. Callbacks that are coroutine functions need ``async with`` (or
|
|
267
|
+
``await tx.acommit()`` / ``await tx.arollback()``).
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
logger: Logger (or name) used to report failing rollback callbacks.
|
|
271
|
+
|
|
272
|
+
Example:
|
|
273
|
+
>>> with Transaction() as tx:
|
|
274
|
+
... tx.on_rollback(print, "undo")
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __reduce__(self) -> NoReturn:
|
|
278
|
+
_core.refuse_pickle(self, "Open the transaction inside the process that runs the work.")
|
|
279
|
+
|
|
280
|
+
def __init__(self, logger: LoggerLike = None) -> None:
|
|
281
|
+
self._logger = _core.get_logger(logger)
|
|
282
|
+
self._commit: list[tuple[Callable[..., Any], tuple[Any, ...], dict[str, Any]]] = []
|
|
283
|
+
self._rollback: list[tuple[Callable[..., Any], tuple[Any, ...], dict[str, Any]]] = []
|
|
284
|
+
self._state = "pending"
|
|
285
|
+
self._state_lock = threading.Lock()
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def state(self) -> str:
|
|
289
|
+
"""``"pending"``, ``"committed"`` or ``"rolled_back"``."""
|
|
290
|
+
return self._state
|
|
291
|
+
|
|
292
|
+
def _check_open(self) -> None:
|
|
293
|
+
if self._state != "pending":
|
|
294
|
+
raise RuntimeError(f"transaction already {self._state}")
|
|
295
|
+
|
|
296
|
+
def _complete(self, state: str) -> None:
|
|
297
|
+
"""Atomically move from pending to ``state``; only one caller can succeed."""
|
|
298
|
+
with self._state_lock:
|
|
299
|
+
self._check_open()
|
|
300
|
+
self._state = state
|
|
301
|
+
|
|
302
|
+
def on_rollback(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
|
|
303
|
+
"""Register ``fn(*args, **kwargs)`` to run if the transaction rolls back."""
|
|
304
|
+
self._check_open()
|
|
305
|
+
self._rollback.append((fn, args, kwargs))
|
|
306
|
+
|
|
307
|
+
def on_commit(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
|
|
308
|
+
"""Register ``fn(*args, **kwargs)`` to run if the transaction commits."""
|
|
309
|
+
self._check_open()
|
|
310
|
+
self._commit.append((fn, args, kwargs))
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _call_sync(fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
314
|
+
result = fn(*args, **kwargs)
|
|
315
|
+
if inspect.isawaitable(result):
|
|
316
|
+
if inspect.iscoroutine(result):
|
|
317
|
+
result.close()
|
|
318
|
+
raise TypeError(
|
|
319
|
+
f"transaction callback {_core.qualname(fn)} is async; "
|
|
320
|
+
"use 'async with' or await acommit()/arollback()"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
def commit(self) -> None:
|
|
324
|
+
"""Mark committed and run the commit callbacks in order."""
|
|
325
|
+
self._complete("committed")
|
|
326
|
+
for fn, args, kwargs in self._commit:
|
|
327
|
+
self._call_sync(fn, args, kwargs)
|
|
328
|
+
|
|
329
|
+
def rollback(self) -> None:
|
|
330
|
+
"""Mark rolled back and run the rollback callbacks in reverse order."""
|
|
331
|
+
self._complete("rolled_back")
|
|
332
|
+
for fn, args, kwargs in reversed(self._rollback):
|
|
333
|
+
try:
|
|
334
|
+
self._call_sync(fn, args, kwargs)
|
|
335
|
+
except Exception:
|
|
336
|
+
self._logger.error("rollback callback %s failed", _core.qualname(fn), exc_info=True)
|
|
337
|
+
|
|
338
|
+
async def acommit(self) -> None:
|
|
339
|
+
"""Like `commit`, awaiting async callbacks."""
|
|
340
|
+
self._complete("committed")
|
|
341
|
+
for fn, args, kwargs in self._commit:
|
|
342
|
+
result = fn(*args, **kwargs)
|
|
343
|
+
if inspect.isawaitable(result):
|
|
344
|
+
await result
|
|
345
|
+
|
|
346
|
+
async def arollback(self) -> None:
|
|
347
|
+
"""Like `rollback`, awaiting async callbacks."""
|
|
348
|
+
self._complete("rolled_back")
|
|
349
|
+
for fn, args, kwargs in reversed(self._rollback):
|
|
350
|
+
try:
|
|
351
|
+
result = fn(*args, **kwargs)
|
|
352
|
+
if inspect.isawaitable(result):
|
|
353
|
+
await result
|
|
354
|
+
except Exception:
|
|
355
|
+
self._logger.error("rollback callback %s failed", _core.qualname(fn), exc_info=True)
|
|
356
|
+
|
|
357
|
+
def __enter__(self) -> Transaction:
|
|
358
|
+
return self
|
|
359
|
+
|
|
360
|
+
def __exit__(
|
|
361
|
+
self,
|
|
362
|
+
exc_type: type[BaseException] | None,
|
|
363
|
+
exc: BaseException | None,
|
|
364
|
+
tb: TracebackType | None,
|
|
365
|
+
) -> None:
|
|
366
|
+
if self._state != "pending":
|
|
367
|
+
return
|
|
368
|
+
if exc_type is None:
|
|
369
|
+
self.commit()
|
|
370
|
+
else:
|
|
371
|
+
self.rollback()
|
|
372
|
+
|
|
373
|
+
async def __aenter__(self) -> Transaction:
|
|
374
|
+
return self
|
|
375
|
+
|
|
376
|
+
async def __aexit__(
|
|
377
|
+
self,
|
|
378
|
+
exc_type: type[BaseException] | None,
|
|
379
|
+
exc: BaseException | None,
|
|
380
|
+
tb: TracebackType | None,
|
|
381
|
+
) -> None:
|
|
382
|
+
if self._state != "pending":
|
|
383
|
+
return
|
|
384
|
+
if exc_type is None:
|
|
385
|
+
await self.acommit()
|
|
386
|
+
else:
|
|
387
|
+
await self.arollback()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class _Injected:
|
|
391
|
+
__slots__ = ()
|
|
392
|
+
|
|
393
|
+
def __repr__(self) -> str:
|
|
394
|
+
return "INJECTED"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
INJECTED: Final[Transaction] = cast(Transaction, _Injected())
|
|
398
|
+
"""Typed placeholder default for a ``tx`` parameter that `transactional` fills in.
|
|
399
|
+
|
|
400
|
+
Write ``def charge(amount: int, *, tx: Transaction = INJECTED) -> None`` and callers
|
|
401
|
+
type-check without passing ``tx``. Never use the value itself."""
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@overload
|
|
405
|
+
def transactional(func: F, /) -> F: ...
|
|
406
|
+
@overload
|
|
407
|
+
def transactional(func: None = None, /, *, logger: LoggerLike = None) -> Callable[[F], F]: ...
|
|
408
|
+
def transactional(func: Callable[..., Any] | None = None, /, *, logger: LoggerLike = None) -> Any:
|
|
409
|
+
"""Run the function inside a fresh `Transaction` per call.
|
|
410
|
+
|
|
411
|
+
If the function has a parameter named ``tx`` it receives the transaction as the
|
|
412
|
+
keyword argument ``tx=``. Declare it as ``tx: Transaction = INJECTED`` (keyword-only is
|
|
413
|
+
cleanest) so callers type-check without passing it; passing ``tx`` yourself raises
|
|
414
|
+
``TypeError``. Otherwise use `require_transaction` / `current_transaction` (a
|
|
415
|
+
``ContextVar``, correct across threads and asyncio tasks). Returning normally
|
|
416
|
+
commits, raising rolls back and re-raises. Nested ``@transactional`` calls each get
|
|
417
|
+
their own transaction. Works for sync and ``async def`` functions (async callbacks
|
|
418
|
+
are awaited).
|
|
419
|
+
|
|
420
|
+
v0.1's ``transactional`` was a no-op.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
func: The function (when used bare).
|
|
424
|
+
logger: Logger (or name) for failing rollback callbacks.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
The wrapped function, or a decorator when called with options.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
TypeError: At decoration time, if ``tx`` is positional-only, ``*tx`` or ``**tx``
|
|
431
|
+
(it is injected by keyword); at call time, if the caller passes ``tx``.
|
|
432
|
+
|
|
433
|
+
Example:
|
|
434
|
+
>>> @transactional
|
|
435
|
+
... def transfer(tx: Transaction = INJECTED):
|
|
436
|
+
... tx.on_rollback(print, "undone")
|
|
437
|
+
"""
|
|
438
|
+
|
|
439
|
+
def decorate(f: Callable[..., Any]) -> Any:
|
|
440
|
+
tx_pos: int | None = None
|
|
441
|
+
try:
|
|
442
|
+
params = inspect.signature(f).parameters
|
|
443
|
+
except (TypeError, ValueError):
|
|
444
|
+
params = None
|
|
445
|
+
wants_tx = params is not None and "tx" in params
|
|
446
|
+
if params is not None and wants_tx:
|
|
447
|
+
kind = params["tx"].kind
|
|
448
|
+
if kind is not kind.POSITIONAL_OR_KEYWORD and kind is not kind.KEYWORD_ONLY:
|
|
449
|
+
raise TypeError(
|
|
450
|
+
f"@transactional cannot inject 'tx' into {_core.qualname(f)}(): it is "
|
|
451
|
+
f"{kind.description}; declare it as a normal or keyword-only parameter "
|
|
452
|
+
"(tx: Transaction = INJECTED)"
|
|
453
|
+
)
|
|
454
|
+
if kind is kind.POSITIONAL_OR_KEYWORD:
|
|
455
|
+
tx_pos = list(params).index("tx")
|
|
456
|
+
|
|
457
|
+
def check_not_passed(args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
|
|
458
|
+
if ("tx" in kwargs and kwargs["tx"] is not INJECTED) or (
|
|
459
|
+
tx_pos is not None and len(args) > tx_pos and args[tx_pos] is not INJECTED
|
|
460
|
+
):
|
|
461
|
+
raise TypeError(
|
|
462
|
+
f"{_core.qualname(f)}() received 'tx' from the caller, but @transactional "
|
|
463
|
+
"supplies it; do not pass tx"
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
if _core.is_async(f):
|
|
467
|
+
|
|
468
|
+
@functools.wraps(f)
|
|
469
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
470
|
+
if wants_tx:
|
|
471
|
+
check_not_passed(args, kwargs)
|
|
472
|
+
tx = Transaction(logger)
|
|
473
|
+
if wants_tx:
|
|
474
|
+
kwargs["tx"] = tx
|
|
475
|
+
token = _current.set(tx)
|
|
476
|
+
try:
|
|
477
|
+
async with tx:
|
|
478
|
+
return await f(*args, **kwargs)
|
|
479
|
+
finally:
|
|
480
|
+
_current.reset(token)
|
|
481
|
+
|
|
482
|
+
return awrapper
|
|
483
|
+
|
|
484
|
+
@functools.wraps(f)
|
|
485
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
486
|
+
if wants_tx:
|
|
487
|
+
check_not_passed(args, kwargs)
|
|
488
|
+
tx = Transaction(logger)
|
|
489
|
+
if wants_tx:
|
|
490
|
+
kwargs["tx"] = tx
|
|
491
|
+
token = _current.set(tx)
|
|
492
|
+
try:
|
|
493
|
+
with tx:
|
|
494
|
+
return f(*args, **kwargs)
|
|
495
|
+
finally:
|
|
496
|
+
_current.reset(token)
|
|
497
|
+
|
|
498
|
+
return wrapper
|
|
499
|
+
|
|
500
|
+
return decorate if func is None else decorate(func)
|