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.
- snakeboot/__init__.py +12 -0
- snakeboot/component_scan/__init__.py +27 -0
- snakeboot/component_scan/application_root.py +56 -0
- snakeboot/component_scan/component_scan.py +104 -0
- snakeboot/django/__init__.py +5 -0
- snakeboot/django/django_atomic_transaction_manager.py +24 -0
- snakeboot/events/__init__.py +33 -0
- snakeboot/events/application_event_listener.py +30 -0
- snakeboot/events/application_event_publisher.py +80 -0
- snakeboot/events/application_event_publisher_injectable.py +24 -0
- snakeboot/events/event_listener_markers.py +41 -0
- snakeboot/py.typed +0 -0
- snakeboot/rest/__init__.py +47 -0
- snakeboot/rest/authenticated_principal.py +15 -0
- snakeboot/rest/controller_endpoint_factory.py +72 -0
- snakeboot/rest/error_answer.py +14 -0
- snakeboot/rest/rest_controller.py +25 -0
- snakeboot/rest/rest_controller_advice.py +38 -0
- snakeboot/rest/rest_controller_errors.py +14 -0
- snakeboot/rest/rest_controller_mappings.py +64 -0
- snakeboot/rest/rest_controller_mounter.py +121 -0
- snakeboot/rest/rest_controller_scanner.py +42 -0
- snakeboot/rest/route_markers.py +45 -0
- snakeboot/testing/__init__.py +12 -0
- snakeboot/testing/declared_classes.py +33 -0
- snakeboot/testing/recording_transaction_manager.py +62 -0
- snakeboot/transaction/__init__.py +17 -0
- snakeboot/transaction/after_commit.py +13 -0
- snakeboot/transaction/transaction_manager.py +25 -0
- snakeboot/transaction/transaction_manager_not_installed_error.py +6 -0
- snakeboot/transaction/transaction_manager_registry.py +36 -0
- snakeboot/transaction/transactional.py +32 -0
- snakeboot-0.1.2.dist-info/METADATA +144 -0
- snakeboot-0.1.2.dist-info/RECORD +36 -0
- snakeboot-0.1.2.dist-info/WHEEL +4 -0
- 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
|