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.
Files changed (70) hide show
  1. {snakeboot-0.2.0 → snakeboot-0.4.0}/PKG-INFO +79 -28
  2. snakeboot-0.4.0/README.md +195 -0
  3. {snakeboot-0.2.0 → snakeboot-0.4.0}/pyproject.toml +40 -3
  4. {snakeboot-0.2.0 → snakeboot-0.4.0}/pyproject.toml.orig +40 -3
  5. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/component_scan/__init__.py +2 -1
  6. snakeboot-0.4.0/src/snakeboot/component_scan/application_root.py +63 -0
  7. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/component_scan/component_scan.py +55 -7
  8. snakeboot-0.4.0/src/snakeboot/configuration/__init__.py +45 -0
  9. snakeboot-0.4.0/src/snakeboot/configuration/cache_properties.py +16 -0
  10. snakeboot-0.4.0/src/snakeboot/configuration/configuration.py +98 -0
  11. snakeboot-0.4.0/src/snakeboot/configuration/configuration_errors.py +11 -0
  12. snakeboot-0.4.0/src/snakeboot/configuration/configuration_properties.py +50 -0
  13. snakeboot-0.4.0/src/snakeboot/configuration/configuration_sources.py +127 -0
  14. snakeboot-0.4.0/src/snakeboot/configuration/database_properties.py +29 -0
  15. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/configuration/django_properties.py +3 -4
  16. snakeboot-0.4.0/src/snakeboot/configuration/load_configuration.py +42 -0
  17. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/configuration/properties_rules.py +21 -9
  18. snakeboot-0.4.0/src/snakeboot/configuration/section.py +16 -0
  19. snakeboot-0.4.0/src/snakeboot/configuration/section_prefix.py +36 -0
  20. snakeboot-0.4.0/src/snakeboot/django_application/__init__.py +30 -0
  21. snakeboot-0.4.0/src/snakeboot/django_application/apps.py +24 -0
  22. snakeboot-0.4.0/src/snakeboot/django_application/container_sections.py +33 -0
  23. snakeboot-0.4.0/src/snakeboot/django_application/django_configurer.py +40 -0
  24. snakeboot-0.4.0/src/snakeboot/django_application/django_settings.py +91 -0
  25. snakeboot-0.4.0/src/snakeboot/django_application/entry_points.py +50 -0
  26. snakeboot-0.4.0/src/snakeboot/django_application/installed_apps.py +65 -0
  27. snakeboot-0.4.0/src/snakeboot/django_application/settings.py +11 -0
  28. snakeboot-0.4.0/src/snakeboot/django_application/transaction_manager.py +12 -0
  29. snakeboot-0.4.0/src/snakeboot/openapi/__init__.py +14 -0
  30. snakeboot-0.4.0/src/snakeboot/openapi/openapi_definition.py +34 -0
  31. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/__init__.py +12 -8
  32. snakeboot-0.4.0/src/snakeboot/rest/bearer_authentication.py +34 -0
  33. snakeboot-0.2.0/src/snakeboot/rest/rest_controller_mounter.py → snakeboot-0.4.0/src/snakeboot/rest/controller_scan.py +34 -24
  34. snakeboot-0.4.0/src/snakeboot/rest/rest_api.py +91 -0
  35. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/rest_controller.py +2 -2
  36. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/rest_controller_advice.py +1 -1
  37. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/route_markers.py +1 -1
  38. snakeboot-0.4.0/src/snakeboot/rest/urls.py +19 -0
  39. snakeboot-0.2.0/README.md +0 -144
  40. snakeboot-0.2.0/src/snakeboot/component_scan/application_root.py +0 -56
  41. snakeboot-0.2.0/src/snakeboot/configuration/__init__.py +0 -35
  42. snakeboot-0.2.0/src/snakeboot/configuration/configuration.py +0 -55
  43. snakeboot-0.2.0/src/snakeboot/configuration/configuration_errors.py +0 -12
  44. snakeboot-0.2.0/src/snakeboot/configuration/configuration_properties.py +0 -48
  45. snakeboot-0.2.0/src/snakeboot/configuration/load_configuration.py +0 -107
  46. snakeboot-0.2.0/src/snakeboot/rest/rest_controller_scanner.py +0 -42
  47. {snakeboot-0.2.0 → snakeboot-0.4.0}/LICENSE +0 -0
  48. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/__init__.py +0 -0
  49. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/django/__init__.py +0 -0
  50. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/django/django_atomic_transaction_manager.py +0 -0
  51. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/__init__.py +0 -0
  52. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/application_event_listener.py +0 -0
  53. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/application_event_publisher.py +0 -0
  54. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/application_event_publisher_injectable.py +0 -0
  55. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/events/event_listener_markers.py +0 -0
  56. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/py.typed +0 -0
  57. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/authenticated_principal.py +0 -0
  58. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/controller_endpoint_factory.py +0 -0
  59. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/error_answer.py +0 -0
  60. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/rest_controller_errors.py +0 -0
  61. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/rest/rest_controller_mappings.py +0 -0
  62. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/testing/__init__.py +0 -0
  63. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/testing/declared_classes.py +0 -0
  64. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/testing/recording_transaction_manager.py +0 -0
  65. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/__init__.py +0 -0
  66. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/after_commit.py +0 -0
  67. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/transaction_manager.py +0 -0
  68. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/transaction_manager_not_installed_error.py +0 -0
  69. {snakeboot-0.2.0 → snakeboot-0.4.0}/src/snakeboot/transaction/transaction_manager_registry.py +0 -0
  70. {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.2.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`, `@EnableConfigurationProperties`, profiles | `snakeboot[configuration]` |
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 root.** The component scan starts in the package of the marked class, like
66
- `@SpringBootApplication`. It skips Django's entry points (`wsgi.py`, `asgi.py`, migrations) and
67
- adds the components snakeboot provides itself (the event publisher).
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
- @application_root
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
- # settings.py: the setting wireup needs anyway
76
- WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names()], auto_inject_views=False)
83
+ # manage.py and myapp/__main__.py # myapp/wsgi.py, for gunicorn
84
+ run(MyApplication) application = wsgi_application(MyApplication)
77
85
  ```
78
86
 
79
- **Typed configuration.** A Pydantic model is bound to one table of `application.toml`, like
80
- `@ConfigurationProperties`. Settings lists the sections once, like `@EnableConfigurationProperties`;
81
- every section then reaches wireup by its type.
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(BaseModel):
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 model is the table [chatbot.llm]
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
- # settings.py
93
- CONFIGURATION = load_configuration(DjangoProperties, ChatbotProperties, package="myapp")
94
- SECRET_KEY = CONFIGURATION.get(DjangoProperties).secret_key.get_secret_value()
95
- WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names(), *CONFIGURATION.injectables()])
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 has
107
- a default, ignores unknown keys or is mutable; when a file or a value is missing; when a key or a table is
108
- unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced, so put the
109
- variant without keys of its own in `application.toml` when a profile or the environment may choose another.
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
- # composition, at start-up
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, found by the component scan.
142
- Two endpoints for the same URL, or two answers for one error, stop the start-up.
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.py
162
- mounted = RestControllerMounter().mount(api, scan=settings.WIREUP.injectables or ())
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.2.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 wireup, but not the transaction and event libraries"
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.2.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 wireup, but not the transaction and event libraries"
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