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
snakeboot/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """snakeboot: Spring Boot ergonomics for hexagonal Python applications.
2
+
3
+ snakeboot.transaction @transactional, after_commit, TransactionManager (no dependencies)
4
+ snakeboot.component_scan @application_root, ComponentScan (no dependencies)
5
+ snakeboot.events ApplicationEventPublisher, @transactional_event_listener (wireup)
6
+ snakeboot.rest @rest_controller, @route, @rest_controller_advice (extra: ninja)
7
+ snakeboot.django DjangoAtomicTransactionManager (extra: django)
8
+ snakeboot.testing RecordingTransactionManager, declared_classes (no dependencies)
9
+
10
+ The libraries imitate the basics of Spring Boot, not Spring itself: each one is a few
11
+ small classes a reader can follow, and none knows anything about the application's domain.
12
+ """
@@ -0,0 +1,27 @@
1
+ """Technical library: one component scan for dependency injection and routing.
2
+
3
+ @application_root # @SpringBootApplication: scan from this package down
4
+ class MyApplication: ...
5
+
6
+ # in settings.py, inside the setting wireup needs anyway:
7
+ WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names()])
8
+
9
+ Standard library only: it finds module names without importing them, so settings can use it
10
+ before Django has started.
11
+ """
12
+
13
+ from snakeboot.component_scan.application_root import application_root
14
+ from snakeboot.component_scan.component_scan import (
15
+ DJANGO_ENTRY_POINTS,
16
+ SNAKEBOOT_AUTO_CONFIGURATION,
17
+ ComponentScan,
18
+ InvalidApplicationRootError,
19
+ )
20
+
21
+ __all__ = [
22
+ "DJANGO_ENTRY_POINTS",
23
+ "SNAKEBOOT_AUTO_CONFIGURATION",
24
+ "ComponentScan",
25
+ "InvalidApplicationRootError",
26
+ "application_root",
27
+ ]
@@ -0,0 +1,56 @@
1
+ import sys
2
+ from collections.abc import Callable
3
+ from typing import overload
4
+
5
+ from snakeboot.component_scan.component_scan import (
6
+ APPLICATION_ROOT_ATTRIBUTE,
7
+ DJANGO_ENTRY_POINTS,
8
+ ComponentScan,
9
+ InvalidApplicationRootError,
10
+ )
11
+
12
+
13
+ @overload
14
+ def application_root[C: type](application_class: C, /) -> C: ...
15
+
16
+
17
+ @overload
18
+ def application_root[C: type](
19
+ *, exclude: tuple[str, ...] = (), default_exclude: tuple[str, ...] = DJANGO_ENTRY_POINTS
20
+ ) -> Callable[[C], C]: ...
21
+
22
+
23
+ def application_root[C: type](
24
+ application_class: C | None = None,
25
+ /,
26
+ *,
27
+ exclude: tuple[str, ...] = (),
28
+ default_exclude: tuple[str, ...] = DJANGO_ENTRY_POINTS,
29
+ ) -> C | Callable[[C], C]:
30
+ """Marks the root of the application, like Spring's `@SpringBootApplication`.
31
+
32
+ The component scan starts in the package of the marked class and covers everything
33
+ below it, so the class belongs in the top package (`myapp/myapp_application.py`).
34
+ Use it bare (`@application_root`) or with own exclusions on top of the Django defaults
35
+ (`@application_root(exclude=(...))`). Settings read the result with `ComponentScan.of(...)`.
36
+ """
37
+
38
+ def declare(declared_class: C) -> C:
39
+ scan = ComponentScan((_package_of(declared_class),), exclude=exclude, default_exclude=default_exclude)
40
+ setattr(declared_class, APPLICATION_ROOT_ATTRIBUTE, scan)
41
+ return declared_class
42
+
43
+ return declare if application_class is None else declare(application_class)
44
+
45
+
46
+ def _package_of(declared_class: type) -> str:
47
+ module = sys.modules.get(declared_class.__module__)
48
+ if module is not None and hasattr(module, "__path__"):
49
+ return module.__name__
50
+ package, _, _ = declared_class.__module__.rpartition(".")
51
+ if not package:
52
+ raise InvalidApplicationRootError(
53
+ f"{declared_class.__name__} is defined in {declared_class.__module__!r}, outside any package; "
54
+ "an application root lives in the top package it scans."
55
+ )
56
+ return package
@@ -0,0 +1,104 @@
1
+ import importlib.util
2
+ import pkgutil
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass
5
+ from fnmatch import fnmatchcase
6
+ from pathlib import Path
7
+ from typing import Final
8
+
9
+ DJANGO_ENTRY_POINTS: Final = ("*.wsgi", "*.asgi", "*.migrations")
10
+ """Modules every Django project has that are never components.
11
+
12
+ `wsgi.py` and `asgi.py` start Django when they are imported; a scan that imported them
13
+ during start-up would start Django a second time, which Django refuses. Migrations hold
14
+ no components. These are conventions of Django, so the scan needs no Django import to know them.
15
+ """
16
+
17
+ SNAKEBOOT_AUTO_CONFIGURATION: Final = ("snakeboot.events.application_event_publisher_injectable",)
18
+ """Modules of snakeboot itself that register components, like Spring Boot's `@EnableAutoConfiguration`.
19
+
20
+ Spring Boot always provides an `ApplicationEventPublisher`; so does snakeboot, through this
21
+ module. The scan adds it to every application without the application naming it. The names
22
+ are strings: the scan still imports nothing and depends on no other part of snakeboot.
23
+ """
24
+
25
+ APPLICATION_ROOT_ATTRIBUTE: Final = "__component_scan__"
26
+
27
+
28
+ class InvalidApplicationRootError(TypeError):
29
+ """Raised at start-up when a class cannot be, or is not, the root of a component scan.
30
+
31
+ The application root decides what the application is made of; a wrong one must stop
32
+ the start-up instead of silently scanning nothing.
33
+ """
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class ComponentScan:
38
+ """Which modules make up the application, like `@ComponentScan(basePackages, excludeFilters)` in Spring.
39
+
40
+ Spring Boot scans from the package of the `@SpringBootApplication` class downwards and
41
+ skips what is never a component through its default filters. Here the base packages are
42
+ named, `default_exclude` skips Django's entry points, and `exclude` adds the application's
43
+ own exclusions (rarely needed). `default_exclude=()` turns the defaults off, like Spring's
44
+ `useDefaultFilters = false`. `auto_configuration` adds the modules through which snakeboot
45
+ registers its own components; an exclusion pattern can remove one, `auto_configuration=()`
46
+ removes them all.
47
+
48
+ `module_names()` lists plain modules only, never packages. Given a package, wireup
49
+ and the controller scanner import every file below it themselves, past any exclusion;
50
+ given only modules, both look exactly where this scan points. Finding the names
51
+ imports nothing, so settings can call it before Django has started.
52
+ """
53
+
54
+ base_packages: tuple[str, ...]
55
+ exclude: tuple[str, ...] = ()
56
+ default_exclude: tuple[str, ...] = DJANGO_ENTRY_POINTS
57
+ auto_configuration: tuple[str, ...] = SNAKEBOOT_AUTO_CONFIGURATION
58
+
59
+ @classmethod
60
+ def of(cls, application_class: type) -> "ComponentScan":
61
+ """Returns the scan that `@application_root` declared on `application_class`."""
62
+ scan = vars(application_class).get(APPLICATION_ROOT_ATTRIBUTE)
63
+ if not isinstance(scan, ComponentScan):
64
+ raise InvalidApplicationRootError(f"{application_class.__name__} is not an @application_root.")
65
+ return scan
66
+
67
+ def module_names(self) -> list[str]:
68
+ """Returns the dotted names of every module under the base packages, plus the auto-configuration, sorted.
69
+
70
+ Excluded modules are left out, auto-configuration included.
71
+ """
72
+ scanned = (name for base_package in self.base_packages for name in self._modules_under(base_package))
73
+ return sorted({name for name in (*scanned, *self.auto_configuration) if not self.is_excluded(name)})
74
+
75
+ def is_excluded(self, module_name: str) -> bool:
76
+ """Tells whether a default or an own exclusion pattern matches the module or one of its parent packages.
77
+
78
+ A pattern is a dotted name with `fnmatch` wildcards: `myapp.composition.django_project`
79
+ excludes that package and everything below it, `*.migrations` every migrations package.
80
+ """
81
+ return any(
82
+ fnmatchcase(module_name, pattern) or fnmatchcase(module_name, f"{pattern}.*")
83
+ for pattern in (*self.default_exclude, *self.exclude)
84
+ )
85
+
86
+ def _modules_under(self, package_name: str) -> Iterator[str]:
87
+ spec = importlib.util.find_spec(package_name)
88
+ if spec is None:
89
+ raise ModuleNotFoundError(
90
+ f"The component scan cannot find the package {package_name!r}.", name=package_name
91
+ )
92
+ if spec.submodule_search_locations is None:
93
+ yield package_name
94
+ return
95
+ for location in spec.submodule_search_locations:
96
+ yield from self._modules_in_directory(Path(location), package_name)
97
+
98
+ def _modules_in_directory(self, directory: Path, package_name: str) -> Iterator[str]:
99
+ for module in pkgutil.iter_modules([str(directory)]):
100
+ name = f"{package_name}.{module.name}"
101
+ if module.ispkg:
102
+ yield from self._modules_in_directory(directory / module.name, name)
103
+ else:
104
+ yield name
@@ -0,0 +1,5 @@
1
+ """Django integration: runs `@transactional` on Django's `transaction.atomic` (extra: `snakeboot[django]`)."""
2
+
3
+ from snakeboot.django.django_atomic_transaction_manager import DjangoAtomicTransactionManager
4
+
5
+ __all__ = ["DjangoAtomicTransactionManager"]
@@ -0,0 +1,24 @@
1
+ from collections.abc import Callable
2
+ from contextlib import AbstractContextManager
3
+ from typing import override
4
+
5
+ from django.db import transaction
6
+
7
+ from snakeboot.transaction import TransactionManager
8
+
9
+
10
+ class DjangoAtomicTransactionManager(TransactionManager):
11
+ """Opens transactions with Django's `transaction.atomic`, for the default database.
12
+
13
+ It lives in composition because choosing the transaction technology is a wiring
14
+ decision: the transaction library stays free of Django, and no bounded context
15
+ owns the database connection. Nested `@transactional` calls become savepoints.
16
+ """
17
+
18
+ @override
19
+ def atomic(self) -> AbstractContextManager[None]:
20
+ return transaction.atomic()
21
+
22
+ @override
23
+ def on_commit(self, callback: Callable[[], None]) -> None:
24
+ transaction.on_commit(callback, robust=True)
@@ -0,0 +1,33 @@
1
+ """Technical library: in-process application events, like Spring's `ApplicationEventPublisher`.
2
+
3
+ @application_event_listener # a @Component with listener methods
4
+ class ParticipantNotificationListener:
5
+ def __init__(self, notifications: NotificationPort) -> None: # constructor injection
6
+ self._notifications = notifications
7
+
8
+ @transactional_event_listener(WaitlistedParticipantPromoted) # @TransactionalEventListener
9
+ def notify_promoted(self, event: WaitlistedParticipantPromoted) -> None: ...
10
+
11
+ # in a @transactional use case, after saving the aggregate:
12
+ self._events.publish_all(workshop.pull_events())
13
+
14
+ Events are facts, never requests: a caller that needs an outcome calls a facade instead.
15
+ The library knows wireup and `snakeboot.transaction`, but no framework and no bounded context.
16
+ """
17
+
18
+ from snakeboot.events.application_event_listener import (
19
+ ApplicationEventListener,
20
+ InvalidApplicationEventListenerError,
21
+ application_event_listener,
22
+ )
23
+ from snakeboot.events.application_event_publisher import ApplicationEventPublisher
24
+ from snakeboot.events.event_listener_markers import event_listener, transactional_event_listener
25
+
26
+ __all__ = [
27
+ "ApplicationEventListener",
28
+ "ApplicationEventPublisher",
29
+ "InvalidApplicationEventListenerError",
30
+ "application_event_listener",
31
+ "event_listener",
32
+ "transactional_event_listener",
33
+ ]
@@ -0,0 +1,30 @@
1
+ from typing import Protocol
2
+
3
+ from wireup import injectable
4
+
5
+
6
+ class ApplicationEventListener(Protocol):
7
+ """The type under which wireup collects every listener, so the publisher receives them all.
8
+
9
+ A listener is any class whose methods carry `@event_listener` or `@transactional_event_listener`;
10
+ it does not inherit from this protocol. Wireup injects `Sequence[ApplicationEventListener]`.
11
+ """
12
+
13
+
14
+ class InvalidApplicationEventListenerError(TypeError):
15
+ """Raised at start-up when a class or method cannot listen to application events.
16
+
17
+ Like a broken controller, a broken listener must stop the application from starting,
18
+ not fail silently at the first event.
19
+ """
20
+
21
+
22
+ def application_event_listener[C: type](listener_class: C) -> C:
23
+ """Declares a listener: a wireup singleton whose marked methods receive published events.
24
+
25
+ Spring finds `@EventListener` methods on any bean. Here the class says it listens, so wireup
26
+ registers it as an `ApplicationEventListener` (qualified by its name, so several can exist)
27
+ and the component scan finds it like every other injectable.
28
+ """
29
+ qualifier = f"{listener_class.__module__}.{listener_class.__qualname__}"
30
+ return injectable(as_type=ApplicationEventListener, qualifier=qualifier)(listener_class)
@@ -0,0 +1,80 @@
1
+ import inspect
2
+ from collections.abc import Callable, Iterable
3
+ from dataclasses import dataclass
4
+ from functools import partial
5
+ from typing import Any, Final
6
+
7
+ from snakeboot.events.application_event_listener import InvalidApplicationEventListenerError
8
+ from snakeboot.events.event_listener_markers import LISTENER_MAPPING_ATTRIBUTE, ListenerMapping
9
+ from snakeboot.transaction import after_commit
10
+
11
+ _SELF_AND_EVENT: Final = 2
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class _Subscription:
16
+ """One listener method and the events it receives."""
17
+
18
+ name: str
19
+ receive: Callable[[object], None]
20
+ mapping: ListenerMapping
21
+
22
+ def deliver(self, event: object) -> None:
23
+ if self.mapping.after_commit:
24
+ after_commit(partial(self.receive, event))
25
+ else:
26
+ self.receive(event)
27
+
28
+
29
+ class ApplicationEventPublisher:
30
+ """Hands each event to the listener methods that asked for its type (Spring's `ApplicationEventPublisher`).
31
+
32
+ It is the Observer pattern for facts: the use case that publishes does not know who
33
+ reacts, and a new reaction is a new listener, not a change to the use case. Delivery is
34
+ synchronous and in-process, in a stable order (by listener name). A listener for a base
35
+ class (`WorkshopEvent`) receives every subclass.
36
+
37
+ The library registers it with wireup (`application_event_publisher_injectable`), connected
38
+ to every listener wireup found, or to none; tests build it with the listeners they need.
39
+ """
40
+
41
+ def __init__(self, listeners: Iterable[object]) -> None:
42
+ self._subscriptions = sorted(
43
+ (subscription for listener in listeners for subscription in self._subscriptions_of(listener)),
44
+ key=lambda subscription: subscription.name,
45
+ )
46
+
47
+ def publish(self, event: object) -> None:
48
+ """Delivers `event` to every listener method for its type; immediately or after the commit."""
49
+ for subscription in self._subscriptions:
50
+ if isinstance(event, subscription.mapping.event_types):
51
+ subscription.deliver(event)
52
+
53
+ def publish_all(self, events: Iterable[object]) -> None:
54
+ """Publishes `events` in the order they happened."""
55
+ for event in events:
56
+ self.publish(event)
57
+
58
+ def _subscriptions_of(self, listener: object) -> list[_Subscription]:
59
+ listener_class = type(listener)
60
+ subscriptions = [
61
+ _Subscription(
62
+ name=f"{listener_class.__module__}.{listener_class.__qualname__}.{name}",
63
+ receive=self._receiver(listener, name, method),
64
+ mapping=mapping,
65
+ )
66
+ for name, method in inspect.getmembers(listener_class, inspect.isfunction)
67
+ if (mapping := getattr(method, LISTENER_MAPPING_ATTRIBUTE, None)) is not None
68
+ ]
69
+ if not subscriptions:
70
+ raise InvalidApplicationEventListenerError(f"{listener_class.__name__} has no listener methods.")
71
+ return subscriptions
72
+
73
+ def _receiver(self, listener: object, name: str, method: Callable[..., Any]) -> Callable[[object], None]:
74
+ owner = f"{type(listener).__name__}.{name}"
75
+ if inspect.iscoroutinefunction(method):
76
+ raise InvalidApplicationEventListenerError(f"{owner} is async; listeners are synchronous.")
77
+ if len(inspect.signature(method).parameters) != _SELF_AND_EVENT:
78
+ raise InvalidApplicationEventListenerError(f"{owner} must take exactly one argument: the event.")
79
+ receive: Callable[[object], None] = getattr(listener, name)
80
+ return receive
@@ -0,0 +1,24 @@
1
+ """Registers the one ApplicationEventPublisher with wireup, so no application has to.
2
+
3
+ Spring always provides an `ApplicationEventPublisher`, with or without listeners; this
4
+ module is the library's equivalent. A component scan that covers the library finds it;
5
+ otherwise an application lists this module among wireup's injectables.
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+
10
+ from wireup import injectable
11
+
12
+ from snakeboot.events.application_event_listener import ApplicationEventListener
13
+ from snakeboot.events.application_event_publisher import ApplicationEventPublisher
14
+
15
+
16
+ @injectable
17
+ def application_event_publisher(listeners: Sequence[ApplicationEventListener] = ()) -> ApplicationEventPublisher:
18
+ """Provides the publisher, connected to every `@application_event_listener` wireup knows.
19
+
20
+ Wireup only creates `Sequence[ApplicationEventListener]` when at least one listener
21
+ exists. Without the default, an application without listeners would not even start;
22
+ with it, wireup leaves the parameter out and the publisher simply has nobody to tell.
23
+ """
24
+ return ApplicationEventPublisher(listeners)
@@ -0,0 +1,41 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+ from typing import Any, Final
4
+
5
+ LISTENER_MAPPING_ATTRIBUTE: Final = "__application_event_listener_mapping__"
6
+
7
+ type MethodMarker = Callable[[Callable[..., Any]], Callable[..., Any]]
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ListenerMapping:
12
+ """What a listener marker records on a method: the events it receives and when."""
13
+
14
+ event_types: tuple[type, ...]
15
+ after_commit: bool
16
+
17
+
18
+ def event_listener(*event_types: type) -> MethodMarker:
19
+ """Marks a method that receives `event_types` at once, inside the publisher's transaction (`@EventListener`).
20
+
21
+ Use it for work that must commit or roll back together with the change that caused the event.
22
+ """
23
+ return _mark(event_types, after_commit=False)
24
+
25
+
26
+ def transactional_event_listener(*event_types: type) -> MethodMarker:
27
+ """Marks a method that receives `event_types` once the transaction has committed.
28
+
29
+ Spring: `@TransactionalEventListener(phase = AFTER_COMMIT)`. Use it for effects outside the
30
+ database, such as notifications: they are delivered through `after_commit`, so a rolled-back
31
+ change never reaches anyone, and a failing listener cannot undo the committed change.
32
+ """
33
+ return _mark(event_types, after_commit=True)
34
+
35
+
36
+ def _mark(event_types: tuple[type, ...], *, after_commit: bool) -> MethodMarker:
37
+ def record(method: Callable[..., Any]) -> Callable[..., Any]:
38
+ setattr(method, LISTENER_MAPPING_ATTRIBUTE, ListenerMapping(event_types, after_commit))
39
+ return method
40
+
41
+ return record
snakeboot/py.typed ADDED
File without changes
@@ -0,0 +1,47 @@
1
+ """Technical library: Spring-style singleton REST controllers on Django Ninja, wired by wireup.
2
+
3
+ @rest_controller("/workshops", tags=["workshops"]) # @RestController + @RequestMapping
4
+ class WorkshopRestController:
5
+ def __init__(self, enrollment: EnrollmentFacadePort) -> None: # constructor injection
6
+ self._enrollment = enrollment
7
+
8
+ @route.get("/{workshop_id}", response=WorkshopViewDTO) # @GetMapping
9
+ def get_workshop(self, request: HttpRequest, workshop_id: str) -> WorkshopViewDTO: ...
10
+
11
+ @rest_controller_advice # @RestControllerAdvice
12
+ class EnrollmentRestErrorAdvice:
13
+ @exception_handler(WorkshopNotFoundError, status=404) # @ExceptionHandler
14
+ def answer_not_found(self, error: Exception) -> dict[str, str]:
15
+ return error_answer(error)
16
+
17
+ Composition mounts every controller and advice it finds in the modules wireup scans:
18
+
19
+ RestControllerMounter().mount(api, scan=settings.WIREUP.injectables or ())
20
+
21
+ It knows Django Ninja and wireup, but nothing of the application (extra: snakeboot[ninja]).
22
+ """
23
+
24
+ from snakeboot.rest.authenticated_principal import authenticated_principal
25
+ from snakeboot.rest.controller_endpoint_factory import ControllerEndpointFactory
26
+ from snakeboot.rest.error_answer import error_answer
27
+ from snakeboot.rest.rest_controller import rest_controller
28
+ from snakeboot.rest.rest_controller_advice import exception_handler, rest_controller_advice
29
+ from snakeboot.rest.rest_controller_errors import InvalidRestControllerError, NoAuthenticatedPrincipalError
30
+ from snakeboot.rest.rest_controller_mounter import MountedRestComponents, RestControllerMounter
31
+ from snakeboot.rest.rest_controller_scanner import RestControllerScanner
32
+ from snakeboot.rest.route_markers import route
33
+
34
+ __all__ = [
35
+ "ControllerEndpointFactory",
36
+ "InvalidRestControllerError",
37
+ "MountedRestComponents",
38
+ "NoAuthenticatedPrincipalError",
39
+ "RestControllerMounter",
40
+ "RestControllerScanner",
41
+ "authenticated_principal",
42
+ "error_answer",
43
+ "exception_handler",
44
+ "rest_controller",
45
+ "rest_controller_advice",
46
+ "route",
47
+ ]
@@ -0,0 +1,15 @@
1
+ from django.http import HttpRequest
2
+
3
+ from snakeboot.rest.rest_controller_errors import NoAuthenticatedPrincipalError
4
+
5
+
6
+ def authenticated_principal(request: HttpRequest) -> str:
7
+ """Returns the id that the API's authentication stored on `request.auth` (Spring: `@AuthenticationPrincipal`).
8
+
9
+ Ninja adds `auth` to the request at run time, so a type checker cannot see it. This is
10
+ the one place that reads it, so controllers stay fully typed.
11
+ """
12
+ principal = request.auth # type: ignore[attr-defined]
13
+ if not isinstance(principal, str):
14
+ raise NoAuthenticatedPrincipalError(f"Expected the id of a signed-in principal, got {principal!r}.")
15
+ return principal
@@ -0,0 +1,72 @@
1
+ import functools
2
+ import inspect
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from django.http import HttpRequest, HttpResponse
7
+ from ninja import NinjaAPI
8
+ from wireup.integration.django import get_request_container
9
+
10
+ from snakeboot.rest.rest_controller_errors import InvalidRestControllerError
11
+
12
+ ControllerResolver = Callable[[type], object]
13
+ ErrorAnswer = Callable[[HttpRequest, Exception], HttpResponse]
14
+
15
+
16
+ def resolve_from_wireup_request_scope(controller_class: type) -> object:
17
+ """Asks wireup for the controller during the current request; a singleton is the same object every time."""
18
+ return get_request_container().get(controller_class)
19
+
20
+
21
+ class ControllerEndpointFactory:
22
+ """Turns one controller or advice method into a function Django Ninja can call.
23
+
24
+ Ninja inspects a view's signature to read path, query and body parameters. A method
25
+ has `self` in front, which Ninja would take for a query parameter, so the endpoint
26
+ shows Ninja the signature *without* `self`. When called, the endpoint asks the
27
+ resolver for the controller instance and calls the method on it.
28
+
29
+ Resolving per call (not once at start-up) keeps two things working: wireup's
30
+ request scope under both WSGI and ASGI, and wireup overrides in tests.
31
+ """
32
+
33
+ def __init__(self, resolve_controller: ControllerResolver = resolve_from_wireup_request_scope) -> None:
34
+ self._resolve_controller = resolve_controller
35
+
36
+ def create(self, controller_class: type, method: Callable[..., Any]) -> Callable[..., Any]:
37
+ """Returns the Ninja view for `method` of `controller_class`."""
38
+ signature = inspect.signature(method)
39
+ parameters_after_self = self._parameters_after_self(controller_class, method)
40
+ resolve_controller = self._resolve_controller
41
+
42
+ @functools.wraps(method)
43
+ def endpoint(*args: Any, **kwargs: Any) -> Any:
44
+ return method(resolve_controller(controller_class), *args, **kwargs)
45
+
46
+ endpoint.__signature__ = signature.replace(parameters=parameters_after_self) # type: ignore[attr-defined]
47
+ return endpoint
48
+
49
+ def create_error_answer(
50
+ self, advice_class: type, method: Callable[..., Any], *, api: NinjaAPI, status: int
51
+ ) -> ErrorAnswer:
52
+ """Returns the Ninja exception handler that answers with `status` and the body `method` builds."""
53
+ self._parameters_after_self(advice_class, method)
54
+ resolve_advice = self._resolve_controller
55
+
56
+ def answer(request: HttpRequest, error: Exception) -> HttpResponse:
57
+ body = method(resolve_advice(advice_class), error)
58
+ return api.create_response(request, body, status=status)
59
+
60
+ return answer
61
+
62
+ def _parameters_after_self(self, owner: type, method: Callable[..., Any]) -> list[inspect.Parameter]:
63
+ if inspect.iscoroutinefunction(method):
64
+ raise InvalidRestControllerError(
65
+ f"{owner.__name__}.{method.__name__} is async; controller endpoints are synchronous."
66
+ )
67
+ parameters = list(inspect.signature(method).parameters.values())
68
+ if not parameters or parameters[0].name != "self":
69
+ raise InvalidRestControllerError(
70
+ f"{owner.__name__}.{method.__name__} must be a regular method taking `self` first."
71
+ )
72
+ return parameters[1:]
@@ -0,0 +1,14 @@
1
+ import re
2
+ from typing import Final
3
+
4
+ _WORD_BOUNDARY: Final = re.compile(r"(?<!^)(?=[A-Z])")
5
+
6
+
7
+ def error_answer(error: Exception) -> dict[str, str]:
8
+ """Builds the error body `{"code": ..., "detail": ...}`, the shape of Spring's `ProblemDetail` in small.
9
+
10
+ `code` is derived from the class name (`AlreadyRegisteredError` -> `already_registered`),
11
+ so a client can rely on it; `detail` is the message, meant for developers.
12
+ """
13
+ name = type(error).__name__.removesuffix("Error")
14
+ return {"code": _WORD_BOUNDARY.sub("_", name).lower(), "detail": str(error)}
@@ -0,0 +1,25 @@
1
+ from collections.abc import Callable, Sequence
2
+
3
+ from wireup import injectable
4
+
5
+ from snakeboot.rest.rest_controller_errors import InvalidRestControllerError
6
+ from snakeboot.rest.rest_controller_mappings import CONTROLLER_MAPPING_ATTRIBUTE, ControllerMapping
7
+
8
+
9
+ def rest_controller[C: type](prefix: str, *, tags: Sequence[str] = ()) -> Callable[[C], C]:
10
+ """Declares a REST controller, like Spring's `@RestController` with `@RequestMapping(prefix)`.
11
+
12
+ The class becomes a wireup singleton (do not add `@injectable` yourself): its
13
+ constructor receives facade ports by type, it is created once, and the container
14
+ refuses to start when a dependency is missing. RestControllerMounter finds it by
15
+ scanning and turns its `@route` methods into Ninja endpoints under `prefix`.
16
+ """
17
+ if prefix != "" and not prefix.startswith("/"):
18
+ raise InvalidRestControllerError(f"A controller prefix starts with '/', got {prefix!r}.")
19
+
20
+ def declare(controller_class: C) -> C:
21
+ mapping = ControllerMapping(prefix=prefix, tags=tuple(tags) or (controller_class.__name__,))
22
+ setattr(controller_class, CONTROLLER_MAPPING_ATTRIBUTE, mapping)
23
+ return injectable(controller_class)
24
+
25
+ return declare