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/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)