snakeboot 0.1.2__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.
Files changed (36) hide show
  1. snakeboot/__init__.py +12 -0
  2. snakeboot/component_scan/__init__.py +27 -0
  3. snakeboot/component_scan/application_root.py +56 -0
  4. snakeboot/component_scan/component_scan.py +104 -0
  5. snakeboot/django/__init__.py +5 -0
  6. snakeboot/django/django_atomic_transaction_manager.py +24 -0
  7. snakeboot/events/__init__.py +33 -0
  8. snakeboot/events/application_event_listener.py +30 -0
  9. snakeboot/events/application_event_publisher.py +80 -0
  10. snakeboot/events/application_event_publisher_injectable.py +24 -0
  11. snakeboot/events/event_listener_markers.py +41 -0
  12. snakeboot/py.typed +0 -0
  13. snakeboot/rest/__init__.py +47 -0
  14. snakeboot/rest/authenticated_principal.py +15 -0
  15. snakeboot/rest/controller_endpoint_factory.py +72 -0
  16. snakeboot/rest/error_answer.py +14 -0
  17. snakeboot/rest/rest_controller.py +25 -0
  18. snakeboot/rest/rest_controller_advice.py +38 -0
  19. snakeboot/rest/rest_controller_errors.py +14 -0
  20. snakeboot/rest/rest_controller_mappings.py +64 -0
  21. snakeboot/rest/rest_controller_mounter.py +121 -0
  22. snakeboot/rest/rest_controller_scanner.py +42 -0
  23. snakeboot/rest/route_markers.py +45 -0
  24. snakeboot/testing/__init__.py +12 -0
  25. snakeboot/testing/declared_classes.py +33 -0
  26. snakeboot/testing/recording_transaction_manager.py +62 -0
  27. snakeboot/transaction/__init__.py +17 -0
  28. snakeboot/transaction/after_commit.py +13 -0
  29. snakeboot/transaction/transaction_manager.py +25 -0
  30. snakeboot/transaction/transaction_manager_not_installed_error.py +6 -0
  31. snakeboot/transaction/transaction_manager_registry.py +36 -0
  32. snakeboot/transaction/transactional.py +32 -0
  33. snakeboot-0.1.2.dist-info/METADATA +144 -0
  34. snakeboot-0.1.2.dist-info/RECORD +36 -0
  35. snakeboot-0.1.2.dist-info/WHEEL +4 -0
  36. snakeboot-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,38 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ from wireup import injectable
