snakeboot 0.5.2__tar.gz → 0.7.0__tar.gz

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 (113) hide show
  1. {snakeboot-0.5.2 → snakeboot-0.7.0}/PKG-INFO +167 -24
  2. snakeboot-0.7.0/README.md +355 -0
  3. {snakeboot-0.5.2 → snakeboot-0.7.0}/pyproject.toml +221 -4
  4. {snakeboot-0.5.2 → snakeboot-0.7.0}/pyproject.toml.orig +219 -5
  5. snakeboot-0.7.0/src/snakeboot/__init__.py +27 -0
  6. snakeboot-0.7.0/src/snakeboot/boot/__init__.py +16 -0
  7. snakeboot-0.7.0/src/snakeboot/command_line/__init__.py +31 -0
  8. snakeboot-0.7.0/src/snakeboot/command_line/command_line.py +67 -0
  9. snakeboot-0.7.0/src/snakeboot/command_line/command_line_errors.py +14 -0
  10. snakeboot-0.7.0/src/snakeboot/command_line/command_line_runner.py +94 -0
  11. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/component_scan/__init__.py +8 -6
  12. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/component_scan/application_root.py +3 -3
  13. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/component_scan/component_scan.py +81 -31
  14. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/__init__.py +13 -6
  15. snakeboot-0.7.0/src/snakeboot/configuration/cache_properties.py +12 -0
  16. snakeboot-0.7.0/src/snakeboot/configuration/database_properties.py +19 -0
  17. snakeboot-0.7.0/src/snakeboot/configuration/rabbitmq_properties.py +39 -0
  18. snakeboot-0.7.0/src/snakeboot/configuration/scheduling_properties.py +16 -0
  19. snakeboot-0.7.0/src/snakeboot/configuration/server_properties.py +22 -0
  20. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/__init__.py +6 -11
  21. snakeboot-0.7.0/src/snakeboot/django_application/background_work.py +56 -0
  22. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/django_settings.py +51 -15
  23. snakeboot-0.7.0/src/snakeboot/django_application/entry_points.py +136 -0
  24. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/installed_apps.py +5 -4
  25. snakeboot-0.7.0/src/snakeboot/django_application/management/commands/__init__.py +0 -0
  26. snakeboot-0.7.0/src/snakeboot/django_application/management/commands/run_lifecycle.py +50 -0
  27. snakeboot-0.7.0/src/snakeboot/django_ninja/__init__.py +31 -0
  28. snakeboot-0.7.0/src/snakeboot/django_ninja/event_stream.py +37 -0
  29. snakeboot-0.7.0/src/snakeboot/django_ninja/ninja_endpoint_factory.py +87 -0
  30. snakeboot-0.7.0/src/snakeboot/django_ninja/rest_api.py +149 -0
  31. {snakeboot-0.5.2/src/snakeboot/rest → snakeboot-0.7.0/src/snakeboot/django_ninja}/urls.py +2 -2
  32. snakeboot-0.7.0/src/snakeboot/lifecycle/__init__.py +33 -0
  33. snakeboot-0.7.0/src/snakeboot/lifecycle/application_components.py +21 -0
  34. snakeboot-0.7.0/src/snakeboot/lifecycle/application_lifecycle.py +43 -0
  35. snakeboot-0.7.0/src/snakeboot/lifecycle/application_lifecycle_injectable.py +26 -0
  36. snakeboot-0.7.0/src/snakeboot/lifecycle/application_ready_event.py +16 -0
  37. snakeboot-0.7.0/src/snakeboot/lifecycle/lifecycle.py +44 -0
  38. snakeboot-0.7.0/src/snakeboot/lifecycle/lifecycle_asgi_application.py +62 -0
  39. snakeboot-0.7.0/src/snakeboot/lifecycle/task_decorator.py +15 -0
  40. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/oauth2/__init__.py +2 -2
  41. snakeboot-0.7.0/src/snakeboot/py.typed +0 -0
  42. snakeboot-0.7.0/src/snakeboot/rabbitmq/__init__.py +43 -0
  43. snakeboot-0.7.0/src/snakeboot/rabbitmq/message_conversion.py +61 -0
  44. snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbit_connection_factory.py +37 -0
  45. snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbit_listener.py +56 -0
  46. snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbit_listener_containers.py +226 -0
  47. snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbit_template.py +93 -0
  48. snakeboot-0.7.0/src/snakeboot/rabbitmq/rabbitmq_injectable.py +42 -0
  49. snakeboot-0.7.0/src/snakeboot/rest/__init__.py +67 -0
  50. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/bearer_authentication.py +8 -4
  51. snakeboot-0.7.0/src/snakeboot/rest/controller_scan.py +149 -0
  52. snakeboot-0.7.0/src/snakeboot/rest/endpoint_signature.py +87 -0
  53. snakeboot-0.7.0/src/snakeboot/rest/invalid_request_error.py +35 -0
  54. snakeboot-0.7.0/src/snakeboot/rest/principal.py +17 -0
  55. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/rest_controller.py +1 -1
  56. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/rest_controller_advice.py +3 -3
  57. snakeboot-0.7.0/src/snakeboot/rest/rest_controller_errors.py +16 -0
  58. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/rest_controller_mappings.py +26 -5
  59. snakeboot-0.7.0/src/snakeboot/rest/route_markers.py +56 -0
  60. snakeboot-0.7.0/src/snakeboot/rest/server_sent_events.py +179 -0
  61. snakeboot-0.7.0/src/snakeboot/scheduling/__init__.py +18 -0
  62. snakeboot-0.7.0/src/snakeboot/scheduling/scheduled.py +69 -0
  63. snakeboot-0.7.0/src/snakeboot/scheduling/task_scheduler.py +112 -0
  64. snakeboot-0.7.0/src/snakeboot/scheduling/task_scheduler_injectable.py +33 -0
  65. snakeboot-0.5.2/README.md +0 -215
  66. snakeboot-0.5.2/src/snakeboot/__init__.py +0 -13
  67. snakeboot-0.5.2/src/snakeboot/configuration/cache_properties.py +0 -16
  68. snakeboot-0.5.2/src/snakeboot/configuration/database_properties.py +0 -29
  69. snakeboot-0.5.2/src/snakeboot/configuration/django_properties.py +0 -19
  70. snakeboot-0.5.2/src/snakeboot/django_application/entry_points.py +0 -61
  71. snakeboot-0.5.2/src/snakeboot/rest/__init__.py +0 -51
  72. snakeboot-0.5.2/src/snakeboot/rest/authenticated_principal.py +0 -15
  73. snakeboot-0.5.2/src/snakeboot/rest/controller_endpoint_factory.py +0 -80
  74. snakeboot-0.5.2/src/snakeboot/rest/controller_scan.py +0 -131
  75. snakeboot-0.5.2/src/snakeboot/rest/rest_api.py +0 -91
  76. snakeboot-0.5.2/src/snakeboot/rest/rest_controller_errors.py +0 -14
  77. snakeboot-0.5.2/src/snakeboot/rest/route_markers.py +0 -45
  78. {snakeboot-0.5.2 → snakeboot-0.7.0}/LICENSE +0 -0
  79. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/configuration.py +0 -0
  80. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/configuration_errors.py +0 -0
  81. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/configuration_properties.py +0 -0
  82. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/configuration_sources.py +0 -0
  83. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/load_configuration.py +0 -0
  84. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/oauth2_properties.py +0 -0
  85. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/properties_rules.py +0 -0
  86. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/section.py +0 -0
  87. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/configuration/section_prefix.py +0 -0
  88. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django/__init__.py +0 -0
  89. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django/django_atomic_transaction_manager.py +0 -0
  90. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/apps.py +0 -0
  91. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/container_sections.py +0 -0
  92. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/django_configurer.py +0 -0
  93. /snakeboot-0.5.2/src/snakeboot/py.typed → /snakeboot-0.7.0/src/snakeboot/django_application/management/__init__.py +0 -0
  94. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/settings.py +0 -0
  95. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/django_application/transaction_manager.py +0 -0
  96. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/__init__.py +0 -0
  97. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/application_event_listener.py +0 -0
  98. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/application_event_publisher.py +0 -0
  99. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/application_event_publisher_injectable.py +0 -0
  100. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/events/event_listener_markers.py +0 -0
  101. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/oauth2/jwt_bearer_authentication.py +0 -0
  102. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/openapi/__init__.py +0 -0
  103. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/openapi/openapi_definition.py +0 -0
  104. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/rest/error_answer.py +0 -0
  105. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/testing/__init__.py +0 -0
  106. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/testing/declared_classes.py +0 -0
  107. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/testing/recording_transaction_manager.py +0 -0
  108. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/__init__.py +0 -0
  109. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/after_commit.py +0 -0
  110. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/transaction_manager.py +0 -0
  111. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/transaction_manager_not_installed_error.py +0 -0
  112. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/transaction_manager_registry.py +0 -0
  113. {snakeboot-0.5.2 → snakeboot-0.7.0}/src/snakeboot/transaction/transactional.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snakeboot
