snakeboot 0.2.0__tar.gz → 0.4.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.
- {snakeboot-0.2.0 → snakeboot-0.4.0}/PKG-INFO +79 -28
- snakeboot-0.4.0/README.md +195 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/pyproject.toml +40 -3
- {snakeboot-0.2.0 → snakeboot-0.4.0}/pyproject.toml.orig +40 -3
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/component_scan/__init__.py +2 -1
- snakeboot-0.4.0/src/snakeboot/component_scan/application_root.py +63 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/component_scan/component_scan.py +55 -7
- snakeboot-0.4.0/src/snakeboot/configuration/__init__.py +45 -0
- snakeboot-0.4.0/src/snakeboot/configuration/cache_properties.py +16 -0
- snakeboot-0.4.0/src/snakeboot/configuration/configuration.py +98 -0
- snakeboot-0.4.0/src/snakeboot/configuration/configuration_errors.py +11 -0
- snakeboot-0.4.0/src/snakeboot/configuration/configuration_properties.py +50 -0
- snakeboot-0.4.0/src/snakeboot/configuration/configuration_sources.py +127 -0
- snakeboot-0.4.0/src/snakeboot/configuration/database_properties.py +29 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/configuration/django_properties.py +3 -4
- snakeboot-0.4.0/src/snakeboot/configuration/load_configuration.py +42 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/configuration/properties_rules.py +21 -9
- snakeboot-0.4.0/src/snakeboot/configuration/section.py +16 -0
- snakeboot-0.4.0/src/snakeboot/configuration/section_prefix.py +36 -0
- snakeboot-0.4.0/src/snakeboot/django_application/__init__.py +30 -0
- snakeboot-0.4.0/src/snakeboot/django_application/apps.py +24 -0
- snakeboot-0.4.0/src/snakeboot/django_application/container_sections.py +33 -0
- snakeboot-0.4.0/src/snakeboot/django_application/django_configurer.py +40 -0
- snakeboot-0.4.0/src/snakeboot/django_application/django_settings.py +91 -0
- snakeboot-0.4.0/src/snakeboot/django_application/entry_points.py +50 -0
- snakeboot-0.4.0/src/snakeboot/django_application/installed_apps.py +65 -0
- snakeboot-0.4.0/src/snakeboot/django_application/settings.py +11 -0
- snakeboot-0.4.0/src/snakeboot/django_application/transaction_manager.py +12 -0
- snakeboot-0.4.0/src/snakeboot/openapi/__init__.py +14 -0
- snakeboot-0.4.0/src/snakeboot/openapi/openapi_definition.py +34 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/__init__.py +12 -8
- snakeboot-0.4.0/src/snakeboot/rest/bearer_authentication.py +34 -0
- snakeboot-0.2.0/src/snakeboot/rest/rest_controller_mounter.py → snakeboot-0.4.0/src/snakeboot/rest/controller_scan.py +34 -24
- snakeboot-0.4.0/src/snakeboot/rest/rest_api.py +91 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/rest_controller.py +2 -2
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/rest_controller_advice.py +1 -1
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/route_markers.py +1 -1
- snakeboot-0.4.0/src/snakeboot/rest/urls.py +19 -0
- snakeboot-0.2.0/README.md +0 -144
- snakeboot-0.2.0/src/snakeboot/component_scan/application_root.py +0 -56
- snakeboot-0.2.0/src/snakeboot/configuration/__init__.py +0 -35
- snakeboot-0.2.0/src/snakeboot/configuration/configuration.py +0 -55
- snakeboot-0.2.0/src/snakeboot/configuration/configuration_errors.py +0 -12
- snakeboot-0.2.0/src/snakeboot/configuration/configuration_properties.py +0 -48
- snakeboot-0.2.0/src/snakeboot/configuration/load_configuration.py +0 -107
- snakeboot-0.2.0/src/snakeboot/rest/rest_controller_scanner.py +0 -42
- {snakeboot-0.2.0 → snakeboot-0.4.0}/LICENSE +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/__init__.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/django/__init__.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/django/django_atomic_transaction_manager.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/__init__.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/application_event_listener.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/application_event_publisher.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/application_event_publisher_injectable.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/event_listener_markers.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/py.typed +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/authenticated_principal.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/controller_endpoint_factory.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/error_answer.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/rest_controller_errors.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/rest_controller_mappings.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/testing/__init__.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/testing/declared_classes.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/testing/recording_transaction_manager.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/__init__.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/after_commit.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/transaction_manager.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/transaction_manager_not_installed_error.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/transaction_manager_registry.py +0 -0
- {snakeboot-0.2.0 → snakeboot-0.4.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.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -48,7 +48,13 @@ classes you can read in an afternoon, and none of them knows anything about your
|
|
|
48
48
|
| `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
|
|
49
49
|
| `@rest_controller`, `@route.get`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
|
|
50
50
|
| `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
|
|
51
|
-
| `@configuration_properties`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@
|
|
51
|
+
| `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@ConfigurationPropertiesScan`, profiles | `snakeboot[configuration]` |
|
|
52
|
+
| `DjangoProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
|
|
53
|
+
| `run`, `wsgi_application`, `django_settings` | `SpringApplication.run`, auto-configuration | `snakeboot[configuration,django]` |
|
|
54
|
+
| `DjangoConfigurer` | `WebMvcConfigurer` | `snakeboot[configuration,django]` |
|
|
55
|
+
| `BearerAuthentication` | a `SecurityFilterChain` bean | `snakeboot[ninja]` |
|
|
56
|
+
| `@openapi_definition` | springdoc's `@OpenAPIDefinition` | nothing |
|
|
57
|
+
| `rest_api`, `snakeboot.rest.urls` | the REST API and `/health` of the application | `snakeboot[ninja]` |
|
|
52
58
|
| `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |
|
|
53
59
|
|
|
54
60
|
The transaction, component scan and testing packages import nothing but the standard library,
|
|
@@ -62,39 +68,68 @@ uv add "snakeboot[configuration,django,ninja]"
|
|
|
62
68
|
|
|
63
69
|
## A tour
|
|
64
70
|
|
|
65
|
-
**The application
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
**The application.** A class marked with `@application_root`, like `@SpringBootApplication`, is the application.
|
|
72
|
+
Its component scan decides what goes into the container: the package of the class, or the packages `base_packages`
|
|
73
|
+
names. The scan skips Django's entry points (`wsgi.py`, `asgi.py`, migrations) and adds the components snakeboot
|
|
74
|
+
provides itself (the event publisher). `run` starts it, like `SpringApplication.run`.
|
|
68
75
|
|
|
69
76
|
```python
|
|
70
77
|
# myapp/myapp_application.py
|
|
71
|
-
@
|
|
78
|
+
@openapi_definition(title="My service", version="1.0.0")
|
|
79
|
+
@application_root(base_packages=("myapp_core",))
|
|
72
80
|
class MyApplication: ...
|
|
73
81
|
|
|
74
82
|
|
|
75
|
-
#
|
|
76
|
-
|
|
83
|
+
# manage.py and myapp/__main__.py # myapp/wsgi.py, for gunicorn
|
|
84
|
+
run(MyApplication) application = wsgi_application(MyApplication)
|
|
77
85
|
```
|
|
78
86
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
The application writes no settings.py and no urls.py: snakeboot owns Django's settings module and builds every
|
|
88
|
+
setting from the application class and its `application.toml`, next to it (`django_settings`, below). Nothing can
|
|
89
|
+
change them after snakeboot. The URLs are the REST API under `/api/`, described by `@openapi_definition`, and
|
|
90
|
+
`/health`.
|
|
91
|
+
|
|
92
|
+
**Typed configuration.** A `Section` (a frozen Pydantic model that refuses unknown keys, the base class of every
|
|
93
|
+
configuration model) is bound to one table of `application.toml`, like `@ConfigurationProperties`. A section is a
|
|
94
|
+
component of the module that declares it: when the component scan puts that module in the container, the section
|
|
95
|
+
comes along, like `@ConfigurationPropertiesScan`, and an adapter asks for it by type.
|
|
82
96
|
|
|
83
97
|
```python
|
|
84
98
|
@configuration_properties("chatbot")
|
|
85
|
-
class ChatbotProperties(
|
|
86
|
-
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
87
|
-
|
|
99
|
+
class ChatbotProperties(Section):
|
|
88
100
|
model: str # no default: the value is in application.toml
|
|
89
|
-
llm: LanguageModelProperties # a nested
|
|
101
|
+
llm: LanguageModelProperties # a nested Section is the table [chatbot.llm]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Django, configured.** `django_settings(MyApplication)` builds the settings, like Spring Boot's
|
|
105
|
+
auto-configuration: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` and `LOGGING` from `[django]`; `DATABASES` from
|
|
106
|
+
`[database]` (PostgreSQL) and `CACHES` from `[cache]` (Redis), when `application.toml` has those tables; the container
|
|
107
|
+
(`WIREUP`) from the component scan; and `INSTALLED_APPS`: snakeboot, wireup and every Django `AppConfig` of the
|
|
108
|
+
component scan. The scan finds them, and the models, by reading the source, so nothing is imported before Django
|
|
109
|
+
starts; a model outside every app stops the start. When Django starts, `snakeboot.django_application` reads the
|
|
110
|
+
sections declared in the modules of the container, checks every table and, with a database, installs the transaction
|
|
111
|
+
manager of `@transactional`.
|
|
90
112
|
|
|
113
|
+
Django apps and middleware from outside the project come from a `DjangoConfigurer`, like a `WebMvcConfigurer`.
|
|
114
|
+
snakeboot finds it in the component scan or next to the application class, as it finds an `AppConfig`, and imports
|
|
115
|
+
its module while it builds the settings, so that module holds strings only:
|
|
91
116
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
117
|
+
```python
|
|
118
|
+
class CorsConfigurer(DjangoConfigurer):
|
|
119
|
+
installed_apps = ("corsheaders",)
|
|
120
|
+
middleware = ("corsheaders.middleware.CorsMiddleware",) # after wireup's, before CommonMiddleware
|
|
96
121
|
```
|
|
97
122
|
|
|
123
|
+
A test that needs a double takes the settings and replaces that one setting:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
# settings_under_test.py, the DJANGO_SETTINGS_MODULE of the tests
|
|
127
|
+
globals().update(django_settings(MyApplication))
|
|
128
|
+
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Without Django, `load_configuration(ChatbotProperties, package="myapp")` reads the sections it is given.
|
|
132
|
+
|
|
98
133
|
The values come from three places, a later one overriding an earlier one key by key:
|
|
99
134
|
|
|
100
135
|
| Source | Holds | Spring Boot |
|
|
@@ -103,10 +138,11 @@ The values come from three places, a later one overriding an earlier one key by
|
|
|
103
138
|
| `application-<profile>.toml`, when `SNAKEBOOT_PROFILE` names one | what differs on a developer machine, say | `application-local.yml`, `SPRING_PROFILES_ACTIVE` |
|
|
104
139
|
| environment variables `SECTION__KEY__SUBKEY` | secrets and addresses of one deployment | relaxed binding |
|
|
105
140
|
|
|
106
|
-
Nothing else is read: no `.env` file, no default in code, no command line. The start stops when a model
|
|
107
|
-
|
|
108
|
-
unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced,
|
|
109
|
-
variant without keys of its own in `application.toml` when a profile or the environment may choose
|
|
141
|
+
Nothing else is read: no `.env` file, no default in code, no command line. The start stops when a model does
|
|
142
|
+
not extend `Section`, changes its `model_config` or has a default; when a file or a value is missing; when a key
|
|
143
|
+
or a table is unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced,
|
|
144
|
+
so put the variant without keys of its own in `application.toml` when a profile or the environment may choose
|
|
145
|
+
another.
|
|
110
146
|
|
|
111
147
|
**Transactions.** A use case declares its boundary; composition chooses the technology.
|
|
112
148
|
|
|
@@ -121,7 +157,7 @@ class CancelRegistrationUseCase:
|
|
|
121
157
|
self._events.publish_all(workshop.pull_events())
|
|
122
158
|
|
|
123
159
|
|
|
124
|
-
#
|
|
160
|
+
# at start-up; snakeboot.django_application does this itself when the application has a database
|
|
125
161
|
TransactionManagerRegistry.install(DjangoAtomicTransactionManager())
|
|
126
162
|
```
|
|
127
163
|
|
|
@@ -138,8 +174,9 @@ class ParticipantNotificationListener:
|
|
|
138
174
|
def notify_promoted(self, event: WaitlistedParticipantPromoted) -> None: ...
|
|
139
175
|
```
|
|
140
176
|
|
|
141
|
-
**REST controllers.** Singleton classes with constructor injection
|
|
142
|
-
|
|
177
|
+
**REST controllers.** Singleton classes with constructor injection. `ControllerScan` finds them in the modules of
|
|
178
|
+
the component scan, the same modules that fill the container, and adds their routes to the API. Two endpoints for
|
|
179
|
+
the same URL, or two answers for one error, stop the start-up.
|
|
143
180
|
|
|
144
181
|
```python
|
|
145
182
|
@rest_controller("/workshops", tags=["workshops"])
|
|
@@ -158,8 +195,22 @@ class EnrollmentRestErrorAdvice:
|
|
|
158
195
|
return error_answer(error) # {"code": "workshop_not_found", "detail": "..."}
|
|
159
196
|
|
|
160
197
|
|
|
161
|
-
# urls
|
|
162
|
-
|
|
198
|
+
# what snakeboot.rest.urls does for the application
|
|
199
|
+
ControllerScan(ComponentScan.of(MyApplication)).add_to(api)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Authentication.** An adapter that extends `BearerAuthentication` is a component of the container, like a
|
|
203
|
+
`SecurityFilterChain` bean: its constructor receives sections and ports by type. With one in the component scan, every
|
|
204
|
+
endpoint needs `Authorization: Bearer <token>`, the OpenAPI document says so, and `authenticated_principal(request)`
|
|
205
|
+
returns whom the token belongs to. `/health` stays open; an endpoint opts out with `auth=None`.
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
class KeycloakTokens(BearerAuthentication):
|
|
209
|
+
def __init__(self, properties: SecurityProperties) -> None:
|
|
210
|
+
self._properties = properties
|
|
211
|
+
|
|
212
|
+
@override
|
|
213
|
+
def authenticate(self, token: str) -> str | None: ... # the subject of a valid token, or None for 401
|
|
163
214
|
```
|
|
164
215
|
|
|
165
216
|
## Design rules
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# snakeboot
|
|
2
|
+
|
|
3
|
+
Spring Boot ergonomics for hexagonal Python applications built with Django, Django Ninja and
|
|
4
|
+
[wireup](https://github.com/maldoinc/wireup).
|
|
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
|
+
| snakeboot | Spring Boot | Needs |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `@transactional`, `after_commit` | `@Transactional`, `@TransactionalEventListener(AFTER_COMMIT)` | nothing |
|
|
12
|
+
| `@application_root`, `ComponentScan` | `@SpringBootApplication`, `@ComponentScan` | nothing |
|
|
13
|
+
| `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
|
|
14
|
+
| `@rest_controller`, `@route.get`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
|
|
15
|
+
| `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
|
|
16
|
+
| `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@ConfigurationPropertiesScan`, profiles | `snakeboot[configuration]` |
|
|
17
|
+
| `DjangoProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
|
|
18
|
+
| `run`, `wsgi_application`, `django_settings` | `SpringApplication.run`, auto-configuration | `snakeboot[configuration,django]` |
|
|
19
|
+
| `DjangoConfigurer` | `WebMvcConfigurer` | `snakeboot[configuration,django]` |
|
|
20
|
+
| `BearerAuthentication` | a `SecurityFilterChain` bean | `snakeboot[ninja]` |
|
|
21
|
+
| `@openapi_definition` | springdoc's `@OpenAPIDefinition` | nothing |
|
|
22
|
+
| `rest_api`, `snakeboot.rest.urls` | the REST API and `/health` of the application | `snakeboot[ninja]` |
|
|
23
|
+
| `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |
|
|
24
|
+
|
|
25
|
+
The transaction, component scan and testing packages import nothing but the standard library,
|
|
26
|
+
so the application layer of a hexagonal application can use `@transactional` without importing Django.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv add "snakeboot[configuration,django,ninja]"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## A tour
|
|
35
|
+
|
|
36
|
+
**The application.** A class marked with `@application_root`, like `@SpringBootApplication`, is the application.
|
|
37
|
+
Its component scan decides what goes into the container: the package of the class, or the packages `base_packages`
|
|
38
|
+
names. The scan skips Django's entry points (`wsgi.py`, `asgi.py`, migrations) and adds the components snakeboot
|
|
39
|
+
provides itself (the event publisher). `run` starts it, like `SpringApplication.run`.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
# myapp/myapp_application.py
|
|
43
|
+
@openapi_definition(title="My service", version="1.0.0")
|
|
44
|
+
@application_root(base_packages=("myapp_core",))
|
|
45
|
+
class MyApplication: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# manage.py and myapp/__main__.py # myapp/wsgi.py, for gunicorn
|
|
49
|
+
run(MyApplication) application = wsgi_application(MyApplication)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The application writes no settings.py and no urls.py: snakeboot owns Django's settings module and builds every
|
|
53
|
+
setting from the application class and its `application.toml`, next to it (`django_settings`, below). Nothing can
|
|
54
|
+
change them after snakeboot. The URLs are the REST API under `/api/`, described by `@openapi_definition`, and
|
|
55
|
+
`/health`.
|
|
56
|
+
|
|
57
|
+
**Typed configuration.** A `Section` (a frozen Pydantic model that refuses unknown keys, the base class of every
|
|
58
|
+
configuration model) is bound to one table of `application.toml`, like `@ConfigurationProperties`. A section is a
|
|
59
|
+
component of the module that declares it: when the component scan puts that module in the container, the section
|
|
60
|
+
comes along, like `@ConfigurationPropertiesScan`, and an adapter asks for it by type.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
@configuration_properties("chatbot")
|
|
64
|
+
class ChatbotProperties(Section):
|
|
65
|
+
model: str # no default: the value is in application.toml
|
|
66
|
+
llm: LanguageModelProperties # a nested Section is the table [chatbot.llm]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Django, configured.** `django_settings(MyApplication)` builds the settings, like Spring Boot's
|
|
70
|
+
auto-configuration: `SECRET_KEY`, `DEBUG`, `ALLOWED_HOSTS` and `LOGGING` from `[django]`; `DATABASES` from
|
|
71
|
+
`[database]` (PostgreSQL) and `CACHES` from `[cache]` (Redis), when `application.toml` has those tables; the container
|
|
72
|
+
(`WIREUP`) from the component scan; and `INSTALLED_APPS`: snakeboot, wireup and every Django `AppConfig` of the
|
|
73
|
+
component scan. The scan finds them, and the models, by reading the source, so nothing is imported before Django
|
|
74
|
+
starts; a model outside every app stops the start. When Django starts, `snakeboot.django_application` reads the
|
|
75
|
+
sections declared in the modules of the container, checks every table and, with a database, installs the transaction
|
|
76
|
+
manager of `@transactional`.
|
|
77
|
+
|
|
78
|
+
Django apps and middleware from outside the project come from a `DjangoConfigurer`, like a `WebMvcConfigurer`.
|
|
79
|
+
snakeboot finds it in the component scan or next to the application class, as it finds an `AppConfig`, and imports
|
|
80
|
+
its module while it builds the settings, so that module holds strings only:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
class CorsConfigurer(DjangoConfigurer):
|
|
84
|
+
installed_apps = ("corsheaders",)
|
|
85
|
+
middleware = ("corsheaders.middleware.CorsMiddleware",) # after wireup's, before CommonMiddleware
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A test that needs a double takes the settings and replaces that one setting:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
# settings_under_test.py, the DJANGO_SETTINGS_MODULE of the tests
|
|
92
|
+
globals().update(django_settings(MyApplication))
|
|
93
|
+
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Without Django, `load_configuration(ChatbotProperties, package="myapp")` reads the sections it is given.
|
|
97
|
+
|
|
98
|
+
The values come from three places, a later one overriding an earlier one key by key:
|
|
99
|
+
|
|
100
|
+
| Source | Holds | Spring Boot |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `application.toml` in the package | every value the application runs with in production | `application.yml` |
|
|
103
|
+
| `application-<profile>.toml`, when `SNAKEBOOT_PROFILE` names one | what differs on a developer machine, say | `application-local.yml`, `SPRING_PROFILES_ACTIVE` |
|
|
104
|
+
| environment variables `SECTION__KEY__SUBKEY` | secrets and addresses of one deployment | relaxed binding |
|
|
105
|
+
|
|
106
|
+
Nothing else is read: no `.env` file, no default in code, no command line. The start stops when a model does
|
|
107
|
+
not extend `Section`, changes its `model_config` or has a default; when a file or a value is missing; when a key
|
|
108
|
+
or a table is unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced,
|
|
109
|
+
so put the variant without keys of its own in `application.toml` when a profile or the environment may choose
|
|
110
|
+
another.
|
|
111
|
+
|
|
112
|
+
**Transactions.** A use case declares its boundary; composition chooses the technology.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
@injectable
|
|
116
|
+
class CancelRegistrationUseCase:
|
|
117
|
+
@transactional
|
|
118
|
+
def execute(self, request: CancelRegistrationRequestDTO) -> None:
|
|
119
|
+
workshop = self._workshops.get(...)
|
|
120
|
+
workshop.cancel_registration(...)
|
|
121
|
+
self._workshops.save(workshop)
|
|
122
|
+
self._events.publish_all(workshop.pull_events())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# at start-up; snakeboot.django_application does this itself when the application has a database
|
|
126
|
+
TransactionManagerRegistry.install(DjangoAtomicTransactionManager())
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Application events.** Listeners react to facts after the commit, so a rolled-back change never
|
|
130
|
+
reaches anyone, and a failing listener cannot undo a committed one.
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
@application_event_listener
|
|
134
|
+
class ParticipantNotificationListener:
|
|
135
|
+
def __init__(self, notifications: NotificationPort) -> None:
|
|
136
|
+
self._notifications = notifications
|
|
137
|
+
|
|
138
|
+
@transactional_event_listener(WaitlistedParticipantPromoted)
|
|
139
|
+
def notify_promoted(self, event: WaitlistedParticipantPromoted) -> None: ...
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**REST controllers.** Singleton classes with constructor injection. `ControllerScan` finds them in the modules of
|
|
143
|
+
the component scan, the same modules that fill the container, and adds their routes to the API. Two endpoints for
|
|
144
|
+
the same URL, or two answers for one error, stop the start-up.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
@rest_controller("/workshops", tags=["workshops"])
|
|
148
|
+
class WorkshopRestController:
|
|
149
|
+
def __init__(self, enrollment: EnrollmentFacadePort) -> None:
|
|
150
|
+
self._enrollment = enrollment
|
|
151
|
+
|
|
152
|
+
@route.get("/{workshop_id}", response=WorkshopViewDTO)
|
|
153
|
+
def get_workshop(self, request: HttpRequest, workshop_id: str) -> WorkshopViewDTO: ...
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@rest_controller_advice
|
|
157
|
+
class EnrollmentRestErrorAdvice:
|
|
158
|
+
@exception_handler(WorkshopNotFoundError, status=404)
|
|
159
|
+
def answer_not_found(self, error: Exception) -> dict[str, str]:
|
|
160
|
+
return error_answer(error) # {"code": "workshop_not_found", "detail": "..."}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# what snakeboot.rest.urls does for the application
|
|
164
|
+
ControllerScan(ComponentScan.of(MyApplication)).add_to(api)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Authentication.** An adapter that extends `BearerAuthentication` is a component of the container, like a
|
|
168
|
+
`SecurityFilterChain` bean: its constructor receives sections and ports by type. With one in the component scan, every
|
|
169
|
+
endpoint needs `Authorization: Bearer <token>`, the OpenAPI document says so, and `authenticated_principal(request)`
|
|
170
|
+
returns whom the token belongs to. `/health` stays open; an endpoint opts out with `auth=None`.
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
class KeycloakTokens(BearerAuthentication):
|
|
174
|
+
def __init__(self, properties: SecurityProperties) -> None:
|
|
175
|
+
self._properties = properties
|
|
176
|
+
|
|
177
|
+
@override
|
|
178
|
+
def authenticate(self, token: str) -> str | None: ... # the subject of a valid token, or None for 401
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Design rules
|
|
182
|
+
|
|
183
|
+
* Imitate the basics, not all of Spring: no AOP, no scheduling, no message brokers.
|
|
184
|
+
* Fail at start-up, not at the first request: a broken controller, listener, root or configuration stops the application.
|
|
185
|
+
* Each package knows as little as possible; `uv run lint-imports` enforces it.
|
|
186
|
+
|
|
187
|
+
## Develop
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
uv sync
|
|
191
|
+
uv run pre-commit install
|
|
192
|
+
uv run pytest # tests, including the import contracts
|
|
193
|
+
uv run ruff check # Clean Code rules
|
|
194
|
+
uv run mypy # strict
|
|
195
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "snakeboot"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -93,6 +93,7 @@ forbidden_modules = [
|
|
|
93
93
|
"snakeboot.events",
|
|
94
94
|
"snakeboot.rest",
|
|
95
95
|
"snakeboot.django",
|
|
96
|
+
"snakeboot.django_application",
|
|
96
97
|
"snakeboot.testing",
|
|
97
98
|
]
|
|
98
99
|
|
|
@@ -111,6 +112,7 @@ forbidden_modules = [
|
|
|
111
112
|
"snakeboot.events",
|
|
112
113
|
"snakeboot.rest",
|
|
113
114
|
"snakeboot.django",
|
|
115
|
+
"snakeboot.django_application",
|
|
114
116
|
"snakeboot.testing",
|
|
115
117
|
]
|
|
116
118
|
|
|
@@ -126,19 +128,20 @@ forbidden_modules = [
|
|
|
126
128
|
"snakeboot.component_scan",
|
|
127
129
|
"snakeboot.rest",
|
|
128
130
|
"snakeboot.django",
|
|
131
|
+
"snakeboot.django_application",
|
|
129
132
|
"snakeboot.testing",
|
|
130
133
|
]
|
|
131
134
|
|
|
132
135
|
[[tool.importlinter.contracts]]
|
|
133
|
-
name = "rest knows Ninja and
|
|
136
|
+
name = "rest knows Ninja, wireup and the component scan, but not the transaction and event libraries"
|
|
134
137
|
type = "forbidden"
|
|
135
138
|
source_modules = ["snakeboot.rest"]
|
|
136
139
|
forbidden_modules = [
|
|
137
140
|
"snakeboot.configuration",
|
|
138
141
|
"snakeboot.transaction",
|
|
139
142
|
"snakeboot.events",
|
|
140
|
-
"snakeboot.component_scan",
|
|
141
143
|
"snakeboot.django",
|
|
144
|
+
"snakeboot.django_application",
|
|
142
145
|
"snakeboot.testing",
|
|
143
146
|
]
|
|
144
147
|
|
|
@@ -153,6 +156,7 @@ forbidden_modules = [
|
|
|
153
156
|
"snakeboot.component_scan",
|
|
154
157
|
"snakeboot.events",
|
|
155
158
|
"snakeboot.rest",
|
|
159
|
+
"snakeboot.django_application",
|
|
156
160
|
"snakeboot.testing",
|
|
157
161
|
]
|
|
158
162
|
|
|
@@ -170,6 +174,7 @@ forbidden_modules = [
|
|
|
170
174
|
"snakeboot.rest",
|
|
171
175
|
"snakeboot.events",
|
|
172
176
|
"snakeboot.django",
|
|
177
|
+
"snakeboot.django_application",
|
|
173
178
|
]
|
|
174
179
|
|
|
175
180
|
[[tool.importlinter.contracts]]
|
|
@@ -184,6 +189,38 @@ forbidden_modules = [
|
|
|
184
189
|
"snakeboot.events",
|
|
185
190
|
"snakeboot.rest",
|
|
186
191
|
"snakeboot.django",
|
|
192
|
+
"snakeboot.django_application",
|
|
193
|
+
"snakeboot.testing",
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
[[tool.importlinter.contracts]]
|
|
197
|
+
name = "django_application starts the container, the configuration and the transactions on Django, and nothing else"
|
|
198
|
+
type = "forbidden"
|
|
199
|
+
source_modules = ["snakeboot.django_application"]
|
|
200
|
+
forbidden_modules = [
|
|
201
|
+
"ninja",
|
|
202
|
+
"snakeboot.events",
|
|
203
|
+
"snakeboot.rest",
|
|
204
|
+
"snakeboot.testing",
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
[[tool.importlinter.contracts]]
|
|
208
|
+
name = "openapi is standard library only, so an application class can use it before Django starts"
|
|
209
|
+
type = "forbidden"
|
|
210
|
+
source_modules = ["snakeboot.openapi"]
|
|
211
|
+
forbidden_modules = [
|
|
212
|
+
"django",
|
|
213
|
+
"ninja",
|
|
214
|
+
"wireup",
|
|
215
|
+
"pydantic",
|
|
216
|
+
"pydantic_settings",
|
|
217
|
+
"snakeboot.configuration",
|
|
218
|
+
"snakeboot.component_scan",
|
|
219
|
+
"snakeboot.transaction",
|
|
220
|
+
"snakeboot.events",
|
|
221
|
+
"snakeboot.rest",
|
|
222
|
+
"snakeboot.django",
|
|
223
|
+
"snakeboot.django_application",
|
|
187
224
|
"snakeboot.testing",
|
|
188
225
|
]
|
|
189
226
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "snakeboot"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "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
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -90,6 +90,7 @@ forbidden_modules = [
|
|
|
90
90
|
"snakeboot.events",
|
|
91
91
|
"snakeboot.rest",
|
|
92
92
|
"snakeboot.django",
|
|
93
|
+
"snakeboot.django_application",
|
|
93
94
|
"snakeboot.testing",
|
|
94
95
|
]
|
|
95
96
|
|
|
@@ -108,6 +109,7 @@ forbidden_modules = [
|
|
|
108
109
|
"snakeboot.events",
|
|
109
110
|
"snakeboot.rest",
|
|
110
111
|
"snakeboot.django",
|
|
112
|
+
"snakeboot.django_application",
|
|
111
113
|
"snakeboot.testing",
|
|
112
114
|
]
|
|
113
115
|
|
|
@@ -123,19 +125,20 @@ forbidden_modules = [
|
|
|
123
125
|
"snakeboot.component_scan",
|
|
124
126
|
"snakeboot.rest",
|
|
125
127
|
"snakeboot.django",
|
|
128
|
+
"snakeboot.django_application",
|
|
126
129
|
"snakeboot.testing",
|
|
127
130
|
]
|
|
128
131
|
|
|
129
132
|
[[tool.importlinter.contracts]]
|
|
130
|
-
name = "rest knows Ninja and
|
|
133
|
+
name = "rest knows Ninja, wireup and the component scan, but not the transaction and event libraries"
|
|
131
134
|
type = "forbidden"
|
|
132
135
|
source_modules = ["snakeboot.rest"]
|
|
133
136
|
forbidden_modules = [
|
|
134
137
|
"snakeboot.configuration",
|
|
135
138
|
"snakeboot.transaction",
|
|
136
139
|
"snakeboot.events",
|
|
137
|
-
"snakeboot.component_scan",
|
|
138
140
|
"snakeboot.django",
|
|
141
|
+
"snakeboot.django_application",
|
|
139
142
|
"snakeboot.testing",
|
|
140
143
|
]
|
|
141
144
|
|
|
@@ -150,6 +153,7 @@ forbidden_modules = [
|
|
|
150
153
|
"snakeboot.component_scan",
|
|
151
154
|
"snakeboot.events",
|
|
152
155
|
"snakeboot.rest",
|
|
156
|
+
"snakeboot.django_application",
|
|
153
157
|
"snakeboot.testing",
|
|
154
158
|
]
|
|
155
159
|
|
|
@@ -167,6 +171,7 @@ forbidden_modules = [
|
|
|
167
171
|
"snakeboot.rest",
|
|
168
172
|
"snakeboot.events",
|
|
169
173
|
"snakeboot.django",
|
|
174
|
+
"snakeboot.django_application",
|
|
170
175
|
]
|
|
171
176
|
|
|
172
177
|
[[tool.importlinter.contracts]]
|
|
@@ -181,6 +186,38 @@ forbidden_modules = [
|
|
|
181
186
|
"snakeboot.events",
|
|
182
187
|
"snakeboot.rest",
|
|
183
188
|
"snakeboot.django",
|
|
189
|
+
"snakeboot.django_application",
|
|
190
|
+
"snakeboot.testing",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
[[tool.importlinter.contracts]]
|
|
194
|
+
name = "django_application starts the container, the configuration and the transactions on Django, and nothing else"
|
|
195
|
+
type = "forbidden"
|
|
196
|
+
source_modules = ["snakeboot.django_application"]
|
|
197
|
+
forbidden_modules = [
|
|
198
|
+
"ninja",
|
|
199
|
+
"snakeboot.events",
|
|
200
|
+
"snakeboot.rest",
|
|
201
|
+
"snakeboot.testing",
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
[[tool.importlinter.contracts]]
|
|
205
|
+
name = "openapi is standard library only, so an application class can use it before Django starts"
|
|
206
|
+
type = "forbidden"
|
|
207
|
+
source_modules = ["snakeboot.openapi"]
|
|
208
|
+
forbidden_modules = [
|
|
209
|
+
"django",
|
|
210
|
+
"ninja",
|
|
211
|
+
"wireup",
|
|
212
|
+
"pydantic",
|
|
213
|
+
"pydantic_settings",
|
|
214
|
+
"snakeboot.configuration",
|
|
215
|
+
"snakeboot.component_scan",
|
|
216
|
+
"snakeboot.transaction",
|
|
217
|
+
"snakeboot.events",
|
|
218
|
+
"snakeboot.rest",
|
|
219
|
+
"snakeboot.django",
|
|
220
|
+
"snakeboot.django_application",
|
|
184
221
|
"snakeboot.testing",
|
|
185
222
|
]
|
|
186
223
|
|
|
@@ -10,7 +10,7 @@ Standard library only: it finds module names without importing them, so settings
|
|
|
10
10
|
before Django has started.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
from snakeboot.component_scan.application_root import application_root
|
|
13
|
+
from snakeboot.component_scan.application_root import application_package, application_root
|
|
14
14
|
from snakeboot.component_scan.component_scan import (
|
|
15
15
|
DJANGO_ENTRY_POINTS,
|
|
16
16
|
SNAKEBOOT_AUTO_CONFIGURATION,
|
|
@@ -23,5 +23,6 @@ __all__ = [
|
|
|
23
23
|
"SNAKEBOOT_AUTO_CONFIGURATION",
|
|
24
24
|
"ComponentScan",
|
|
25
25
|
"InvalidApplicationRootError",
|
|
26
|
+
"application_package",
|
|
26
27
|
"application_root",
|
|
27
28
|
]
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
*,
|
|
20
|
+
base_packages: tuple[str, ...] = (),
|
|
21
|
+
exclude: tuple[str, ...] = (),
|
|
22
|
+
default_exclude: tuple[str, ...] = DJANGO_ENTRY_POINTS,
|
|
23
|
+
) -> Callable[[C], C]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def application_root[C: type](
|
|
27
|
+
application_class: C | None = None,
|
|
28
|
+
/,
|
|
29
|
+
*,
|
|
30
|
+
base_packages: tuple[str, ...] = (),
|
|
31
|
+
exclude: tuple[str, ...] = (),
|
|
32
|
+
default_exclude: tuple[str, ...] = DJANGO_ENTRY_POINTS,
|
|
33
|
+
) -> C | Callable[[C], C]:
|
|
34
|
+
"""Marks the root of the application, like Spring's `@SpringBootApplication`.
|
|
35
|
+
|
|
36
|
+
By default the component scan starts in the package of the marked class and covers everything below it, so the
|
|
37
|
+
class belongs in the top package (`myapp/myapp_application.py`). `base_packages` names the packages of the
|
|
38
|
+
container instead, like `scanBasePackages`: an application that only assembles packages living elsewhere.
|
|
39
|
+
Use it bare (`@application_root`) or with arguments (`@application_root(base_packages=(...), exclude=(...))`).
|
|
40
|
+
`ComponentScan.of(...)` returns the scan; the package of the class holds the application's files.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def declare(declared_class: C) -> C:
|
|
44
|
+
packages = base_packages or (application_package(declared_class),)
|
|
45
|
+
scan = ComponentScan(packages, exclude=exclude, default_exclude=default_exclude)
|
|
46
|
+
setattr(declared_class, APPLICATION_ROOT_ATTRIBUTE, scan)
|
|
47
|
+
return declared_class
|
|
48
|
+
|
|
49
|
+
return declare if application_class is None else declare(application_class)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def application_package(application_class: type) -> str:
|
|
53
|
+
"""Returns the package of `application_class`: the top package of the application, next to its files."""
|
|
54
|
+
module = sys.modules.get(application_class.__module__)
|
|
55
|
+
if module is not None and hasattr(module, "__path__"):
|
|
56
|
+
return module.__name__
|
|
57
|
+
package, _, _ = application_class.__module__.rpartition(".")
|
|
58
|
+
if not package:
|
|
59
|
+
raise InvalidApplicationRootError(
|
|
60
|
+
f"{application_class.__name__} is defined in {application_class.__module__!r}, outside any package; "
|
|
61
|
+
"an application root lives in the top package of the application."
|
|
62
|
+
)
|
|
63
|
+
return package
|