5
+
6
+ from snakeboot.rest.rest_controller_errors import InvalidRestControllerError
7
+ from snakeboot.rest.rest_controller_mappings import (
8
+ ADVICE_MARKER_ATTRIBUTE,
9
+ EXCEPTION_HANDLER_ATTRIBUTE,
10
+ ExceptionHandlerMapping,
11
+ )
12
+
13
+
14
+ def rest_controller_advice[C: type](advice_class: C) -> C:
15
+ """Declares how errors become HTTP answers for every controller, like Spring's `@RestControllerAdvice`.
16
+
17
+ The class becomes a wireup singleton, like a controller. RestControllerMounter finds it
18
+ with the same component scan and registers each `@exception_handler` method with Ninja,
19
+ so composition never lists an error translation.
20
+ """
21
+ setattr(advice_class, ADVICE_MARKER_ATTRIBUTE, True)
22
+ return injectable(advice_class)
23
+
24
+
25
+ def exception_handler(*error_types: type[Exception], status: int) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
26
+ """Marks an advice method as the answer to `error_types`, like Spring's `@ExceptionHandler`.
27
+
28
+ The method receives the error and returns the body; the answer gets `status`.
29
+ Ninja picks the most specific error type through the class hierarchy.
30
+ """
31
+ if not error_types:
32
+ raise InvalidRestControllerError("@exception_handler needs at least one error type.")
33
+
34
+ def record(method: Callable[..., Any]) -> Callable[..., Any]:
35
+ setattr(method, EXCEPTION_HANDLER_ATTRIBUTE, ExceptionHandlerMapping(error_types, status))
36
+ return method
37
+
38
+ return record
@@ -0,0 +1,14 @@
1
+ class InvalidRestControllerError(TypeError):
2
+ """Raised at start-up when a class or method cannot become a REST controller or endpoint.
3
+
4
+ Failing while mounting, not at the first request, is the point: a broken controller
5
+ must stop the application from starting, like a missing bean in Spring.
6
+ """
7
+
8
+
9
+ class NoAuthenticatedPrincipalError(TypeError):
10
+ """Raised when an endpoint asks who is signed in, but no authentication answered with an id.
11
+
12
+ It is a programming error, not a client error: the endpoint opted out of
13
+ authentication (`auth=None`) or the installed authentication returns something else.
14
+ """
@@ -0,0 +1,64 @@
1
+ import re
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Final
5
+
6
+ CONTROLLER_MAPPING_ATTRIBUTE: Final = "__rest_controller_mapping__"
7
+ ROUTE_MAPPING_ATTRIBUTE: Final = "__rest_route_mapping__"
8
+ ADVICE_MARKER_ATTRIBUTE: Final = "__rest_controller_advice__"
9
+ EXCEPTION_HANDLER_ATTRIBUTE: Final = "__rest_exception_handler_mapping__"
10
+
11
+ _PATH_PARAMETER: Final = re.compile(r"\{[^}]*\}")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ControllerMapping:
16
+ """What `@rest_controller` records on a class: the URL prefix and the OpenAPI tags."""
17
+
18
+ prefix: str
19
+ tags: tuple[str, ...]
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class RouteMapping:
24
+ """What `@route.get(...)` and friends record on a method: HTTP method, path and Ninja options.
25
+
26
+ The options are passed to Ninja unchanged (`response`, `auth`, `summary`, ...).
27
+ """
28
+
29
+ http_method: str
30
+ path: str
31
+ options: dict[str, Any] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ExceptionHandlerMapping:
36
+ """What `@exception_handler(...)` records on an advice method: the errors it answers and the status."""
37
+
38
+ error_types: tuple[type[Exception], ...]
39
+ status: int
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class DeclaredEndpoint:
44
+ """One `@route` method of one controller, as the mounter is about to publish it."""
45
+
46
+ controller_class: type
47
+ method: Callable[..., Any]
48
+ prefix: str
49
+ route_mapping: RouteMapping
50
+
51
+ @property
52
+ def name(self) -> str:
53
+ """`Controller.method`, for error messages."""
54
+ return f"{self.controller_class.__name__}.{self.method.__name__}"
55
+
56
+ @property
57
+ def route_key(self) -> tuple[str, str]:
58
+ """HTTP method and full path, with every path parameter reduced to `{}`.
59
+
60
+ `/workshops/{workshop_id}` and `/workshops/{id}` match the same URLs, so they get
61
+ the same key: like Spring, two endpoints with one key are an ambiguous mapping.
62
+ """
63
+ segments = [segment for segment in f"{self.prefix}/{self.route_mapping.path}".split("/") if segment]
64
+ return self.route_mapping.http_method, _PATH_PARAMETER.sub("{}", "/" + "/".join(segments))
@@ -0,0 +1,121 @@
1
+ import inspect
2
+ from collections.abc import Iterable, Sequence
3
+ from dataclasses import dataclass
4
+ from types import ModuleType
5
+
6
+ from ninja import NinjaAPI, Router
7
+
8
+ from snakeboot.rest.controller_endpoint_factory import ControllerEndpointFactory
9
+ from snakeboot.rest.rest_controller_errors import InvalidRestControllerError
10
+ from snakeboot.rest.rest_controller_mappings import (
11
+ ADVICE_MARKER_ATTRIBUTE,
12
+ CONTROLLER_MAPPING_ATTRIBUTE,
13
+ EXCEPTION_HANDLER_ATTRIBUTE,
14
+ ROUTE_MAPPING_ATTRIBUTE,
15
+ ControllerMapping,
16
+ DeclaredEndpoint,
17
+ ExceptionHandlerMapping,
18
+ )
19
+ from snakeboot.rest.rest_controller_scanner import RestControllerScanner
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class MountedRestComponents:
24
+ """What RestControllerMounter added to an API: the controllers and the advices it found."""
25
+
26
+ controllers: list[type]
27
+ advices: list[type]
28
+
29
+
30
+ class RestControllerMounter:
31
+ """Finds the REST controllers and advices under some packages and adds them to a Ninja API.
32
+
33
+ Composition calls it once, with the same modules wireup scans for injectables:
34
+ one "component scan" feeds both dependency injection and routing, so a controller
35
+ the container knows always has its routes, and the other way round.
36
+
37
+ Like Spring, it refuses to start on an ambiguous mapping: two endpoints for the
38
+ same HTTP method and path, or two advice methods for the same error type.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ scanner: RestControllerScanner | None = None,
44
+ endpoint_factory: ControllerEndpointFactory | None = None,
45
+ ) -> None:
46
+ self._scanner = scanner or RestControllerScanner()
47
+ self._endpoint_factory = endpoint_factory or ControllerEndpointFactory()
48
+
49
+ def mount(self, api: NinjaAPI, *, scan: Iterable[str | ModuleType]) -> MountedRestComponents:
50
+ """Adds one router per controller and the error answers of every advice found under `scan`.
51
+
52
+ `scan` takes what `settings.WIREUP.injectables` holds: module names or module objects.
53
+ """
54
+ packages = list(scan)
55
+ controllers = self._scanner.scan(packages)
56
+ advices = self._scanner.scan(packages, marker=ADVICE_MARKER_ATTRIBUTE)
57
+ endpoints_by_controller = {controller: self._endpoints_of(controller) for controller in controllers}
58
+ self._refuse_ambiguous_routes([e for endpoints in endpoints_by_controller.values() for e in endpoints])
59
+ for controller_class, endpoints in endpoints_by_controller.items():
60
+ prefix = self._controller_mapping(controller_class).prefix
61
+ api.add_router(prefix, self._router_for(controller_class, endpoints))
62
+ self._register_error_answers(api, advices)
63
+ return MountedRestComponents(controllers=controllers, advices=advices)
64
+
65
+ def _controller_mapping(self, controller_class: type) -> ControllerMapping:
66
+ mapping: ControllerMapping = getattr(controller_class, CONTROLLER_MAPPING_ATTRIBUTE)
67
+ return mapping
68
+
69
+ def _endpoints_of(self, controller_class: type) -> list[DeclaredEndpoint]:
70
+ prefix = self._controller_mapping(controller_class).prefix
71
+ endpoints = [
72
+ DeclaredEndpoint(controller_class, method, prefix, route_mapping)
73
+ for _, method in inspect.getmembers(controller_class, inspect.isfunction)
74
+ if (route_mapping := getattr(method, ROUTE_MAPPING_ATTRIBUTE, None)) is not None
75
+ ]
76
+ if not endpoints:
77
+ raise InvalidRestControllerError(f"{controller_class.__name__} has no @route methods.")
78
+ return endpoints
79
+
80
+ def _refuse_ambiguous_routes(self, endpoints: Sequence[DeclaredEndpoint]) -> None:
81
+ claimed: dict[tuple[str, str], DeclaredEndpoint] = {}
82
+ for endpoint in endpoints:
83
+ if (earlier := claimed.get(endpoint.route_key)) is not None:
84
+ http_method, path = endpoint.route_key
85
+ raise InvalidRestControllerError(
86
+ f"Ambiguous mapping: {http_method} '{path}' is mapped by {earlier.name} and {endpoint.name}."
87
+ )
88
+ claimed[endpoint.route_key] = endpoint
89
+
90
+ def _router_for(self, controller_class: type, endpoints: Sequence[DeclaredEndpoint]) -> Router:
91
+ router = Router(tags=list(self._controller_mapping(controller_class).tags))
92
+ for endpoint in endpoints:
93
+ router.add_api_operation(
94
+ endpoint.route_mapping.path,
95
+ [endpoint.route_mapping.http_method],
96
+ self._endpoint_factory.create(controller_class, endpoint.method),
97
+ **endpoint.route_mapping.options,
98
+ )
99
+ return router
100
+
101
+ def _register_error_answers(self, api: NinjaAPI, advices: Sequence[type]) -> None:
102
+ answered_by: dict[type[Exception], str] = {}
103
+ for advice_class in advices:
104
+ for _, method in inspect.getmembers(advice_class, inspect.isfunction):
105
+ mapping: ExceptionHandlerMapping | None = getattr(method, EXCEPTION_HANDLER_ATTRIBUTE, None)
106
+ if mapping is None:
107
+ continue
108
+ answer = self._endpoint_factory.create_error_answer(
109
+ advice_class, method, api=api, status=mapping.status
110
+ )
111
+ for error_type in mapping.error_types:
112
+ self._claim_error_type(answered_by, error_type, f"{advice_class.__name__}.{method.__name__}")
113
+ # Ninja annotates the handler's error as `Exception | type[Exception]`, but passes an instance.
114
+ api.add_exception_handler(error_type, answer) # type: ignore[arg-type]
115
+
116
+ def _claim_error_type(self, answered_by: dict[type[Exception], str], error_type: type[Exception], by: str) -> None:
117
+ if (earlier := answered_by.get(error_type)) is not None:
118
+ raise InvalidRestControllerError(
119
+ f"Ambiguous mapping: {error_type.__name__} is answered by {earlier} and {by}."
120
+ )
121
+ answered_by[error_type] = by
@@ -0,0 +1,42 @@
1
+ import importlib
2
+ import pkgutil
3
+ from collections.abc import Iterable
4
+ from types import ModuleType
5
+
6
+ from snakeboot.rest.rest_controller_mappings import CONTROLLER_MAPPING_ATTRIBUTE
7
+
8
+
9
+ class RestControllerScanner:
10
+ """Component scan: finds every class with a REST marker in the modules under some packages.
11
+
12
+ Python has no classpath scanning and a decorator only runs when its module is
13
+ imported, so a registry filled by decorators would silently miss controllers in
14
+ modules nobody imported. The scanner imports the modules itself, which makes the
15
+ result complete and independent of import order. It is sorted, so routes are
16
+ always registered in the same order.
17
+ """
18
+
19
+ def scan(self, packages: Iterable[str | ModuleType], *, marker: str = CONTROLLER_MAPPING_ATTRIBUTE) -> list[type]:
20
+ """Returns the classes carrying `marker` (controllers by default) defined in `packages`.
21
+
22
+ `packages` may name packages (searched with their subpackages) or plain modules, or be
23
+ module objects: the same forms wireup accepts, so the list in `settings.WIREUP` fits.
24
+ """
25
+ found: dict[str, type] = {}
26
+ for package in packages:
27
+ imported = package if isinstance(package, ModuleType) else importlib.import_module(package)
28
+ for module in self._modules_under(imported):
29
+ for candidate in vars(module).values():
30
+ if self._is_marked_class_defined_in(candidate, module, marker):
31
+ found[f"{candidate.__module__}.{candidate.__qualname__}"] = candidate
32
+ return [found[name] for name in sorted(found)]
33
+
34
+ def _modules_under(self, package: ModuleType) -> list[ModuleType]:
35
+ if not hasattr(package, "__path__"):
36
+ return [package]
37
+ submodules = pkgutil.walk_packages(package.__path__, prefix=f"{package.__name__}.")
38
+ return [package, *(importlib.import_module(info.name) for info in submodules)]
39
+
40
+ def _is_marked_class_defined_in(self, candidate: object, module: ModuleType, marker: str) -> bool:
41
+ # A class imported into another module is found once, where it is defined.
42
+ return isinstance(candidate, type) and marker in vars(candidate) and candidate.__module__ == module.__name__
@@ -0,0 +1,45 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ from snakeboot.rest.rest_controller_mappings import ROUTE_MAPPING_ATTRIBUTE, RouteMapping
5
+
6
+
7
+ class RouteMarkers:
8
+ """Marks controller methods as endpoints, like Spring's `@GetMapping` and `@PostMapping`.
9
+
10
+ A marker only records a RouteMapping on the method; it changes nothing else. The
11
+ RestControllerMounter reads the marks when it builds the Ninja routers. Use the
12
+ module-level instance: `@route.get("/{workshop_id}", response=WorkshopViewDTO)`.
13
+ """
14
+
15
+ def get(self, path: str, **options: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
16
+ """Marks a method as the handler of `GET prefix+path`."""
17
+ return self._mark("GET", path, options)
18
+
19
+ def post(self, path: str, **options: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
20
+ """Marks a method as the handler of `POST prefix+path`."""
21
+ return self._mark("POST", path, options)
22
+
23
+ def put(self, path: str, **options: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
24
+ """Marks a method as the handler of `PUT prefix+path`."""
25
+ return self._mark("PUT", path, options)
26
+
27
+ def patch(self, path: str, **options: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
28
+ """Marks a method as the handler of `PATCH prefix+path`."""
29
+ return self._mark("PATCH", path, options)
30
+
31
+ def delete(self, path: str, **options: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
32
+ """Marks a method as the handler of `DELETE prefix+path`."""
33
+ return self._mark("DELETE", path, options)
34
+
35
+ def _mark(
36
+ self, http_method: str, path: str, options: dict[str, Any]
37
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
38
+ def record(method: Callable[..., Any]) -> Callable[..., Any]:
39
+ setattr(method, ROUTE_MAPPING_ATTRIBUTE, RouteMapping(http_method, path, options))
40
+ return method
41
+
42
+ return record
43
+
44
+
45
+ route = RouteMarkers()
@@ -0,0 +1,12 @@
1
+ """Test helpers, like spring-test: a transaction manager for tests and a reader for architecture tests.
2
+
3
+ TransactionManagerRegistry.install(RecordingTransactionManager()) # use cases without a database
4
+
5
+ declared = declared_classes(Path("src/myapp"), "rest_controller") # what the source declares
6
+ assert declared == qualified_names(urls.mounted.controllers) # ... is what was mounted
7
+ """
8
+
9
+ from snakeboot.testing.declared_classes import declared_classes, qualified_names
10
+ from snakeboot.testing.recording_transaction_manager import RecordingTransactionManager
11
+
12
+ __all__ = ["RecordingTransactionManager", "declared_classes", "qualified_names"]
@@ -0,0 +1,33 @@
1
+ import ast
2
+ from pathlib import Path
3
+
4
+
5
+ def declared_classes(package_directory: Path, decorator_name: str) -> set[str]:
6
+ """Returns `module.Class` for every class in the source under `package_directory` carrying `decorator_name`.
7
+
8
+ It reads the source instead of importing it, so an architecture test can compare what is
9
+ *written* with what the component scan *found*: a `@rest_controller` in a module the scan
10
+ skips would otherwise be silently missing from the API. Both `@name` and `@name(...)` count.
11
+ """
12
+ found: set[str] = set()
13
+ for path in sorted(package_directory.rglob("*.py")):
14
+ for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
15
+ if isinstance(node, ast.ClassDef) and any(_names(d, decorator_name) for d in node.decorator_list):
16
+ found.add(f"{_module_name(package_directory, path)}.{node.name}")
17
+ return found
18
+
19
+
20
+ def qualified_names(classes: list[type]) -> set[str]:
21
+ """Returns `module.Class` for each class, the form `declared_classes` uses."""
22
+ return {f"{cls.__module__}.{cls.__qualname__}" for cls in classes}
23
+
24
+
25
+ def _names(decorator: ast.expr, decorator_name: str) -> bool:
26
+ target = decorator.func if isinstance(decorator, ast.Call) else decorator
27
+ return isinstance(target, ast.Name) and target.id == decorator_name
28
+
29
+
30
+ def _module_name(package_directory: Path, path: Path) -> str:
31
+ relative = path.relative_to(package_directory.parent).with_suffix("")
32
+ parts = relative.parts[:-1] if relative.name == "__init__" else relative.parts
33
+ return ".".join(parts)
@@ -0,0 +1,62 @@
1
+ from collections.abc import Callable, Iterator
2
+ from contextlib import AbstractContextManager, contextmanager
3
+ from typing import override
4
+
5
+ from snakeboot.transaction import TransactionManager
6
+
7
+
8
+ class RecordingTransactionManager(TransactionManager):
9
+ """Test double: remembers which transactions were opened, committed and rolled back.
10
+
11
+ Application tests install it to run `@transactional` use cases without a database,
12
+ to assert that a use case really runs inside a transaction, and to check that
13
+ `after_commit` callbacks run only after a commit.
14
+ """
15
+
16
+ def __init__(self) -> None:
17
+ self.opened = 0
18
+ self.committed = 0
19
+ self.rolled_back = 0
20
+ self._depth = 0
21
+ self._pending_callbacks: list[Callable[[], None]] = []
22
+ self.callback_errors: list[Exception] = []
23
+
24
+ @property
25
+ def in_transaction(self) -> bool:
26
+ """Tells whether a transaction is open right now, so a test can check where code runs."""
27
+ return self._depth > 0
28
+
29
+ @override
30
+ def atomic(self) -> AbstractContextManager[None]:
31
+ return self._record_transaction()
32
+
33
+ @override
34
+ def on_commit(self, callback: Callable[[], None]) -> None:
35
+ if self.in_transaction:
36
+ self._pending_callbacks.append(callback)
37
+ else:
38
+ self._execute_robustly(callback)
39
+
40
+ def _execute_robustly(self, callback: Callable[[], None]) -> None:
41
+ try:
42
+ callback()
43
+ except Exception as error: # noqa: BLE001 - like Django's robust on_commit, any callback error is recorded
44
+ self.callback_errors.append(error)
45
+
46
+ @contextmanager
47
+ def _record_transaction(self) -> Iterator[None]:
48
+ self.opened += 1
49
+ self._depth += 1
50
+ try:
51
+ yield
52
+ except BaseException:
53
+ self._depth -= 1
54
+ self.rolled_back += 1
55
+ self._pending_callbacks.clear()
56
+ raise
57
+ self._depth -= 1
58
+ self.committed += 1
59
+ if not self.in_transaction:
60
+ callbacks, self._pending_callbacks = self._pending_callbacks, []
61
+ for callback in callbacks:
62
+ self._execute_robustly(callback)
@@ -0,0 +1,17 @@
1
+ """Technical library that declares transaction boundaries without knowing the technology."""
2
+
3
+ from snakeboot.transaction.after_commit import after_commit
4
+ from snakeboot.transaction.transaction_manager import TransactionManager
5
+ from snakeboot.transaction.transaction_manager_not_installed_error import (
6
+ TransactionManagerNotInstalledError,
7
+ )
8
+ from snakeboot.transaction.transaction_manager_registry import TransactionManagerRegistry
9
+ from snakeboot.transaction.transactional import transactional
10
+
11
+ __all__ = [
12
+ "TransactionManager",
13
+ "TransactionManagerNotInstalledError",
14
+ "TransactionManagerRegistry",
15
+ "after_commit",
16
+ "transactional",
17
+ ]
@@ -0,0 +1,13 @@
1
+ from collections.abc import Callable
2
+
3
+ from snakeboot.transaction.transaction_manager_registry import TransactionManagerRegistry
4
+
5
+
6
+ def after_commit(callback: Callable[[], None]) -> None:
7
+ """Defers a side effect until the surrounding `@transactional` operation has committed.
8
+
9
+ Use it for effects outside the database, such as notifications: a participant must
10
+ never hear about a seat that a rolled-back transaction took away again. Mirrors
11
+ Spring's `@TransactionalEventListener(phase = AFTER_COMMIT)`.
12
+ """
13
+ TransactionManagerRegistry.current().on_commit(callback)
@@ -0,0 +1,25 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Callable
3
+ from contextlib import AbstractContextManager
4
+
5
+
6
+ class TransactionManager(ABC):
7
+ """Opens a transaction in a concrete technology, like Spring's PlatformTransactionManager.
8
+
9
+ `@transactional` and `after_commit` only know this abstraction. Composition installs
10
+ the concrete manager (Django's `atomic` in production, a recording fake in tests), so
11
+ use cases declare transaction behaviour without depending on the persistence technology.
12
+ """
13
+
14
+ @abstractmethod
15
+ def atomic(self) -> AbstractContextManager[None]:
16
+ """Returns a context manager that commits on success and rolls back on an exception."""
17
+
18
+ @abstractmethod
19
+ def on_commit(self, callback: Callable[[], None]) -> None:
20
+ """Runs `callback` once the current transaction has committed; drops it on a rollback.
21
+
22
+ Outside a transaction the callback runs immediately. Callbacks run robustly:
23
+ an exception in one callback does not prevent others from running and does not
24
+ mask an already committed transaction.
25
+ """
@@ -0,0 +1,6 @@
1
+ class TransactionManagerNotInstalledError(RuntimeError):
2
+ """Raised when `@transactional` runs before composition installed a TransactionManager.
3
+
4
+ A missing manager is a wiring mistake. Failing loudly prevents a use case from
5
+ silently changing state without a transaction.
6
+ """
@@ -0,0 +1,36 @@
1
+ from typing import ClassVar
2
+
3
+ from snakeboot.transaction.transaction_manager import TransactionManager
4
+ from snakeboot.transaction.transaction_manager_not_installed_error import (
5
+ TransactionManagerNotInstalledError,
6
+ )
7
+
8
+
9
+ class TransactionManagerRegistry:
10
+ """Holds the one TransactionManager that composition installed for this process.
11
+
12
+ A decorator cannot receive constructor injection, so `@transactional` looks the
13
+ manager up here. Only composition and tests call `install` and `uninstall`.
14
+ """
15
+
16
+ _installed: ClassVar[TransactionManager | None] = None
17
+
18
+ @classmethod
19
+ def install(cls, manager: TransactionManager) -> None:
20
+ """Makes `manager` the transaction manager used by every `@transactional` call."""
21
+ cls._installed = manager
22
+
23
+ @classmethod
24
+ def uninstall(cls) -> None:
25
+ """Removes the installed manager, so a test cannot leak its manager into the next one."""
26
+ cls._installed = None
27
+
28
+ @classmethod
29
+ def current(cls) -> TransactionManager:
30
+ """Returns the installed manager or fails when composition forgot to install one."""
31
+ if cls._installed is None:
32
+ raise TransactionManagerNotInstalledError(
33
+ "No TransactionManager installed. Composition must call "
34
+ "TransactionManagerRegistry.install(...) at start-up."
35
+ )
36
+ return cls._installed
@@ -0,0 +1,32 @@
1
+ import inspect
2
+ from collections.abc import Callable
3
+ from functools import wraps
4
+
5
+ from snakeboot.transaction.transaction_manager_registry import TransactionManagerRegistry
6
+
7
+
8
+ def transactional[**P, R](operation: Callable[P, R]) -> Callable[P, R]:
9
+ """Declares that `operation` runs in one transaction, like Spring's `@Transactional`.
10
+
11
+ Put it on the `execute` method of a use case that changes state. The transaction
12
+ commits when `operation` returns and rolls back when it raises. Which technology
13
+ opens the transaction is decided by the TransactionManager composition installed.
14
+ """
15
+ if inspect.iscoroutinefunction(operation):
16
+ raise TypeError(
17
+ f"{operation.__qualname__} is an async function; @transactional supports synchronous operations only."
18
+ )
19
+
20
+ @wraps(operation)
21
+ def run_in_transaction(*args: P.args, **kwargs: P.kwargs) -> R:
22
+ with TransactionManagerRegistry.current().atomic():
23
+ result = operation(*args, **kwargs)
24
+ if inspect.iscoroutine(result):
25
+ result.close()
26
+ raise TypeError(
27
+ f"{operation.__qualname__} returned a coroutine; "
28
+ "@transactional supports synchronous operations only."
29
+ )
30
+ return result
31
+
32
+ return run_in_transaction