3
- Version: 0.5.2
3
+ Version: 0.7.0
4
4
  Summary: Spring Boot ergonomics for hexagonal Python: @transactional, application events, a component scan, typed configuration with profiles and singleton REST controllers on Django Ninja, wired by wireup.
5
5
  Keywords: spring-boot,hexagonal-architecture,ddd,django,django-ninja,wireup,dependency-injection,transactional
6
6
  Author: Wouter Deketelaere
@@ -27,6 +27,8 @@ Requires-Dist: django>=6.1.1 ; extra == 'oauth2'
27
27
  Requires-Dist: django-ninja>=1.7.1 ; extra == 'oauth2'
28
28
  Requires-Dist: pydantic-settings>=2.16.0,<3 ; extra == 'oauth2'
29
29
  Requires-Dist: pyjwt[crypto]>=2.10.1 ; extra == 'oauth2'
30
+ Requires-Dist: pydantic-settings>=2.16.0,<3 ; extra == 'rabbitmq'
31
+ Requires-Dist: pika>=1.3.2 ; extra == 'rabbitmq'
30
32
  Requires-Python: >=3.13
31
33
  Project-URL: Homepage, https://github.com/KdG-OCDI/snakeboot
32
34
  Project-URL: Repository, https://github.com/KdG-OCDI/snakeboot
@@ -36,48 +38,65 @@ Provides-Extra: configuration
36
38
  Provides-Extra: django
37
39
  Provides-Extra: ninja
38
40
  Provides-Extra: oauth2
41
+ Provides-Extra: rabbitmq
39
42
  Description-Content-Type: text/markdown
40
43
 
41
44
  # snakeboot
42
45
 
