pythonboot 0.1.0__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.
@@ -0,0 +1,241 @@
1
+ """Application-scoped task executors with Spring-style pool admission."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ from abc import ABC, abstractmethod
7
+ from collections import deque
8
+ from collections.abc import Callable
9
+ from concurrent.futures import Future
10
+ from contextvars import Context
11
+ from enum import StrEnum
12
+ from functools import partial
13
+ from threading import Condition, Thread, current_thread
14
+ from time import monotonic
15
+ from typing import Any, ParamSpec, TypeVar
16
+
17
+ P = ParamSpec("P")
18
+ T = TypeVar("T")
19
+ TaskDecorator = Callable[[Callable[[], Any]], Callable[[], Any]]
20
+ _logger = logging.getLogger(__name__)
21
+
22
+
23
+ class TaskRejectedError(RuntimeError):
24
+ """The executor is shut down or its pool and queue are saturated."""
25
+
26
+
27
+ class RejectionPolicy(StrEnum):
28
+ ABORT = "abort"
29
+ CALLER_RUNS = "caller_runs"
30
+ DISCARD = "discard"
31
+ DISCARD_OLDEST = "discard_oldest"
32
+
33
+
34
+ class TaskExecutor(ABC):
35
+ """Submit synchronous work for execution outside the calling thread."""
36
+
37
+ @abstractmethod
38
+ def submit(self, task: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> Future[T]:
39
+ """Return a future containing the task result or exception."""
40
+
41
+ def execute(self, task: Callable[P, Any], /, *args: P.args, **kwargs: P.kwargs) -> None:
42
+ """Submit fire-and-forget work and log unhandled task failures."""
43
+ future = self.submit(task, *args, **kwargs)
44
+
45
+ def report(completed: Future[Any]) -> None:
46
+ if not completed.cancelled() and (error := completed.exception()) is not None:
47
+ _logger.error("Task execution failed", exc_info=(type(error), error, error.__traceback__))
48
+
49
+ future.add_done_callback(report)
50
+
51
+ async def asubmit(self, task: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> T:
52
+ """Await synchronous work without blocking the event loop."""
53
+ return await asyncio.wrap_future(self.submit(task, *args, **kwargs))
54
+
55
+
56
+ class ThreadPoolTaskExecutor(TaskExecutor):
57
+ """Grow core workers, enqueue work, then grow to max workers before rejecting.
58
+
59
+ A queue capacity of None is unbounded; zero allows direct handoff only.
60
+ Threads are created on demand. Extra workers expire after keep_alive_seconds.
61
+ Running tasks cannot be forcibly interrupted during cancellation or shutdown.
62
+ """
63
+
64
+ def __init__(
65
+ self, *, core_pool_size: int = 8, max_pool_size: int = 32,
66
+ queue_capacity: int | None = 256, keep_alive_seconds: float = 60,
67
+ thread_name_prefix: str = "pythonboot-task-",
68
+ allow_core_thread_timeout: bool = False,
69
+ rejection_policy: RejectionPolicy | str = RejectionPolicy.ABORT,
70
+ wait_for_tasks_to_complete_on_shutdown: bool = True,
71
+ await_termination_seconds: float | None = None,
72
+ task_decorator: TaskDecorator | None = None,
73
+ ) -> None:
74
+ for name, value in (("core_pool_size", core_pool_size), ("max_pool_size", max_pool_size)):
75
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
76
+ raise ValueError(f"{name} must be a positive integer")
77
+ if max_pool_size < core_pool_size:
78
+ raise ValueError("max_pool_size must be at least core_pool_size")
79
+ if queue_capacity is not None and (
80
+ isinstance(queue_capacity, bool) or not isinstance(queue_capacity, int) or queue_capacity < 0
81
+ ):
82
+ raise ValueError("queue_capacity must be a non-negative integer or None")
83
+ if not 0 < keep_alive_seconds < float("inf"):
84
+ raise ValueError("keep_alive_seconds must be positive and finite")
85
+ if await_termination_seconds is not None and not 0 <= await_termination_seconds < float("inf"):
86
+ raise ValueError("await_termination_seconds must be non-negative and finite or None")
87
+ if not isinstance(thread_name_prefix, str) or not thread_name_prefix:
88
+ raise ValueError("thread_name_prefix must be a non-empty string")
89
+ if task_decorator is not None and not callable(task_decorator):
90
+ raise TypeError("task_decorator must be callable")
91
+ self.core_pool_size = core_pool_size
92
+ self.max_pool_size = max_pool_size
93
+ self.queue_capacity = queue_capacity
94
+ self.keep_alive_seconds = keep_alive_seconds
95
+ self.thread_name_prefix = thread_name_prefix
96
+ self.allow_core_thread_timeout = allow_core_thread_timeout
97
+ self.rejection_policy = RejectionPolicy(rejection_policy)
98
+ self.wait_for_tasks_to_complete_on_shutdown = wait_for_tasks_to_complete_on_shutdown
99
+ self.await_termination_seconds = await_termination_seconds
100
+ self.task_decorator = task_decorator
101
+ self._condition = Condition()
102
+ self._queue: deque[tuple[Future[Any], Callable[[], Any]]] = deque()
103
+ self._threads: set[Thread] = set()
104
+ self._idle = 0
105
+ self._sequence = 0
106
+ self._shutdown = False
107
+
108
+ @property
109
+ def pool_size(self) -> int:
110
+ with self._condition:
111
+ return len(self._threads)
112
+
113
+ @property
114
+ def queue_size(self) -> int:
115
+ with self._condition:
116
+ return len(self._queue)
117
+
118
+ def submit(self, task: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> Future[T]:
119
+ if not callable(task) or inspect.iscoroutinefunction(task) or inspect.iscoroutinefunction(getattr(task, "__call__", None)):
120
+ raise TypeError("Thread pool tasks must be synchronous callables")
121
+ callback = partial(task, *args, **kwargs)
122
+ if self.task_decorator is not None:
123
+ callback = self.task_decorator(callback)
124
+ if not callable(callback):
125
+ raise TypeError("task_decorator must return a callable")
126
+ future: Future[T] = Future()
127
+ work = (future, callback)
128
+ discarded: Future[Any] | None = None
129
+ with self._condition:
130
+ if self._shutdown:
131
+ raise TaskRejectedError("Task executor has been shut down")
132
+ if len(self._threads) < self.core_pool_size:
133
+ self._start_worker(work)
134
+ return future
135
+ available = (
136
+ self.queue_capacity is None
137
+ or self.queue_capacity > 0 and len(self._queue) < self.queue_capacity
138
+ or self.queue_capacity == 0 and len(self._queue) < self._idle
139
+ )
140
+ if available:
141
+ self._queue.append(work)
142
+ self._condition.notify()
143
+ return future
144
+ if len(self._threads) < self.max_pool_size:
145
+ self._start_worker(work)
146
+ return future
147
+ if self.rejection_policy == RejectionPolicy.ABORT:
148
+ raise TaskRejectedError("Task executor pool and queue are saturated")
149
+ if self.rejection_policy == RejectionPolicy.DISCARD_OLDEST and self._queue:
150
+ discarded, _ = self._queue.popleft()
151
+ self._queue.append(work)
152
+ self._condition.notify()
153
+ if discarded is not None:
154
+ discarded.cancel()
155
+ elif self.rejection_policy == RejectionPolicy.CALLER_RUNS:
156
+ self._run(work)
157
+ else:
158
+ future.cancel()
159
+ return future
160
+
161
+ def _start_worker(self, work: tuple[Future[Any], Callable[[], Any]]) -> None:
162
+ self._sequence += 1
163
+ thread = Thread(
164
+ target=Context().run, args=(self._worker, work),
165
+ name=f"{self.thread_name_prefix}{self._sequence}", daemon=False,
166
+ )
167
+ self._threads.add(thread)
168
+ try:
169
+ thread.start()
170
+ except BaseException:
171
+ self._threads.remove(thread)
172
+ raise
173
+
174
+ @staticmethod
175
+ def _run(work: tuple[Future[Any], Callable[[], Any]]) -> None:
176
+ future, callback = work
177
+ if not future.set_running_or_notify_cancel():
178
+ return
179
+ try:
180
+ result = callback()
181
+ if inspect.isawaitable(result):
182
+ if inspect.iscoroutine(result):
183
+ result.close()
184
+ raise TypeError("Thread pool tasks must not return awaitables")
185
+ except BaseException as error:
186
+ future.set_exception(error)
187
+ else:
188
+ future.set_result(result)
189
+
190
+ def _worker(self, work: tuple[Future[Any], Callable[[], Any]]) -> None:
191
+ try:
192
+ while True:
193
+ self._run(work)
194
+ with self._condition:
195
+ deadline = monotonic() + self.keep_alive_seconds
196
+ while not self._queue:
197
+ if self._shutdown:
198
+ self._threads.remove(current_thread())
199
+ return
200
+ may_expire = self.allow_core_thread_timeout or len(self._threads) > self.core_pool_size
201
+ remaining = deadline - monotonic() if may_expire else None
202
+ if remaining is not None and remaining <= 0:
203
+ self._threads.remove(current_thread())
204
+ return
205
+ self._idle += 1
206
+ try:
207
+ self._condition.wait(remaining)
208
+ finally:
209
+ self._idle -= 1
210
+ work = self._queue.popleft()
211
+ finally:
212
+ with self._condition:
213
+ self._threads.discard(current_thread())
214
+ self._condition.notify_all()
215
+
216
+ def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
217
+ """Reject new submissions, optionally cancel queued work, and join workers."""
218
+ with self._condition:
219
+ self._shutdown = True
220
+ pending = tuple(self._queue) if cancel_futures else ()
221
+ if cancel_futures:
222
+ self._queue.clear()
223
+ threads = tuple(self._threads)
224
+ self._condition.notify_all()
225
+ for future, _ in pending:
226
+ future.cancel()
227
+ if wait:
228
+ deadline = (
229
+ monotonic() + self.await_termination_seconds
230
+ if self.await_termination_seconds is not None else None
231
+ )
232
+ for thread in threads:
233
+ if thread is not current_thread():
234
+ thread.join(None if deadline is None else max(0, deadline - monotonic()))
235
+
236
+ async def close(self) -> None:
237
+ """Release workers through the container's asynchronous destruction hook."""
238
+ await asyncio.to_thread(
239
+ self.shutdown, self.wait_for_tasks_to_complete_on_shutdown,
240
+ cancel_futures=not self.wait_for_tasks_to_complete_on_shutdown,
241
+ )
@@ -0,0 +1,24 @@
1
+ """Public resource-independent transaction contracts and declarations."""
2
+
3
+ from pythonboot.transaction.decorators import get_transaction_attribute, transactional
4
+ from pythonboot.transaction.definition import Isolation, Propagation, TransactionAttribute, TransactionDefinition
5
+ from pythonboot.transaction.exceptions import (
6
+ IllegalTransactionStateError, InvalidIsolationLevelError,
7
+ NestedTransactionNotSupportedError, NoTransactionError,
8
+ TransactionError, TransactionTimedOutError, UnexpectedRollbackError,
9
+ )
10
+ from pythonboot.transaction.manager import (
11
+ AsyncPlatformTransactionManager, AsyncSavepointManager,
12
+ PlatformTransactionManager, SavepointManager, TransactionStatus,
13
+ )
14
+ from pythonboot.transaction.interceptor import TransactionInterceptor
15
+
16
+ __all__ = [
17
+ "Propagation", "Isolation", "TransactionDefinition", "TransactionAttribute",
18
+ "TransactionStatus", "PlatformTransactionManager", "AsyncPlatformTransactionManager",
19
+ "SavepointManager", "AsyncSavepointManager", "transactional", "get_transaction_attribute",
20
+ "TransactionError", "NoTransactionError", "IllegalTransactionStateError",
21
+ "InvalidIsolationLevelError", "TransactionTimedOutError",
22
+ "NestedTransactionNotSupportedError", "UnexpectedRollbackError",
23
+ "TransactionInterceptor",
24
+ ]
@@ -0,0 +1,48 @@
1
+ """Declarative transaction metadata compatible with annotation pointcuts."""
2
+
3
+ from typing import Callable, TypeVar
4
+
5
+ from pythonboot.aop.pointcut import MethodAnnotation
6
+ from pythonboot.transaction.definition import Isolation, Propagation, TransactionAttribute
7
+
8
+
9
+ F = TypeVar("F", bound=Callable)
10
+
11
+
12
+ class Transactional(MethodAnnotation):
13
+ """Mark a method for a resource-specific transaction interceptor."""
14
+
15
+ def __call__(
16
+ self,
17
+ method: F | None = None,
18
+ *,
19
+ propagation: Propagation = Propagation.REQUIRED,
20
+ isolation: Isolation = Isolation.DEFAULT,
21
+ timeout: int = -1,
22
+ read_only: bool = False,
23
+ name: str | None = None,
24
+ rollback_for: tuple[type[BaseException], ...] = (Exception,),
25
+ no_rollback_for: tuple[type[BaseException], ...] = (),
26
+ transaction_manager: str | None = None,
27
+ ) -> F | Callable[[F], F]:
28
+ """Support both @transactional and @transactional(...), without wrapping."""
29
+ attribute = TransactionAttribute(
30
+ propagation=propagation, isolation=isolation, timeout=timeout,
31
+ read_only=read_only, name=name, rollback_for=rollback_for,
32
+ no_rollback_for=no_rollback_for, transaction_manager=transaction_manager,
33
+ )
34
+
35
+ def decorate(target: F) -> F:
36
+ super(Transactional, self).__call__(target)
37
+ target.__pythonboot_transaction__ = attribute
38
+ return target
39
+
40
+ return decorate(method) if method is not None else decorate
41
+
42
+
43
+ transactional = Transactional("pythonboot.transactional")
44
+
45
+
46
+ def get_transaction_attribute(method: Callable) -> TransactionAttribute | None:
47
+ """Read transaction metadata from a function or bound instance method."""
48
+ return getattr(method, "__pythonboot_transaction__", None)
@@ -0,0 +1,90 @@
1
+ """Resource-independent transaction definitions and rollback rules."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import IntEnum
5
+
6
+
7
+ class Propagation(IntEnum):
8
+ """Spring-compatible transaction propagation policies."""
9
+
10
+ REQUIRED = 0
11
+ SUPPORTS = 1
12
+ MANDATORY = 2
13
+ REQUIRES_NEW = 3
14
+ NOT_SUPPORTED = 4
15
+ NEVER = 5
16
+ NESTED = 6
17
+
18
+
19
+ class Isolation(IntEnum):
20
+ """Standard transaction isolation levels; DEFAULT uses the resource default."""
21
+
22
+ DEFAULT = -1
23
+ READ_UNCOMMITTED = 1
24
+ READ_COMMITTED = 2
25
+ REPEATABLE_READ = 4
26
+ SERIALIZABLE = 8
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class TransactionDefinition:
31
+ """Describe a transaction without creating or binding resource connections."""
32
+
33
+ propagation: Propagation = Propagation.REQUIRED
34
+ isolation: Isolation = Isolation.DEFAULT
35
+ timeout: int = -1
36
+ read_only: bool = False
37
+ name: str | None = None
38
+
39
+ def __post_init__(self) -> None:
40
+ if not isinstance(self.propagation, Propagation):
41
+ raise TypeError("propagation must be a Propagation value")
42
+ if not isinstance(self.isolation, Isolation):
43
+ raise TypeError("isolation must be an Isolation value")
44
+ if isinstance(self.timeout, bool) or not isinstance(self.timeout, int) or self.timeout < -1:
45
+ raise ValueError("timeout must be -1 or a non-negative number of seconds")
46
+ if not isinstance(self.read_only, bool):
47
+ raise TypeError("read_only must be a boolean")
48
+ if self.name is not None and (not isinstance(self.name, str) or not self.name.strip()):
49
+ raise ValueError("name must be a non-empty string or None")
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class TransactionAttribute(TransactionDefinition):
54
+ """Add rollback rules and an optional transaction manager bean name."""
55
+
56
+ rollback_for: tuple[type[BaseException], ...] = (Exception,)
57
+ no_rollback_for: tuple[type[BaseException], ...] = ()
58
+ transaction_manager: str | None = None
59
+
60
+ def __post_init__(self) -> None:
61
+ super().__post_init__()
62
+ for label in ("rollback_for", "no_rollback_for"):
63
+ rules = getattr(self, label)
64
+ if not isinstance(rules, (tuple, list)) or any(
65
+ not isinstance(rule, type) or not issubclass(rule, BaseException)
66
+ for rule in rules
67
+ ):
68
+ raise TypeError(f"{label} must be a sequence of exception classes")
69
+ object.__setattr__(self, label, tuple(dict.fromkeys(rules)))
70
+ if self.transaction_manager is not None and (
71
+ not isinstance(self.transaction_manager, str) or not self.transaction_manager.strip()
72
+ ):
73
+ raise ValueError("transaction_manager must be a non-empty bean name or None")
74
+
75
+ def rollback_on(self, error: BaseException) -> bool:
76
+ """Use the closest exception rule; exclusions win ties; fatal errors roll back."""
77
+ if not isinstance(error, BaseException):
78
+ raise TypeError("error must be an exception instance")
79
+ if not isinstance(error, Exception):
80
+ return True
81
+ hierarchy = type(error).__mro__
82
+ rollback_depth = min(
83
+ (hierarchy.index(rule) for rule in self.rollback_for if rule in hierarchy),
84
+ default=float("inf"),
85
+ )
86
+ exclusion_depth = min(
87
+ (hierarchy.index(rule) for rule in self.no_rollback_for if rule in hierarchy),
88
+ default=float("inf"),
89
+ )
90
+ return rollback_depth < exclusion_depth
@@ -0,0 +1,31 @@
1
+ """Resource-independent transaction exceptions."""
2
+
3
+ from pythonboot.exceptions import PythonBootError
4
+
5
+
6
+ class TransactionError(PythonBootError):
7
+ """Base class for transaction failures."""
8
+
9
+
10
+ class NoTransactionError(TransactionError):
11
+ """A required existing transaction is missing."""
12
+
13
+
14
+ class IllegalTransactionStateError(TransactionError):
15
+ """Invalid transaction participation, status ownership, or completion state."""
16
+
17
+
18
+ class InvalidIsolationLevelError(TransactionError):
19
+ """The requested isolation level cannot be applied by the resource."""
20
+
21
+
22
+ class TransactionTimedOutError(TransactionError):
23
+ """A transaction exceeded its configured timeout."""
24
+
25
+
26
+ class NestedTransactionNotSupportedError(TransactionError):
27
+ """The resource does not support nested transactions or savepoints."""
28
+
29
+
30
+ class UnexpectedRollbackError(TransactionError):
31
+ """Commit was requested for a transaction already marked rollback-only."""
@@ -0,0 +1,71 @@
1
+ """Application-scoped around advice for declarative transactions."""
2
+
3
+ from dataclasses import replace
4
+ import inspect
5
+ from typing import Any, Callable
6
+
7
+ from pythonboot.aop.decorators import around
8
+ from pythonboot.aop.joinpoint import ProceedingJoinPoint
9
+ from pythonboot.transaction.decorators import get_transaction_attribute, transactional
10
+ from pythonboot.transaction.definition import TransactionAttribute
11
+ from pythonboot.transaction.exceptions import IllegalTransactionStateError
12
+ from pythonboot.transaction.manager import AsyncPlatformTransactionManager, PlatformTransactionManager
13
+
14
+
15
+ class TransactionInterceptor:
16
+ """Resolve managers from the owning container and complete transaction scopes."""
17
+
18
+ def __init__(
19
+ self,
20
+ resolver: Callable[[type, str | None], Any],
21
+ async_resolver: Callable[[type, str | None], Any] | None = None,
22
+ ) -> None:
23
+ self._resolver = resolver
24
+ self._async_resolver = async_resolver
25
+
26
+ @around(transactional)
27
+ def invoke(self, join_point: ProceedingJoinPoint) -> Any:
28
+ """Dispatch using the original method's coroutine type."""
29
+ attribute = get_transaction_attribute(join_point.method)
30
+ if attribute is None:
31
+ raise IllegalTransactionStateError("Transaction advice requires transaction metadata")
32
+ if attribute.name is None:
33
+ attribute = replace(attribute, name=join_point.qualified_name)
34
+ if inspect.iscoroutinefunction(join_point.method):
35
+ return self._invoke_async(join_point, attribute)
36
+ return self._invoke_sync(join_point, attribute)
37
+
38
+ def _invoke_sync(self, join_point: ProceedingJoinPoint, attribute: TransactionAttribute) -> Any:
39
+ manager = self._resolver(PlatformTransactionManager, attribute.transaction_manager)
40
+ if not isinstance(manager, PlatformTransactionManager):
41
+ raise IllegalTransactionStateError("Synchronous transaction methods require PlatformTransactionManager")
42
+ status = manager.get_transaction(attribute)
43
+ try:
44
+ result = join_point.proceed()
45
+ except BaseException as error:
46
+ if attribute.rollback_on(error):
47
+ manager.rollback(status)
48
+ else:
49
+ manager.commit(status)
50
+ raise
51
+ manager.commit(status)
52
+ return result
53
+
54
+ async def _invoke_async(self, join_point: ProceedingJoinPoint, attribute: TransactionAttribute) -> Any:
55
+ if self._async_resolver is None:
56
+ manager = self._resolver(AsyncPlatformTransactionManager, attribute.transaction_manager)
57
+ else:
58
+ manager = await self._async_resolver(AsyncPlatformTransactionManager, attribute.transaction_manager)
59
+ if not isinstance(manager, AsyncPlatformTransactionManager):
60
+ raise IllegalTransactionStateError("Asynchronous transaction methods require AsyncPlatformTransactionManager")
61
+ status = await manager.get_transaction(attribute)
62
+ try:
63
+ result = await join_point.proceed()
64
+ except BaseException as error:
65
+ if attribute.rollback_on(error):
66
+ await manager.rollback(status)
67
+ else:
68
+ await manager.commit(status)
69
+ raise
70
+ await manager.commit(status)
71
+ return result
@@ -0,0 +1,114 @@
1
+ """Contracts for synchronous and asynchronous resource transaction managers."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ from pythonboot.transaction.definition import TransactionDefinition
7
+
8
+
9
+ class SavepointManager(ABC):
10
+ """Optional synchronous savepoint capability for nested transactions."""
11
+
12
+ @abstractmethod
13
+ def create_savepoint(self) -> Any:
14
+ """Create and return an opaque resource savepoint."""
15
+
16
+ @abstractmethod
17
+ def rollback_to_savepoint(self, savepoint: Any) -> None:
18
+ """Roll back to the given savepoint without completing the transaction."""
19
+
20
+ @abstractmethod
21
+ def release_savepoint(self, savepoint: Any) -> None:
22
+ """Release the given savepoint."""
23
+
24
+
25
+ class AsyncSavepointManager(ABC):
26
+ """Optional asynchronous savepoint capability for nested transactions."""
27
+
28
+ @abstractmethod
29
+ async def create_savepoint(self) -> Any:
30
+ """Create and return an opaque resource savepoint."""
31
+
32
+ @abstractmethod
33
+ async def rollback_to_savepoint(self, savepoint: Any) -> None:
34
+ """Roll back to the given savepoint without completing the transaction."""
35
+
36
+ @abstractmethod
37
+ async def release_savepoint(self, savepoint: Any) -> None:
38
+ """Release the given savepoint."""
39
+
40
+
41
+ class TransactionStatus(ABC):
42
+ """Expose transaction participation, rollback-only state, and completion."""
43
+
44
+ @property
45
+ @abstractmethod
46
+ def is_new_transaction(self) -> bool:
47
+ """Whether this scope owns a newly created physical transaction."""
48
+
49
+ @property
50
+ @abstractmethod
51
+ def has_transaction(self) -> bool:
52
+ """Whether this scope participates in a physical transaction."""
53
+
54
+ @property
55
+ @abstractmethod
56
+ def has_savepoint(self) -> bool:
57
+ """Whether this scope uses a savepoint in an existing transaction."""
58
+
59
+ @property
60
+ @abstractmethod
61
+ def is_rollback_only(self) -> bool:
62
+ """Whether the underlying transaction must be rolled back."""
63
+
64
+ @abstractmethod
65
+ def set_rollback_only(self) -> None:
66
+ """Mark the transaction for rollback even if the business method returns."""
67
+
68
+ @property
69
+ @abstractmethod
70
+ def is_completed(self) -> bool:
71
+ """Whether this scope has already been committed or rolled back."""
72
+
73
+
74
+ class PlatformTransactionManager(ABC):
75
+ """Synchronous transaction manager implemented by resource-specific starters.
76
+
77
+ Managers implement propagation and isolation, reject unsupported options,
78
+ and validate status ownership and completion. Completion must release owned
79
+ resources and restore suspended contexts, including on failure. Participating
80
+ rollback marks the shared transaction rollback-only; an owner attempting to
81
+ commit that transaction must raise UnexpectedRollbackError.
82
+ """
83
+
84
+ @abstractmethod
85
+ def get_transaction(self, definition: TransactionDefinition | None = None) -> TransactionStatus:
86
+ """Create, join, suspend, or reject a transaction according to its definition."""
87
+
88
+ @abstractmethod
89
+ def commit(self, status: TransactionStatus) -> None:
90
+ """Complete a scope; only the owning scope commits the physical transaction."""
91
+
92
+ @abstractmethod
93
+ def rollback(self, status: TransactionStatus) -> None:
94
+ """Roll back an owned transaction or mark a participating transaction."""
95
+
96
+
97
+ class AsyncPlatformTransactionManager(ABC):
98
+ """Asynchronous manager with the same completion contract as the sync manager.
99
+
100
+ Implementations must clean up resources on cancellation and prevent sharing
101
+ a non-concurrent resource transaction across independent asyncio tasks.
102
+ """
103
+
104
+ @abstractmethod
105
+ async def get_transaction(self, definition: TransactionDefinition | None = None) -> TransactionStatus:
106
+ """Create, join, suspend, or reject a transaction according to its definition."""
107
+
108
+ @abstractmethod
109
+ async def commit(self, status: TransactionStatus) -> None:
110
+ """Complete a scope; only the owning scope commits the physical transaction."""
111
+
112
+ @abstractmethod
113
+ async def rollback(self, status: TransactionStatus) -> None:
114
+ """Roll back an owned transaction or mark a participating transaction."""
@@ -0,0 +1,5 @@
1
+ """Public framework utility interfaces."""
2
+
3
+ from pythonboot.util.context_util import ContextUtil
4
+
5
+ __all__ = ["ContextUtil"]