43
- Spring Boot ergonomics for hexagonal Python applications built with Django, Django Ninja and
44
- [wireup](https://github.com/maldoinc/wireup).
46
+ Spring Boot ergonomics for hexagonal Python applications, wired by [wireup](https://github.com/maldoinc/wireup)
47
+ and running on Django and Django Ninja underneath.
45
48
 
46
49
  snakeboot imitates the basics of Spring Boot, not Spring itself. Each part is a handful of small
47
50
  classes you can read in an afternoon, and none of them knows anything about your domain.
48
51
 
52
+ An application speaks snakeboot, not Django: its entry points, controllers, commands, listeners and configuration
53
+ import nothing of Django or Django Ninja, so snakeboot can bind another framework without changing them. Only the
54
+ persistence adapters, which hold the models and queries of the ORM, and the tests that use Django's database, still
55
+ see Django.
56
+
49
57
  | snakeboot | Spring Boot | Needs |
50
58
  |---|---|---|
51
59
  | `@transactional`, `after_commit` | `@Transactional`, `@TransactionalEventListener(AFTER_COMMIT)` | nothing |
52
60
  | `@application_root`, `ComponentScan` | `@SpringBootApplication`, `@ComponentScan` | nothing |
53
61
  | `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
54
- | `@rest_controller`, `@route.get`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
62
+ | `@rest_controller`, `@route.get(status=...)`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@ResponseStatus`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
63
+ | `Principal` | `@AuthenticationPrincipal` | `snakeboot[ninja]` |
64
+ | `InvalidRequestError`, `RequestProblem` | `MethodArgumentNotValidException` | `snakeboot[ninja]` |
65
+ | `ServerSentEvents`, `ServerSentEvent` | `SseEmitter`, `ServerSentEvent` | `snakeboot[ninja]` |
55
66
  | `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
56
67
  | `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@ConfigurationPropertiesScan`, profiles | `snakeboot[configuration]` |
57
- | `DjangoProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
58
- | `run`, `wsgi_application`, `asgi_application`, `django_settings` | `SpringApplication.run`, auto-configuration | `snakeboot[configuration,django]` |
59
- | `DjangoConfigurer` | `WebMvcConfigurer` | `snakeboot[configuration,django]` |
68
+ | `ServerProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
69
+ | `snakeboot.boot`: `run`, `wsgi_application`, `asgi_application` | `SpringApplication.run`, auto-configuration | `snakeboot[configuration,django]` |
70
+ | `CommandLineRunner`, `CommandLineError` | `CommandLineRunner`, Spring Shell's `@Command` | `snakeboot[configuration,django]` |
71
+ | `DjangoConfigurer`, `django_settings` | `WebMvcConfigurer` | `snakeboot[configuration,django]` |
60
72
  | `BearerAuthentication` | a `SecurityFilterChain` bean | `snakeboot[ninja]` |
61
73
  | `JwtBearerAuthentication`, `OAuth2Properties` | `spring-boot-starter-oauth2-resource-server`, `spring.security.oauth2.resourceserver.jwt.*` | `snakeboot[oauth2]` |
74
+ | `Lifecycle`, `ApplicationReadyEvent`, `run_lifecycle` | `SmartLifecycle`, `ApplicationReadyEvent`, an application without the web starter | wireup |
75
+ | `@scheduled`, `SchedulingProperties` | `@Scheduled` | `snakeboot[configuration,django]` |
76
+ | `@rabbit_listener`, `RabbitTemplate`, `RabbitProperties`, `RejectAndDontRequeueError` | `@RabbitListener`, `RabbitTemplate`, `spring.rabbitmq.*`, `AmqpRejectAndDontRequeueException` | `snakeboot[rabbitmq]` |
62
77
  | `@openapi_definition` | springdoc's `@OpenAPIDefinition` | nothing |
63
- | `rest_api`, `snakeboot.rest.urls` | the REST API and `/health` of the application | `snakeboot[ninja]` |
78
+ | `snakeboot.django_ninja` | the REST API and `/health` of the application, on Django Ninja | `snakeboot[ninja]` |
64
79
  | `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |
65
80
 
66
81
  The transaction, component scan and testing packages import nothing but the standard library,
67
- so the application layer of a hexagonal application can use `@transactional` without importing Django.
82
+ so the application layer of a hexagonal application can use `@transactional` without importing Django. The REST,
83
+ command line, event and lifecycle packages add wireup, and no web framework: a controller or a command is a module
84
+ that imports without Django.
68
85
 
69
86
  ## Install
70
87
 
71
88
  ```bash
72
89
  uv add "snakeboot[configuration,django,ninja]"
90
+ uv add "snakeboot[configuration,django,ninja,rabbitmq]" # with RabbitMQ
73
91
  ```
74
92
 
75
93
  ## A tour
76
94
 
77
95
  **The application.** A class marked with `@application_root`, like `@SpringBootApplication`, is the application.
78
96
  Its component scan decides what goes into the container: the package of the class, or the packages `base_packages`
79
- names. The scan skips Django's entry points (`wsgi.py`, `asgi.py`, migrations) and adds the components snakeboot
80
- provides itself (the event publisher). `run` starts it, like `SpringApplication.run`.
97
+ names. The scan skips the server entry points (`wsgi.py`, `asgi.py`) and migrations, and adds the components snakeboot
98
+ provides itself (the event publisher, the lifecycle, the scheduler). `run` starts it, like `SpringApplication.run`;
99
+ the entry points import it from `snakeboot.boot`, which stays the same whatever framework runs underneath.
81
100
 
82
101
  ```python
83
102
  # myapp/myapp_application.py
@@ -87,9 +106,9 @@ class MyApplication: ...
87
106
 
88
107
 
89
108
  # manage.py and myapp/__main__.py # myapp/wsgi.py, for gunicorn
90
- run(MyApplication) application = wsgi_application(MyApplication)
109
+ from snakeboot.boot import run application = wsgi_application(MyApplication)
91
110
  # myapp/asgi.py, for uvicorn (streaming, server-sent events)
92
- application = asgi_application(MyApplication)
111
+ run(MyApplication) application = asgi_application(MyApplication)
93
112
  ```
94
113
 
95
114
  The application writes no settings.py and no urls.py: snakeboot owns Django's settings module and builds every
@@ -110,7 +129,7 @@ class ChatbotProperties(Section):
110
129
  ```
111
130
 
112
131
  **Django, configured.** `django_settings(MyApplication)` builds the settings, like Spring Boot's
113
- auto-configuration: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` and `LOGGING` from `[django]`; `DATABASES` from
132
+ auto-configuration: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` and `LOGGING` from `[server]`; `DATABASES` from
114
133
  `[database]` (PostgreSQL) and `CACHES` from `[cache]` (Redis), when `application.toml` has those tables; the container
115
134
  (`WIREUP`) from the component scan; and `INSTALLED_APPS`: snakeboot, wireup and every Django `AppConfig` of the
116
135
  component scan. The scan finds them, and the models, by reading the source, so nothing is imported before Django
@@ -183,8 +202,12 @@ class ParticipantNotificationListener:
183
202
  ```
184
203
 
185
204
  **REST controllers.** Singleton classes with constructor injection. `ControllerScan` finds them in the modules of
186
- the component scan, the same modules that fill the container, and adds their routes to the API. Two endpoints for
187
- the same URL, or two answers for one error, stop the start-up.
205
+ the component scan, the same modules that fill the container, and `snakeboot.django_ninja` publishes their routes.
206
+ A controller sees no request: an endpoint takes what the call carries (path and query parameters, a body as a
207
+ Pydantic model) and returns the body, whose type is its return annotation; `status=` sets the status of the answer,
208
+ like `@ResponseStatus`. A call that does not fit (a missing field, a wrong type) is an `InvalidRequestError`, which
209
+ an advice answers like any error, as with Spring's `MethodArgumentNotValidException`. Two endpoints for the same URL,
210
+ two answers for one error, or a method that takes `request` stop the start-up.
188
211
 
189
212
  ```python
190
213
  @rest_controller("/workshops", tags=["workshops"])
@@ -192,8 +215,11 @@ class WorkshopRestController:
192
215
  def __init__(self, enrollment: EnrollmentFacadePort) -> None:
193
216
  self._enrollment = enrollment
194
217
 
195
- @route.get("/{workshop_id}", response=WorkshopViewDTO)
196
- def get_workshop(self, request: HttpRequest, workshop_id: str) -> WorkshopViewDTO: ...
218
+ @route.get("/{workshop_id}")
219
+ def get_workshop(self, workshop_id: str) -> WorkshopViewDTO: ...
220
+
221
+ @route.post("", status=201)
222
+ def create_workshop(self, body: NewWorkshopDTO) -> WorkshopViewDTO: ...
197
223
 
198
224
 
199
225
  @rest_controller_advice
@@ -202,15 +228,36 @@ class EnrollmentRestErrorAdvice:
202
228
  def answer_not_found(self, error: Exception) -> dict[str, str]:
203
229
  return error_answer(error) # {"code": "workshop_not_found", "detail": "..."}
204
230
 
231
+ @exception_handler(InvalidRequestError, status=422)
232
+ def answer_invalid_request(self, error: Exception) -> dict[str, str]:
233
+ return error_answer(error) # {"code": "invalid_request", "detail": "body.body.title: Field required"}
234
+ ```
205
235
 
206
- # what snakeboot.rest.urls does for the application
207
- ControllerScan(ComponentScan.of(MyApplication)).add_to(api)
236
+ **Server-sent events.** An endpoint that returns `ServerSentEvents` streams, like one that returns Spring's
237
+ `SseEmitter`: the web framework serves it as `text/event-stream`, uncached and unbuffered, and under ASGI a watching
238
+ client holds no thread. `ServerSentEvents.watching` covers what most streams do: it reads a value once in the call
239
+ (so an unknown id answers 404 as usual), then every `every`, and sends it whenever it changed, until `until` holds.
240
+ A silence of 15 seconds gets a comment that keeps proxies open, and a stream ends after 30 minutes, when the
241
+ browser's `EventSource` reconnects. The reads run as the framework runs synchronous code, between fresh database
242
+ connections; `ServerSentEvents(source)` takes an async iterable of `ServerSentEvent`s for any other stream.
243
+
244
+ ```python
245
+ @route.get("/games/{game_id}/events")
246
+ def watch_game(self, game_id: str) -> ServerSentEvents:
247
+ return ServerSentEvents.watching(
248
+ lambda: self._get_game.execute(GetGameRequestDTO(game_id=game_id)),
249
+ every=timedelta(milliseconds=500),
250
+ until=lambda game: game.status in FINAL_STATUSES,
251
+ as_event=lambda game: ServerSentEvent(game, event="game", id=str(game.version)), # data as JSON
252
+ )
208
253
  ```
209
254
 
210
255
  **Authentication.** An adapter that extends `BearerAuthentication` is a component of the container, like a
211
256
  `SecurityFilterChain` bean: its constructor receives sections and ports by type. With one in the component scan, every
212
- endpoint needs `Authorization: Bearer <token>`, the OpenAPI document says so, and `authenticated_principal(request)`
213
- returns whom the token belongs to. `/health` stays open; an endpoint opts out with `auth=None`.
257
+ endpoint needs `Authorization: Bearer <token>`, the OpenAPI document says so, and a parameter of type `Principal`
258
+ receives whom the token belongs to, like `@AuthenticationPrincipal`. `/health` stays open; an endpoint opts out with
259
+ `public=True`. A public endpoint that asks for a `Principal`, or a `Principal` in an API without authentication,
260
+ stops the start-up.
214
261
 
215
262
  ```python
216
263
  class KeycloakTokens(BearerAuthentication):
@@ -219,6 +266,12 @@ class KeycloakTokens(BearerAuthentication):
219
266
 
220
267
  @override
221
268
  def authenticate(self, token: str) -> str | None: ... # the subject of a valid token, or None for 401
269
+
270
+
271
+ # in a controller
272
+ @route.post("/{workshop_id}/registrations", status=201)
273
+ def register(self, workshop_id: str, participant: Principal) -> RegistrationDTO:
274
+ return self._enrollment.register(workshop_id, participant_id=participant.name)
222
275
  ```
223
276
 
224
277
  **Tokens of an authorization server.** For the JWT access tokens of Keycloak or another OAuth2 server, extend
@@ -238,10 +291,100 @@ jwks_uri = "http://keycloak:8080/realms/kareldegames/protocol/openid-connect/cer
238
291
  audience = "tictactoe" # the `aud` the tokens must hold
239
292
  ```
240
293
 
294
+ **What runs beside the requests.** Django knows no moment "the server starts"; uvicorn does, and says so with ASGI's
295
+ `lifespan`, which `asgi_application` answers. Then every `Lifecycle` component starts, like a `SmartLifecycle` bean,
296
+ and `ApplicationReadyEvent` goes to the event listeners; when uvicorn stops, they stop. Management commands
297
+ (`migrate`), WSGI and the tests start nothing. `python -m myapp run_lifecycle` runs the same lifecycle without a web
298
+ server, until SIGTERM: a process that only listens and schedules, like Spring Boot without the web starter.
299
+
300
+ ```python
301
+ @application_event_listener
302
+ class RegisterOnStartup:
303
+ @event_listener(ApplicationReadyEvent) # once, when the application has started
304
+ def register(self, _event: ApplicationReadyEvent) -> None: ...
305
+ ```
306
+
307
+ **Commands.** A subclass of `CommandLineRunner` that names its command is a command of the application, like a
308
+ `CommandLineRunner` bean: a component whose constructor receives use cases and ports, with arguments declared as with
309
+ argparse in a classmethod, because they are parsed before the command is built: `--help` answers at once, even when
310
+ the command depends on a server that is down. `python -m myapp self_play --games 20` starts the application and runs
311
+ it; `python -m myapp help` lists the commands next to the framework's own (`migrate`, `run_lifecycle`), and
312
+ `help self_play` prints its help, which is its docstring. Unlike Spring Boot, which runs every runner at each start,
313
+ the first argument chooses one, as with Spring Shell's `@Command`.
314
+ A `CommandLineError` stops the command with its message and exit status 1; two commands of one name stop the start.
315
+
316
+ ```python
317
+ class SelfPlayCommand(CommandLineRunner, command="self_play"):
318
+ """Lets the AI levels play each other and prints how strong each one is."""
319
+
320
+ def __init__(self, self_play: SelfPlayUseCase) -> None:
321
+ self._self_play = self_play
322
+
323
+ @classmethod
324
+ @override
325
+ def add_arguments(cls, parser: ArgumentParser) -> None:
326
+ parser.add_argument("--games", type=int, default=20, help="Games per pairing.")
327
+
328
+ @override
329
+ def run(self, arguments: Namespace) -> None:
330
+ if arguments.games < 1:
331
+ raise CommandLineError("Play at least one game.")
332
+ print(self._self_play.execute(SelfPlayRequestDTO(games=arguments.games)))
333
+ ```
334
+
335
+ **Scheduled methods.** `@scheduled` on a method of a component runs it periodically, like `@Scheduled`: with a fixed
336
+ rate or a fixed delay, on one thread, each run between fresh database connections. A method that raises is logged and
337
+ runs again at its next turn. `[scheduling] auto_startup = false` keeps a process from running them.
338
+
339
+ ```python
340
+ @injectable
341
+ class AbortExpiredGamesJob:
342
+ @scheduled(fixed_delay=timedelta(seconds=2))
343
+ def abort_expired_games(self) -> None: ...
344
+ ```
345
+
346
+ **RabbitMQ.** With the table `[rabbitmq]` (extra `snakeboot[rabbitmq]`, through pika), `@rabbit_listener` makes a
347
+ method of a component consume a queue, like `@RabbitListener` with `@QueueBinding`: the durable topic exchange, the
348
+ queue and the binding are declared at start-up, the JSON body becomes the pydantic model, `dict` or `bytes` the method
349
+ takes, and each listener has a thread and a connection of its own. Returning acknowledges the message;
350
+ `RejectAndDontRequeueError` or an unreadable body refuses it for good; another failure is tried three times, a second
351
+ apart, then refused, as Spring Boot's `retry.enabled` does. The `RabbitTemplate` sends persistent JSON, confirmed by the
352
+ broker. `[rabbitmq.listener] auto_startup = false` keeps a process from consuming. Under uvicorn every worker process
353
+ runs its own listeners and scheduled methods, like Spring's `concurrency`: RabbitMQ gives each message to one of them,
354
+ and a scheduled method runs once per process.
355
+
356
+ ```python
357
+ @injectable
358
+ class GameStartedListener:
359
+ def __init__(self, start_game: StartGameUseCase) -> None:
360
+ self._start_game = start_game
361
+
362
+ @rabbit_listener(queue="tictactoe.game-started", exchange="games", routing_key="game.tictactoe.started")
363
+ def on_game_started(self, message: GameStartedMessage) -> None: ... # a pydantic model
364
+
365
+
366
+ # an adapter that sends
367
+ self._rabbit.convert_and_send("games", "game.tictactoe.finished", event, message_id=f"{game_id}.finished")
368
+ ```
369
+
370
+ ```toml
371
+ [rabbitmq]
372
+ host = "rabbitmq"
373
+ port = 5672
374
+ username = "guest" # the password comes from RABBITMQ__PASSWORD
375
+ virtual_host = "/"
376
+
377
+ [rabbitmq.listener]
378
+ auto_startup = true
379
+ ```
380
+
241
381
  ## Design rules
242
382
 
243
- * Imitate the basics, not all of Spring: no AOP, no scheduling, no message brokers.
244
- * Fail at start-up, not at the first request: a broken controller, listener, root or configuration stops the application.
383
+ * Imitate the basics, not all of Spring: no AOP, no cron, no message broker other than RabbitMQ.
384
+ * Fail at start-up, not at the first request: a broken controller, command, listener, root or configuration stops the
385
+ application.
386
+ * An application imports snakeboot, not the framework underneath: only the `django*` packages of snakeboot know Django
387
+ or Django Ninja, so another framework means another binding, not other applications.
245
388
  * Each package knows as little as possible; `uv run lint-imports` enforces it.
246
389
 
247
390
  ## Develop
@@ -0,0 +1,355 @@
1
+ # snakeboot
2
+
3
+ Spring Boot ergonomics for hexagonal Python applications, wired by [wireup](https://github.com/maldoinc/wireup)
4
+ and running on Django and Django Ninja underneath.
5
+
6
+ snakeboot imitates the basics of Spring Boot, not Spring itself. Each part is a handful of small
7
+ classes you can read in an afternoon, and none of them knows anything about your domain.
8
+
9
+ An application speaks snakeboot, not Django: its entry points, controllers, commands, listeners and configuration
10
+ import nothing of Django or Django Ninja, so snakeboot can bind another framework without changing them. Only the
11
+ persistence adapters, which hold the models and queries of the ORM, and the tests that use Django's database, still
12
+ see Django.
13
+
14
+ | snakeboot | Spring Boot | Needs |
15
+ |---|---|---|
16
+ | `@transactional`, `after_commit` | `@Transactional`, `@TransactionalEventListener(AFTER_COMMIT)` | nothing |
17
+ | `@application_root`, `ComponentScan` | `@SpringBootApplication`, `@ComponentScan` | nothing |
18
+ | `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
19
+ | `@rest_controller`, `@route.get(status=...)`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@ResponseStatus`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
20
+ | `Principal` | `@AuthenticationPrincipal` | `snakeboot[ninja]` |
21
+ | `InvalidRequestError`, `RequestProblem` | `MethodArgumentNotValidException` | `snakeboot[ninja]` |
22
+ | `ServerSentEvents`, `ServerSentEvent` | `SseEmitter`, `ServerSentEvent` | `snakeboot[ninja]` |
23
+ | `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
24
+ | `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@ConfigurationPropertiesScan`, profiles | `snakeboot[configuration]` |
25
+ | `ServerProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
26
+ | `snakeboot.boot`: `run`, `wsgi_application`, `asgi_application` | `SpringApplication.run`, auto-configuration | `snakeboot[configuration,django]` |
27
+ | `CommandLineRunner`, `CommandLineError` | `CommandLineRunner`, Spring Shell's `@Command` | `snakeboot[configuration,django]` |
28
+ | `DjangoConfigurer`, `django_settings` | `WebMvcConfigurer` | `snakeboot[configuration,django]` |
29
+ | `BearerAuthentication` | a `SecurityFilterChain` bean | `snakeboot[ninja]` |
30
+ | `JwtBearerAuthentication`, `OAuth2Properties` | `spring-boot-starter-oauth2-resource-server`, `spring.security.oauth2.resourceserver.jwt.*` | `snakeboot[oauth2]` |
31
+ | `Lifecycle`, `ApplicationReadyEvent`, `run_lifecycle` | `SmartLifecycle`, `ApplicationReadyEvent`, an application without the web starter | wireup |
32
+ | `@scheduled`, `SchedulingProperties` | `@Scheduled` | `snakeboot[configuration,django]` |
33
+ | `@rabbit_listener`, `RabbitTemplate`, `RabbitProperties`, `RejectAndDontRequeueError` | `@RabbitListener`, `RabbitTemplate`, `spring.rabbitmq.*`, `AmqpRejectAndDontRequeueException` | `snakeboot[rabbitmq]` |
34
+ | `@openapi_definition` | springdoc's `@OpenAPIDefinition` | nothing |
35
+ | `snakeboot.django_ninja` | the REST API and `/health` of the application, on Django Ninja | `snakeboot[ninja]` |
36
+ | `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |
37
+
38
+ The transaction, component scan and testing packages import nothing but the standard library,
39
+ so the application layer of a hexagonal application can use `@transactional` without importing Django. The REST,
40
+ command line, event and lifecycle packages add wireup, and no web framework: a controller or a command is a module
41
+ that imports without Django.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ uv add "snakeboot[configuration,django,ninja]"
47
+ uv add "snakeboot[configuration,django,ninja,rabbitmq]" # with RabbitMQ
48
+ ```
49
+
50
+ ## A tour
51
+
52
+ **The application.** A class marked with `@application_root`, like `@SpringBootApplication`, is the application.
53
+ Its component scan decides what goes into the container: the package of the class, or the packages `base_packages`
54
+ names. The scan skips the server entry points (`wsgi.py`, `asgi.py`) and migrations, and adds the components snakeboot
55
+ provides itself (the event publisher, the lifecycle, the scheduler). `run` starts it, like `SpringApplication.run`;
56
+ the entry points import it from `snakeboot.boot`, which stays the same whatever framework runs underneath.
57
+
58
+ ```python
59
+ # myapp/myapp_application.py
60
+ @openapi_definition(title="My service", version="1.0.0")
61
+ @application_root(base_packages=("myapp_core",))
62
+ class MyApplication: ...
63
+
64
+
65
+ # manage.py and myapp/__main__.py # myapp/wsgi.py, for gunicorn
66
+ from snakeboot.boot import run application = wsgi_application(MyApplication)
67
+ # myapp/asgi.py, for uvicorn (streaming, server-sent events)
68
+ run(MyApplication) application = asgi_application(MyApplication)
69
+ ```
70
+
71
+ The application writes no settings.py and no urls.py: snakeboot owns Django's settings module and builds every
72
+ setting from the application class and its `application.toml`, next to it (`django_settings`, below). Nothing can
73
+ change them after snakeboot. The URLs are the REST API under `/api/`, described by `@openapi_definition`, and
74
+ `/health`.
75
+
76
+ **Typed configuration.** A `Section` (a frozen Pydantic model that refuses unknown keys, the base class of every
77
+ configuration model) is bound to one table of `application.toml`, like `@ConfigurationProperties`. A section is a
78
+ component of the module that declares it: when the component scan puts that module in the container, the section
79
+ comes along, like `@ConfigurationPropertiesScan`, and an adapter asks for it by type.
80
+
81
+ ```python
82
+ @configuration_properties("chatbot")
83
+ class ChatbotProperties(Section):
84
+ model: str # no default: the value is in application.toml
85
+ llm: LanguageModelProperties # a nested Section is the table [chatbot.llm]
86
+ ```
87
+
88
+ **Django, configured.** `django_settings(MyApplication)` builds the settings, like Spring Boot's
89
+ auto-configuration: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` and `LOGGING` from `[server]`; `DATABASES` from
90
+ `[database]` (PostgreSQL) and `CACHES` from `[cache]` (Redis), when `application.toml` has those tables; the container
91
+ (`WIREUP`) from the component scan; and `INSTALLED_APPS`: snakeboot, wireup and every Django `AppConfig` of the
92
+ component scan. The scan finds them, and the models, by reading the source, so nothing is imported before Django
93
+ starts; a model outside every app stops the start. When Django starts, `snakeboot.django_application` reads the
94
+ sections declared in the modules of the container, checks every table and, with a database, installs the transaction
95
+ manager of `@transactional`.
96
+
97
+ Django apps and middleware from outside the project come from a `DjangoConfigurer`, like a `WebMvcConfigurer`.
98
+ snakeboot finds it in the component scan or next to the application class, as it finds an `AppConfig`, and imports
99
+ its module while it builds the settings, so that module holds strings only:
100
+
101
+ ```python
102
+ class CorsConfigurer(DjangoConfigurer):
103
+ installed_apps = ("corsheaders",)
104
+ middleware = ("corsheaders.middleware.CorsMiddleware",) # after wireup's, before CommonMiddleware
105
+ ```
106
+
107
+ A test that needs a double takes the settings and replaces that one setting:
108
+
109
+ ```python
110
+ # settings_under_test.py, the DJANGO_SETTINGS_MODULE of the tests
111
+ globals().update(django_settings(MyApplication))
112
+ DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
113
+ ```
114
+
115
+ Without Django, `load_configuration(ChatbotProperties, package="myapp")` reads the sections it is given.
116
+
117
+ The values come from three places, a later one overriding an earlier one key by key:
118
+
119
+ | Source | Holds | Spring Boot |
120
+ |---|---|---|
121
+ | `application.toml` in the package | every value the application runs with in production | `application.yml` |
122
+ | `application-<profile>.toml`, when `SNAKEBOOT_PROFILE` names one | what differs on a developer machine, say | `application-local.yml`, `SPRING_PROFILES_ACTIVE` |
123
+ | environment variables `SECTION__KEY__SUBKEY` | secrets and addresses of one deployment | relaxed binding |
124
+
125
+ Nothing else is read: no `.env` file, no default in code, no command line. The start stops when a model does
126
+ not extend `Section`, changes its `model_config` or has a default; when a file or a value is missing; when a key
127
+ or a table is unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced,
128
+ so put the variant without keys of its own in `application.toml` when a profile or the environment may choose
129
+ another.
130
+
131
+ **Transactions.** A use case declares its boundary; composition chooses the technology.
132
+
133
+ ```python
134
+ @injectable
135
+ class CancelRegistrationUseCase:
136
+ @transactional
137
+ def execute(self, request: CancelRegistrationRequestDTO) -> None:
138
+ workshop = self._workshops.get(...)
139
+ workshop.cancel_registration(...)
140
+ self._workshops.save(workshop)
141
+ self._events.publish_all(workshop.pull_events())
142
+
143
+
144
+ # at start-up; snakeboot.django_application does this itself when the application has a database
145
+ TransactionManagerRegistry.install(DjangoAtomicTransactionManager())
146
+ ```
147
+
148
+ **Application events.** Listeners react to facts after the commit, so a rolled-back change never
149
+ reaches anyone, and a failing listener cannot undo a committed one.
150
+
151
+ ```python
152
+ @application_event_listener
153
+ class ParticipantNotificationListener:
154
+ def __init__(self, notifications: NotificationPort) -> None:
155
+ self._notifications = notifications
156
+
157
+ @transactional_event_listener(WaitlistedParticipantPromoted)
158
+ def notify_promoted(self, event: WaitlistedParticipantPromoted) -> None: ...
159
+ ```
160
+
161
+ **REST controllers.** Singleton classes with constructor injection. `ControllerScan` finds them in the modules of
162
+ the component scan, the same modules that fill the container, and `snakeboot.django_ninja` publishes their routes.
163
+ A controller sees no request: an endpoint takes what the call carries (path and query parameters, a body as a
164
+ Pydantic model) and returns the body, whose type is its return annotation; `status=` sets the status of the answer,
165
+ like `@ResponseStatus`. A call that does not fit (a missing field, a wrong type) is an `InvalidRequestError`, which
166
+ an advice answers like any error, as with Spring's `MethodArgumentNotValidException`. Two endpoints for the same URL,
167
+ two answers for one error, or a method that takes `request` stop the start-up.
168
+
169
+ ```python
170
+ @rest_controller("/workshops", tags=["workshops"])
171
+ class WorkshopRestController:
172
+ def __init__(self, enrollment: EnrollmentFacadePort) -> None:
173
+ self._enrollment = enrollment
174
+
175
+ @route.get("/{workshop_id}")
176
+ def get_workshop(self, workshop_id: str) -> WorkshopViewDTO: ...
177
+
178
+ @route.post("", status=201)
179
+ def create_workshop(self, body: NewWorkshopDTO) -> WorkshopViewDTO: ...
180
+
181
+
182
+ @rest_controller_advice
183
+ class EnrollmentRestErrorAdvice:
184
+ @exception_handler(WorkshopNotFoundError, status=404)
185
+ def answer_not_found(self, error: Exception) -> dict[str, str]:
186
+ return error_answer(error) # {"code": "workshop_not_found", "detail": "..."}
187
+
188
+ @exception_handler(InvalidRequestError, status=422)
189
+ def answer_invalid_request(self, error: Exception) -> dict[str, str]:
190
+ return error_answer(error) # {"code": "invalid_request", "detail": "body.body.title: Field required"}
191
+ ```
192
+
193
+ **Server-sent events.** An endpoint that returns `ServerSentEvents` streams, like one that returns Spring's
194
+ `SseEmitter`: the web framework serves it as `text/event-stream`, uncached and unbuffered, and under ASGI a watching
195
+ client holds no thread. `ServerSentEvents.watching` covers what most streams do: it reads a value once in the call
196
+ (so an unknown id answers 404 as usual), then every `every`, and sends it whenever it changed, until `until` holds.
197
+ A silence of 15 seconds gets a comment that keeps proxies open, and a stream ends after 30 minutes, when the
198
+ browser's `EventSource` reconnects. The reads run as the framework runs synchronous code, between fresh database
199
+ connections; `ServerSentEvents(source)` takes an async iterable of `ServerSentEvent`s for any other stream.
200
+
201
+ ```python
202
+ @route.get("/games/{game_id}/events")
203
+ def watch_game(self, game_id: str) -> ServerSentEvents:
204
+ return ServerSentEvents.watching(
205
+ lambda: self._get_game.execute(GetGameRequestDTO(game_id=game_id)),
206
+ every=timedelta(milliseconds=500),
207
+ until=lambda game: game.status in FINAL_STATUSES,
208
+ as_event=lambda game: ServerSentEvent(game, event="game", id=str(game.version)), # data as JSON
209
+ )
210
+ ```
211
+
212
+ **Authentication.** An adapter that extends `BearerAuthentication` is a component of the container, like a
213
+ `SecurityFilterChain` bean: its constructor receives sections and ports by type. With one in the component scan, every
214
+ endpoint needs `Authorization: Bearer <token>`, the OpenAPI document says so, and a parameter of type `Principal`
215
+ receives whom the token belongs to, like `@AuthenticationPrincipal`. `/health` stays open; an endpoint opts out with
216
+ `public=True`. A public endpoint that asks for a `Principal`, or a `Principal` in an API without authentication,
217
+ stops the start-up.
218
+
219
+ ```python
220
+ class KeycloakTokens(BearerAuthentication):
221
+ def __init__(self, properties: SecurityProperties) -> None:
222
+ self._properties = properties
223
+
224
+ @override
225
+ def authenticate(self, token: str) -> str | None: ... # the subject of a valid token, or None for 401
226
+
227
+
228
+ # in a controller
229
+ @route.post("/{workshop_id}/registrations", status=201)
230
+ def register(self, workshop_id: str, participant: Principal) -> RegistrationDTO:
231
+ return self._enrollment.register(workshop_id, participant_id=participant.name)
232
+ ```
233
+
234
+ **Tokens of an authorization server.** For the JWT access tokens of Keycloak or another OAuth2 server, extend
235
+ `JwtBearerAuthentication` (extra `snakeboot[oauth2]`) and add the table `[oauth2]`, like Spring Boot's resource
236
+ server. It fetches the issuer's public keys from `jwks_uri` and caches them, and accepts a token only when the
237
+ signature, the issuer, the audience and the expiry are right; its `sub` is the principal.
238
+
239
+ ```python
240
+ class PlayerTokens(JwtBearerAuthentication):
241
+ """The access tokens that Keycloak gives the platform's players."""
242
+ ```
243
+
244
+ ```toml
245
+ [oauth2]
246
+ issuer_uri = "http://localhost:8180/realms/kareldegames" # the `iss` of the tokens
247
+ jwks_uri = "http://keycloak:8080/realms/kareldegames/protocol/openid-connect/certs"
248
+ audience = "tictactoe" # the `aud` the tokens must hold
249
+ ```
250
+
251
+ **What runs beside the requests.** Django knows no moment "the server starts"; uvicorn does, and says so with ASGI's
252
+ `lifespan`, which `asgi_application` answers. Then every `Lifecycle` component starts, like a `SmartLifecycle` bean,
253
+ and `ApplicationReadyEvent` goes to the event listeners; when uvicorn stops, they stop. Management commands
254
+ (`migrate`), WSGI and the tests start nothing. `python -m myapp run_lifecycle` runs the same lifecycle without a web
255
+ server, until SIGTERM: a process that only listens and schedules, like Spring Boot without the web starter.
256
+
257
+ ```python
258
+ @application_event_listener
259
+ class RegisterOnStartup:
260
+ @event_listener(ApplicationReadyEvent) # once, when the application has started
261
+ def register(self, _event: ApplicationReadyEvent) -> None: ...
262
+ ```
263
+
264
+ **Commands.** A subclass of `CommandLineRunner` that names its command is a command of the application, like a
265
+ `CommandLineRunner` bean: a component whose constructor receives use cases and ports, with arguments declared as with
266
+ argparse in a classmethod, because they are parsed before the command is built: `--help` answers at once, even when
267
+ the command depends on a server that is down. `python -m myapp self_play --games 20` starts the application and runs
268
+ it; `python -m myapp help` lists the commands next to the framework's own (`migrate`, `run_lifecycle`), and
269
+ `help self_play` prints its help, which is its docstring. Unlike Spring Boot, which runs every runner at each start,
270
+ the first argument chooses one, as with Spring Shell's `@Command`.
271
+ A `CommandLineError` stops the command with its message and exit status 1; two commands of one name stop the start.
272
+
273
+ ```python
274
+ class SelfPlayCommand(CommandLineRunner, command="self_play"):
275
+ """Lets the AI levels play each other and prints how strong each one is."""
276
+
277
+ def __init__(self, self_play: SelfPlayUseCase) -> None:
278
+ self._self_play = self_play
279
+
280
+ @classmethod
281
+ @override
282
+ def add_arguments(cls, parser: ArgumentParser) -> None:
283
+ parser.add_argument("--games", type=int, default=20, help="Games per pairing.")
284
+
285
+ @override
286
+ def run(self, arguments: Namespace) -> None:
287
+ if arguments.games < 1:
288
+ raise CommandLineError("Play at least one game.")
289
+ print(self._self_play.execute(SelfPlayRequestDTO(games=arguments.games)))
290
+ ```
291
+
292
+ **Scheduled methods.** `@scheduled` on a method of a component runs it periodically, like `@Scheduled`: with a fixed
293
+ rate or a fixed delay, on one thread, each run between fresh database connections. A method that raises is logged and
294
+ runs again at its next turn. `[scheduling] auto_startup = false` keeps a process from running them.
295
+
296
+ ```python
297
+ @injectable
298
+ class AbortExpiredGamesJob:
299
+ @scheduled(fixed_delay=timedelta(seconds=2))
300
+ def abort_expired_games(self) -> None: ...
301
+ ```
302
+
303
+ **RabbitMQ.** With the table `[rabbitmq]` (extra `snakeboot[rabbitmq]`, through pika), `@rabbit_listener` makes a
304
+ method of a component consume a queue, like `@RabbitListener` with `@QueueBinding`: the durable topic exchange, the
305
+ queue and the binding are declared at start-up, the JSON body becomes the pydantic model, `dict` or `bytes` the method
306
+ takes, and each listener has a thread and a connection of its own. Returning acknowledges the message;
307
+ `RejectAndDontRequeueError` or an unreadable body refuses it for good; another failure is tried three times, a second
308
+ apart, then refused, as Spring Boot's `retry.enabled` does. The `RabbitTemplate` sends persistent JSON, confirmed by the
309
+ broker. `[rabbitmq.listener] auto_startup = false` keeps a process from consuming. Under uvicorn every worker process
310
+ runs its own listeners and scheduled methods, like Spring's `concurrency`: RabbitMQ gives each message to one of them,
311
+ and a scheduled method runs once per process.
312
+
313
+ ```python
314
+ @injectable
315
+ class GameStartedListener:
316
+ def __init__(self, start_game: StartGameUseCase) -> None:
317
+ self._start_game = start_game
318
+
319
+ @rabbit_listener(queue="tictactoe.game-started", exchange="games", routing_key="game.tictactoe.started")
320
+ def on_game_started(self, message: GameStartedMessage) -> None: ... # a pydantic model
321
+
322
+
323
+ # an adapter that sends
324
+ self._rabbit.convert_and_send("games", "game.tictactoe.finished", event, message_id=f"{game_id}.finished")
325
+ ```
326
+
327
+ ```toml
328
+ [rabbitmq]
329
+ host = "rabbitmq"
330
+ port = 5672
331
+ username = "guest" # the password comes from RABBITMQ__PASSWORD
332
+ virtual_host = "/"
333
+
334
+ [rabbitmq.listener]
335
+ auto_startup = true
336
+ ```
337
+
338
+ ## Design rules
339
+
340
+ * Imitate the basics, not all of Spring: no AOP, no cron, no message broker other than RabbitMQ.
341
+ * Fail at start-up, not at the first request: a broken controller, command, listener, root or configuration stops the
342
+ application.
343
+ * An application imports snakeboot, not the framework underneath: only the `django*` packages of snakeboot know Django
344
+ or Django Ninja, so another framework means another binding, not other applications.
345
+ * Each package knows as little as possible; `uv run lint-imports` enforces it.
346
+
347
+ ## Develop
348
+
349
+ ```bash
350
+ uv sync
351
+ uv run pre-commit install
352
+ uv run pytest # tests, including the import contracts
353
+ uv run ruff check # Clean Code rules
354
+ uv run mypy # strict
355
+ ```