snakeboot 0.1.2__tar.gz → 0.3.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 (47) hide show
  1. {snakeboot-0.1.2 → snakeboot-0.3.0}/PKG-INFO +42 -5
  2. {snakeboot-0.1.2 → snakeboot-0.3.0}/README.md +38 -3
  3. {snakeboot-0.1.2 → snakeboot-0.3.0}/pyproject.toml +31 -2
  4. {snakeboot-0.1.2 → snakeboot-0.3.0}/pyproject.toml.orig +40 -3
  5. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/__init__.py +1 -0
  6. snakeboot-0.3.0/src/snakeboot/configuration/__init__.py +41 -0
  7. snakeboot-0.3.0/src/snakeboot/configuration/cache_properties.py +16 -0
  8. snakeboot-0.3.0/src/snakeboot/configuration/configuration.py +55 -0
  9. snakeboot-0.3.0/src/snakeboot/configuration/configuration_errors.py +12 -0
  10. snakeboot-0.3.0/src/snakeboot/configuration/configuration_properties.py +48 -0
  11. snakeboot-0.3.0/src/snakeboot/configuration/database_properties.py +29 -0
  12. snakeboot-0.3.0/src/snakeboot/configuration/django_properties.py +19 -0
  13. snakeboot-0.3.0/src/snakeboot/configuration/load_configuration.py +107 -0
  14. snakeboot-0.3.0/src/snakeboot/configuration/properties_rules.py +56 -0
  15. snakeboot-0.3.0/src/snakeboot/configuration/section.py +12 -0
  16. {snakeboot-0.1.2 → snakeboot-0.3.0}/LICENSE +0 -0
  17. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/component_scan/__init__.py +0 -0
  18. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/component_scan/application_root.py +0 -0
  19. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/component_scan/component_scan.py +0 -0
  20. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/django/__init__.py +0 -0
  21. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/django/django_atomic_transaction_manager.py +0 -0
  22. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/__init__.py +0 -0
  23. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/application_event_listener.py +0 -0
  24. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/application_event_publisher.py +0 -0
  25. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/application_event_publisher_injectable.py +0 -0
  26. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/event_listener_markers.py +0 -0
  27. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/py.typed +0 -0
  28. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/__init__.py +0 -0
  29. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/authenticated_principal.py +0 -0
  30. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/controller_endpoint_factory.py +0 -0
  31. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/error_answer.py +0 -0
  32. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller.py +0 -0
  33. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_advice.py +0 -0
  34. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_errors.py +0 -0
  35. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_mappings.py +0 -0
  36. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_mounter.py +0 -0
  37. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_scanner.py +0 -0
  38. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/route_markers.py +0 -0
  39. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/testing/__init__.py +0 -0
  40. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/testing/declared_classes.py +0 -0
  41. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/testing/recording_transaction_manager.py +0 -0
  42. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/__init__.py +0 -0
  43. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/after_commit.py +0 -0
  44. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/transaction_manager.py +0 -0
  45. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/transaction_manager_not_installed_error.py +0 -0
  46. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/transaction_manager_registry.py +0 -0
  47. {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/transactional.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snakeboot
3
- Version: 0.1.2
4
- Summary: Spring Boot ergonomics for hexagonal Python: @transactional, application events, a component scan and singleton REST controllers on Django Ninja, wired by wireup.
3
+ Version: 0.3.0
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
7
7
  Author-email: Wouter Deketelaere <wouter.deketelaere@kdg.be>
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.14
19
19
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Dist: wireup>=2.12.0
22
+ Requires-Dist: pydantic-settings>=2.16.0,<3 ; extra == 'configuration'
22
23
  Requires-Dist: django>=6.1.1 ; extra == 'django'
23
24
  Requires-Dist: django>=6.1.1 ; extra == 'ninja'
24
25
  Requires-Dist: django-ninja>=1.7.1 ; extra == 'ninja'
@@ -27,6 +28,7 @@ Project-URL: Homepage, https://github.com/KdG-OCDI/snakeboot
27
28
  Project-URL: Repository, https://github.com/KdG-OCDI/snakeboot
28
29
  Project-URL: Issues, https://github.com/KdG-OCDI/snakeboot/issues
29
30
  Project-URL: Changelog, https://github.com/KdG-OCDI/snakeboot/blob/main/CHANGELOG.md
31
+ Provides-Extra: configuration
30
32
  Provides-Extra: django
31
33
  Provides-Extra: ninja
32
34
  Description-Content-Type: text/markdown
@@ -46,6 +48,8 @@ classes you can read in an afternoon, and none of them knows anything about your
46
48
  | `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
47
49
  | `@rest_controller`, `@route.get`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
48
50
  | `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
51
+ | `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@EnableConfigurationProperties`, profiles | `snakeboot[configuration]` |
52
+ | `DjangoProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
49
53
  | `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |
50
54
 
51
55
  The transaction, component scan and testing packages import nothing but the standard library,
@@ -54,7 +58,7 @@ so the application layer of a hexagonal application can use `@transactional` wit
54
58
  ## Install
55
59
 
56
60
  ```bash
57
- uv add "snakeboot[django,ninja]"
61
+ uv add "snakeboot[configuration,django,ninja]"
58
62
  ```
59
63
 
60
64
  ## A tour
@@ -73,6 +77,39 @@ class MyApplication: ...
73
77
  WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names()], auto_inject_views=False)
74
78
  ```
75
79
 
80
+ **Typed configuration.** A `Section` (a frozen Pydantic model that refuses unknown keys) is bound to one
81
+ table of `application.toml`, like `@ConfigurationProperties`. Settings lists the sections once, like
82
+ `@EnableConfigurationProperties`; every section then reaches wireup by its type. The sections Django itself
83
+ needs come with snakeboot: `DjangoProperties` (`[django]`), `DatabaseProperties` (`[database]`, PostgreSQL) and
84
+ `CacheProperties` (`[cache]`, Redis).
85
+
86
+ ```python
87
+ @configuration_properties("chatbot")
88
+ class ChatbotProperties(Section):
89
+ model: str # no default: the value is in application.toml
90
+ llm: LanguageModelProperties # a nested Section is the table [chatbot.llm]
91
+
92
+
93
+ # settings.py
94
+ CONFIGURATION = load_configuration(DjangoProperties, DatabaseProperties, ChatbotProperties, package="myapp")
95
+ SECRET_KEY = CONFIGURATION.get(DjangoProperties).secret_key.get_secret_value()
96
+ DATABASES = {"default": CONFIGURATION.get(DatabaseProperties).as_django_database()}
97
+ WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names(), *CONFIGURATION.injectables()])
98
+ ```
99
+
100
+ The values come from three places, a later one overriding an earlier one key by key:
101
+
102
+ | Source | Holds | Spring Boot |
103
+ |---|---|---|
104
+ | `application.toml` in the package | every value the application runs with in production | `application.yml` |
105
+ | `application-<profile>.toml`, when `SNAKEBOOT_PROFILE` names one | what differs on a developer machine, say | `application-local.yml`, `SPRING_PROFILES_ACTIVE` |
106
+ | environment variables `SECTION__KEY__SUBKEY` | secrets and addresses of one deployment | relaxed binding |
107
+
108
+ Nothing else is read: no `.env` file, no default in code, no command line. The start stops when a model has
109
+ a default, ignores unknown keys or is mutable; when a file or a value is missing; when a key or a table is
110
+ unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced, so put the
111
+ variant without keys of its own in `application.toml` when a profile or the environment may choose another.
112
+
76
113
  **Transactions.** A use case declares its boundary; composition chooses the technology.
77
114
 
78
115
  ```python
@@ -129,8 +166,8 @@ mounted = RestControllerMounter().mount(api, scan=settings.WIREUP.injectables or
129
166
 
130
167
  ## Design rules
131
168
 
132
- * Imitate the basics, not all of Spring: no AOP, no profiles, no scheduling, no message brokers.
133
- * Fail at start-up, not at the first request: a broken controller, listener or root stops the application.
169
+ * Imitate the basics, not all of Spring: no AOP, no scheduling, no message brokers.
170
+ * Fail at start-up, not at the first request: a broken controller, listener, root or configuration stops the application.
134
171
  * Each package knows as little as possible; `uv run lint-imports` enforces it.
135
172
 
136
173
  ## Develop
@@ -13,6 +13,8 @@ classes you can read in an afternoon, and none of them knows anything about your
13
13
  | `ApplicationEventPublisher`, `@event_listener`, `@transactional_event_listener` | the same names | wireup |
14
14
  | `@rest_controller`, `@route.get`, `@rest_controller_advice`, `@exception_handler` | `@RestController`, `@GetMapping`, `@RestControllerAdvice`, `@ExceptionHandler` | `snakeboot[ninja]` |
15
15
  | `DjangoAtomicTransactionManager` | `PlatformTransactionManager` for JDBC | `snakeboot[django]` |
16
+ | `@configuration_properties`, `Section`, `load_configuration`, `application-<profile>.toml` | `@ConfigurationProperties`, `@EnableConfigurationProperties`, profiles | `snakeboot[configuration]` |
17
+ | `DjangoProperties`, `DatabaseProperties`, `CacheProperties` | `server.*`, `spring.datasource.*`, `spring.data.redis.*` | `snakeboot[configuration]` |
16
18
  | `RecordingTransactionManager`, `declared_classes` | spring-test | nothing |
17
19
 
18
20
  The transaction, component scan and testing packages import nothing but the standard library,
@@ -21,7 +23,7 @@ so the application layer of a hexagonal application can use `@transactional` wit
21
23
  ## Install
22
24
 
23
25
  ```bash
24
- uv add "snakeboot[django,ninja]"
26
+ uv add "snakeboot[configuration,django,ninja]"
25
27
  ```
26
28
 
27
29
  ## A tour
@@ -40,6 +42,39 @@ class MyApplication: ...
40
42
  WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names()], auto_inject_views=False)
41
43
  ```
42
44
 
45
+ **Typed configuration.** A `Section` (a frozen Pydantic model that refuses unknown keys) is bound to one
46
+ table of `application.toml`, like `@ConfigurationProperties`. Settings lists the sections once, like
47
+ `@EnableConfigurationProperties`; every section then reaches wireup by its type. The sections Django itself
48
+ needs come with snakeboot: `DjangoProperties` (`[django]`), `DatabaseProperties` (`[database]`, PostgreSQL) and
49
+ `CacheProperties` (`[cache]`, Redis).
50
+
51
+ ```python
52
+ @configuration_properties("chatbot")
53
+ class ChatbotProperties(Section):
54
+ model: str # no default: the value is in application.toml
55
+ llm: LanguageModelProperties # a nested Section is the table [chatbot.llm]
56
+
57
+
58
+ # settings.py
59
+ CONFIGURATION = load_configuration(DjangoProperties, DatabaseProperties, ChatbotProperties, package="myapp")
60
+ SECRET_KEY = CONFIGURATION.get(DjangoProperties).secret_key.get_secret_value()
61
+ DATABASES = {"default": CONFIGURATION.get(DatabaseProperties).as_django_database()}
62
+ WIREUP = WireupSettings(injectables=[*ComponentScan.of(MyApplication).module_names(), *CONFIGURATION.injectables()])
63
+ ```
64
+
65
+ The values come from three places, a later one overriding an earlier one key by key:
66
+
67
+ | Source | Holds | Spring Boot |
68
+ |---|---|---|
69
+ | `application.toml` in the package | every value the application runs with in production | `application.yml` |
70
+ | `application-<profile>.toml`, when `SNAKEBOOT_PROFILE` names one | what differs on a developer machine, say | `application-local.yml`, `SPRING_PROFILES_ACTIVE` |
71
+ | environment variables `SECTION__KEY__SUBKEY` | secrets and addresses of one deployment | relaxed binding |
72
+
73
+ Nothing else is read: no `.env` file, no default in code, no command line. The start stops when a model has
74
+ a default, ignores unknown keys or is mutable; when a file or a value is missing; when a key or a table is
75
+ unknown (a typo in `application.toml` or in `CHATBOT__LLM__MODLE`). Tables are merged, not replaced, so put the
76
+ variant without keys of its own in `application.toml` when a profile or the environment may choose another.
77
+
43
78
  **Transactions.** A use case declares its boundary; composition chooses the technology.
44
79
 
45
80
  ```python
@@ -96,8 +131,8 @@ mounted = RestControllerMounter().mount(api, scan=settings.WIREUP.injectables or
96
131
 
97
132
  ## Design rules
98
133
 
99
- * Imitate the basics, not all of Spring: no AOP, no profiles, no scheduling, no message brokers.
100
- * Fail at start-up, not at the first request: a broken controller, listener or root stops the application.
134
+ * Imitate the basics, not all of Spring: no AOP, no scheduling, no message brokers.
135
+ * Fail at start-up, not at the first request: a broken controller, listener, root or configuration stops the application.
101
136
  * Each package knows as little as possible; `uv run lint-imports` enforces it.
102
137
 
103
138
  ## Develop
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "snakeboot"
3
- version = "0.1.2"
4
- description = "Spring Boot ergonomics for hexagonal Python: @transactional, application events, a component scan and singleton REST controllers on Django Ninja, wired by wireup."
3
+ version = "0.3.0"
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"
7
7
  license-files = ["LICENSE"]
@@ -47,6 +47,7 @@ ninja = [
47
47
  "django>=6.1.1",
48
48
  "django-ninja>=1.7.1",
49
49
  ]
50
+ configuration = ["pydantic-settings>=2.16.0,<3"]
50
51
 
51
52
  [build-system]
52
53
  requires = ["uv_build>=0.12.19,<0.13.0"]
@@ -60,6 +61,7 @@ dev = [
60
61
  "import-linter>=2.15",
61
62
  "mypy>=2.4.0",
62
63
  "pre-commit>=4.7.0",
64
+ "pydantic-settings>=2.16.0,<3",
63
65
  "pytest>=9.1.1",
64
66
  "pytest-django>=4.14.0",
65
67
  "ruff>=0.17.1",
@@ -84,6 +86,9 @@ forbidden_modules = [
84
86
  "django",
85
87
  "ninja",
86
88
  "wireup",
89
+ "pydantic",
90
+ "pydantic_settings",
91
+ "snakeboot.configuration",
87
92
  "snakeboot.component_scan",
88
93
  "snakeboot.events",
89
94
  "snakeboot.rest",
@@ -99,6 +104,9 @@ forbidden_modules = [
99
104
  "django",
100
105
  "ninja",
101
106
  "wireup",
107
+ "pydantic",
108
+ "pydantic_settings",
109
+ "snakeboot.configuration",
102
110
  "snakeboot.transaction",
103
111
  "snakeboot.events",
104
112
  "snakeboot.rest",
@@ -114,6 +122,7 @@ forbidden_modules = [
114
122
  "django",
115
123
  "ninja",
116
124
  "pydantic",
125
+ "snakeboot.configuration",
117
126
  "snakeboot.component_scan",
118
127
  "snakeboot.rest",
119
128
  "snakeboot.django",
@@ -125,6 +134,7 @@ name = "rest knows Ninja and wireup, but not the transaction and event libraries
125
134
  type = "forbidden"
126
135
  source_modules = ["snakeboot.rest"]
127
136
  forbidden_modules = [
137
+ "snakeboot.configuration",
128
138
  "snakeboot.transaction",
129
139
  "snakeboot.events",
130
140
  "snakeboot.component_scan",
@@ -139,6 +149,7 @@ source_modules = ["snakeboot.django"]
139
149
  forbidden_modules = [
140
150
  "ninja",
141
151
  "wireup",
152
+ "snakeboot.configuration",
142
153
  "snakeboot.component_scan",
143
154
  "snakeboot.events",
144
155
  "snakeboot.rest",
@@ -153,11 +164,29 @@ forbidden_modules = [
153
164
  "django",
154
165
  "ninja",
155
166
  "wireup",
167
+ "pydantic",
168
+ "pydantic_settings",
169
+ "snakeboot.configuration",
156
170
  "snakeboot.rest",
157
171
  "snakeboot.events",
158
172
  "snakeboot.django",
159
173
  ]
160
174
 
175
+ [[tool.importlinter.contracts]]
176
+ name = "configuration knows Pydantic Settings and wireup, but no web or ORM framework and no other part of snakeboot"
177
+ type = "forbidden"
178
+ source_modules = ["snakeboot.configuration"]
179
+ forbidden_modules = [
180
+ "django",
181
+ "ninja",
182
+ "snakeboot.transaction",
183
+ "snakeboot.component_scan",
184
+ "snakeboot.events",
185
+ "snakeboot.rest",
186
+ "snakeboot.django",
187
+ "snakeboot.testing",
188
+ ]
189
+
161
190
  [tool.ruff]
162
191
  line-length = 120
163
192
  target-version = "py313"
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "snakeboot"
3
- version = "0.1.2"
4
- description = "Spring Boot ergonomics for hexagonal Python: @transactional, application events, a component scan and singleton REST controllers on Django Ninja, wired by wireup."
3
+ version = "0.3.0"
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"
7
7
  license-files = ["LICENSE"]
@@ -38,6 +38,8 @@ Changelog = "https://github.com/KdG-OCDI/snakeboot/blob/main/CHANGELOG.md"
38
38
  django = ["django>=6.1.1"]
39
39
  # Singleton REST controllers and advices on Django Ninja (snakeboot.rest).
40
40
  ninja = ["django>=6.1.1", "django-ninja>=1.7.1"]
41
+ # Typed configuration from application.toml, one profile and the environment (snakeboot.configuration).
42
+ configuration = ["pydantic-settings>=2.16.0,<3"]
41
43
 
42
44
  [build-system]
43
45
  requires = ["uv_build>=0.12.19,<0.13.0"]
@@ -51,6 +53,7 @@ dev = [
51
53
  "import-linter>=2.15",
52
54
  "mypy>=2.4.0",
53
55
  "pre-commit>=4.7.0",
56
+ "pydantic-settings>=2.16.0,<3",
54
57
  "pytest>=9.1.1",
55
58
  "pytest-django>=4.14.0",
56
59
  "ruff>=0.17.1",
@@ -80,6 +83,9 @@ forbidden_modules = [
80
83
  "django",
81
84
  "ninja",
82
85
  "wireup",
86
+ "pydantic",
87
+ "pydantic_settings",
88
+ "snakeboot.configuration",
83
89
  "snakeboot.component_scan",
84
90
  "snakeboot.events",
85
91
  "snakeboot.rest",
@@ -95,6 +101,9 @@ forbidden_modules = [
95
101
  "django",
96
102
  "ninja",
97
103
  "wireup",
104
+ "pydantic",
105
+ "pydantic_settings",
106
+ "snakeboot.configuration",
98
107
  "snakeboot.transaction",
99
108
  "snakeboot.events",
100
109
  "snakeboot.rest",
@@ -110,6 +119,7 @@ forbidden_modules = [
110
119
  "django",
111
120
  "ninja",
112
121
  "pydantic",
122
+ "snakeboot.configuration",
113
123
  "snakeboot.component_scan",
114
124
  "snakeboot.rest",
115
125
  "snakeboot.django",
@@ -121,6 +131,7 @@ name = "rest knows Ninja and wireup, but not the transaction and event libraries
121
131
  type = "forbidden"
122
132
  source_modules = ["snakeboot.rest"]
123
133
  forbidden_modules = [
134
+ "snakeboot.configuration",
124
135
  "snakeboot.transaction",
125
136
  "snakeboot.events",
126
137
  "snakeboot.component_scan",
@@ -135,6 +146,7 @@ source_modules = ["snakeboot.django"]
135
146
  forbidden_modules = [
136
147
  "ninja",
137
148
  "wireup",
149
+ "snakeboot.configuration",
138
150
  "snakeboot.component_scan",
139
151
  "snakeboot.events",
140
152
  "snakeboot.rest",
@@ -145,7 +157,32 @@ forbidden_modules = [
145
157
  name = "testing helpers run without a framework"
146
158
  type = "forbidden"
147
159
  source_modules = ["snakeboot.testing"]
148
- forbidden_modules = ["django", "ninja", "wireup", "snakeboot.rest", "snakeboot.events", "snakeboot.django"]
160
+ forbidden_modules = [
161
+ "django",
162
+ "ninja",
163
+ "wireup",
164
+ "pydantic",
165
+ "pydantic_settings",
166
+ "snakeboot.configuration",
167
+ "snakeboot.rest",
168
+ "snakeboot.events",
169
+ "snakeboot.django",
170
+ ]
171
+
172
+ [[tool.importlinter.contracts]]
173
+ name = "configuration knows Pydantic Settings and wireup, but no web or ORM framework and no other part of snakeboot"
174
+ type = "forbidden"
175
+ source_modules = ["snakeboot.configuration"]
176
+ forbidden_modules = [
177
+ "django",
178
+ "ninja",
179
+ "snakeboot.transaction",
180
+ "snakeboot.component_scan",
181
+ "snakeboot.events",
182
+ "snakeboot.rest",
183
+ "snakeboot.django",
184
+ "snakeboot.testing",
185
+ ]
149
186
 
150
187
  # ---------------------------------------------------------------------------
151
188
  # Code contracts: the Clean Code rules in AGENTS.md, enforced by Ruff and mypy.
@@ -3,6 +3,7 @@
3
3
  snakeboot.transaction @transactional, after_commit, TransactionManager (no dependencies)
4
4
  snakeboot.component_scan @application_root, ComponentScan (no dependencies)
5
5
  snakeboot.events ApplicationEventPublisher, @transactional_event_listener (wireup)
6
+ snakeboot.configuration @configuration_properties, load_configuration (extra: configuration)
6
7
  snakeboot.rest @rest_controller, @route, @rest_controller_advice (extra: ninja)
7
8
  snakeboot.django DjangoAtomicTransactionManager (extra: django)
8
9
  snakeboot.testing RecordingTransactionManager, declared_classes (no dependencies)
@@ -0,0 +1,41 @@
1
+ """Technical library: typed configuration from application.toml, one profile and the environment.
2
+
3
+ @configuration_properties("chatbot") # @ConfigurationProperties(prefix = "chatbot")
4
+ class ChatbotProperties(Section):
5
+ model: str # no default: the value is in application.toml
6
+
7
+ # in settings.py, like @EnableConfigurationProperties({DjangoProperties.class, ChatbotProperties.class}):
8
+ CONFIGURATION = load_configuration(DjangoProperties, ChatbotProperties, package="myservice")
9
+ SECRET_KEY = CONFIGURATION.get(DjangoProperties).secret_key.get_secret_value()
10
+ WIREUP = WireupSettings(injectables=[*scan.module_names(), *CONFIGURATION.injectables()])
11
+
12
+ The values come from `application.toml` in the package, then `application-<profile>.toml` when
13
+ SNAKEBOOT_PROFILE is set, then environment variables such as CHATBOT__MODEL; nothing else.
14
+ `DjangoProperties`, `DatabaseProperties` and `CacheProperties` are the sections Django itself needs.
15
+ """
16
+
17
+ from snakeboot.configuration.cache_properties import CacheProperties
18
+ from snakeboot.configuration.configuration import Configuration
19
+ from snakeboot.configuration.configuration_errors import (
20
+ ConfigurationFileNotFoundError,
21
+ InvalidConfigurationPropertiesError,
22
+ )
23
+ from snakeboot.configuration.configuration_properties import configuration_properties
24
+ from snakeboot.configuration.database_properties import DatabaseProperties
25
+ from snakeboot.configuration.django_properties import DjangoProperties
26
+ from snakeboot.configuration.load_configuration import BASE_FILE, PROFILE_VARIABLE, load_configuration
27
+ from snakeboot.configuration.section import Section
28
+
29
+ __all__ = [
30
+ "BASE_FILE",
31
+ "PROFILE_VARIABLE",
32
+ "CacheProperties",
33
+ "Configuration",
34
+ "ConfigurationFileNotFoundError",
35
+ "DatabaseProperties",
36
+ "DjangoProperties",
37
+ "InvalidConfigurationPropertiesError",
38
+ "Section",
39
+ "configuration_properties",
40
+ "load_configuration",
41
+ ]
@@ -0,0 +1,16 @@
1
+ from snakeboot.configuration.configuration_properties import configuration_properties
2
+ from snakeboot.configuration.section import Section
3
+
4
+
5
+ @configuration_properties("cache")
6
+ class CacheProperties(Section):
7
+ """The section `[cache]`: the Redis that backs Django's cache, like Spring Boot's `spring.data.redis.*`.
8
+
9
+ CACHES = {"default": CONFIGURATION.get(CacheProperties).as_django_cache()}
10
+ """
11
+
12
+ redis_url: str
13
+
14
+ def as_django_cache(self) -> dict[str, str]:
15
+ """The entry of Django's `CACHES` setting."""
16
+ return {"BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": self.redis_url}
@@ -0,0 +1,55 @@
1
+ from collections.abc import Callable, Mapping
2
+ from dataclasses import dataclass
3
+ from types import ModuleType
4
+ from typing import Final
5
+
6
+ from pydantic import BaseModel
7
+ from wireup import injectable
8
+
9
+ from snakeboot.configuration.configuration_properties import prefix_of
10
+
11
+ INJECTABLES_MODULE: Final = "snakeboot.configuration.loaded_sections"
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class Configuration:
16
+ """What `load_configuration` read: the active profile and one validated, immutable object per section.
17
+
18
+ Composition reads the sections Django itself needs with `get`, and hands every section to wireup with
19
+ `injectables`: an adapter then asks for its section by type, like a `@ConfigurationProperties` bean.
20
+ """
21
+
22
+ profile: str | None
23
+ sections: Mapping[type[BaseModel], BaseModel]
24
+
25
+ def get[S: BaseModel](self, section: type[S]) -> S:
26
+ """Returns the loaded object of `section`."""
27
+ loaded = self.sections.get(section)
28
+ if not isinstance(loaded, section):
29
+ raise LookupError(
30
+ f"{section.__name__} is not a section of this configuration: pass it to load_configuration."
31
+ )
32
+ return loaded
33
+
34
+ def injectables(self) -> list[ModuleType]:
35
+ """Returns a module with one wireup provider per section, for `WireupSettings(injectables=[...])`.
36
+
37
+ The list is empty when there are no sections.
38
+ """
39
+ if not self.sections:
40
+ return []
41
+ module = ModuleType(INJECTABLES_MODULE, "The sections of the configuration, provided by type.")
42
+ module.__file__ = f"<{INJECTABLES_MODULE}>" # wireup only reads the members of a module with a file
43
+ for section, loaded in self.sections.items():
44
+ provider = _provider_of(section, loaded)
45
+ setattr(module, provider.__name__, provider)
46
+ return [module]
47
+
48
+
49
+ def _provider_of(section: type[BaseModel], loaded: BaseModel) -> Callable[[], BaseModel]:
50
+ def provide() -> BaseModel:
51
+ return loaded
52
+
53
+ provide.__name__ = provide.__qualname__ = f"provide_{prefix_of(section)}_properties"
54
+ provide.__annotations__ = {"return": section}
55
+ return injectable(provide)
@@ -0,0 +1,12 @@
1
+ class InvalidConfigurationPropertiesError(TypeError):
2
+ """Raised at start-up when a class cannot be bound to a section of the configuration.
3
+
4
+ A default in code, a model that ignores unknown keys, an unmarked section or two models claiming
5
+ the same section: each would let a value come from somewhere else than application.toml, the active
6
+ profile or the environment. Like an invalid `@ConfigurationProperties` class in Spring, it stops the
7
+ application from starting.
8
+ """
9
+
10
+
11
+ class ConfigurationFileNotFoundError(FileNotFoundError):
12
+ """Raised at start-up when `application.toml`, or the file of the active profile, is not in the package."""
@@ -0,0 +1,48 @@
1
+ import re
2
+ from collections.abc import Callable
3
+ from typing import Final
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from snakeboot.configuration.configuration_errors import InvalidConfigurationPropertiesError
8
+
9
+ PREFIX_ATTRIBUTE: Final = "__configuration_properties_prefix__"
10
+ _PREFIX: Final = re.compile(r"[a-z][a-z0-9]*(?:_[a-z0-9]+)*")
11
+
12
+
13
+ def configuration_properties[M: type[BaseModel]](prefix: str) -> Callable[[M], M]:
14
+ """Binds a Pydantic model to the table `[prefix]`, like Spring's `@ConfigurationProperties(prefix = ...)`.
15
+
16
+ The model receives the table `[prefix]` of `application.toml`, merged with the active profile and the
17
+ environment variables `PREFIX__FIELD__SUBFIELD` (`CHATBOT__LLM__MODEL` sets `chatbot.llm.model`). The
18
+ prefix is one lowercase name with single underscores: a double underscore separates the levels of an
19
+ environment variable.
20
+ """
21
+ if not _PREFIX.fullmatch(prefix):
22
+ raise InvalidConfigurationPropertiesError(
23
+ f"A configuration prefix is one lowercase name with single underscores, got {prefix!r}."
24
+ )
25
+
26
+ def declare(model: M) -> M:
27
+ if not _is_pydantic_model(model):
28
+ raise InvalidConfigurationPropertiesError(
29
+ f"@configuration_properties marks a Pydantic model, not {model!r}."
30
+ )
31
+ setattr(model, PREFIX_ATTRIBUTE, prefix)
32
+ return model
33
+
34
+ return declare
35
+
36
+
37
+ def prefix_of(model: type[BaseModel]) -> str:
38
+ """Returns the prefix `model` was declared with. A subclass does not inherit it: it is another section."""
39
+ prefix = vars(model).get(PREFIX_ATTRIBUTE)
40
+ if not isinstance(prefix, str):
41
+ raise InvalidConfigurationPropertiesError(
42
+ f"{model.__name__} is not marked with @configuration_properties, so it is not a section."
43
+ )
44
+ return prefix
45
+
46
+
47
+ def _is_pydantic_model(candidate: object) -> bool:
48
+ return isinstance(candidate, type) and issubclass(candidate, BaseModel)
@@ -0,0 +1,29 @@
1
+ from pydantic import SecretStr
2
+
3
+ from snakeboot.configuration.configuration_properties import configuration_properties
4
+ from snakeboot.configuration.section import Section
5
+
6
+
7
+ @configuration_properties("database")
8
+ class DatabaseProperties(Section):
9
+ """The section `[database]`: the PostgreSQL database of the service, like Spring Boot's `spring.datasource.*`.
10
+
11
+ DATABASES = {"default": CONFIGURATION.get(DatabaseProperties).as_django_database()}
12
+ """
13
+
14
+ host: str
15
+ port: int
16
+ name: str
17
+ user: str
18
+ password: SecretStr
19
+
20
+ def as_django_database(self) -> dict[str, str]:
21
+ """The entry of Django's `DATABASES` setting."""
22
+ return {
23
+ "ENGINE": "django.db.backends.postgresql",
24
+ "HOST": self.host,
25
+ "PORT": str(self.port),
26
+ "NAME": self.name,
27
+ "USER": self.user,
28
+ "PASSWORD": self.password.get_secret_value(),
29
+ }
@@ -0,0 +1,19 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import SecretStr
4
+
5
+ from snakeboot.configuration.configuration_properties import configuration_properties
6
+ from snakeboot.configuration.section import Section
7
+
8
+
9
+ @configuration_properties("django")
10
+ class DjangoProperties(Section):
11
+ """The section `[django]`: what settings.py hands to Django itself, like Spring Boot's `server.*` properties.
12
+
13
+ `secret_key` is a SecretStr, so it never shows in a log or a traceback; settings.py unwraps it once.
14
+ """
15
+
16
+ secret_key: SecretStr
17
+ debug: bool
18
+ allowed_hosts: tuple[str, ...]
19
+ log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
@@ -0,0 +1,107 @@
1
+ import os
2
+ import re
3
+ from collections.abc import Sequence
4
+ from importlib.resources import files
5
+ from importlib.resources.abc import Traversable
6
+ from types import MappingProxyType
7
+ from typing import Any, Final, override
8
+
9
+ from pydantic import BaseModel, create_model
10
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
11
+
12
+ from snakeboot.configuration.configuration import Configuration
13
+ from snakeboot.configuration.configuration_errors import (
14
+ ConfigurationFileNotFoundError,
15
+ InvalidConfigurationPropertiesError,
16
+ )
17
+ from snakeboot.configuration.configuration_properties import prefix_of
18
+ from snakeboot.configuration.properties_rules import refuse_loose_models
19
+
20
+ PROFILE_VARIABLE: Final = "SNAKEBOOT_PROFILE"
21
+ """The environment variable that activates a profile, like `SPRING_PROFILES_ACTIVE` (one profile at most)."""
22
+
23
+ BASE_FILE: Final = "application.toml"
24
+ _PROFILE_NAME: Final = re.compile(r"[a-z][a-z0-9-]*")
25
+
26
+
27
+ def load_configuration(*sections: type[BaseModel], package: str) -> Configuration:
28
+ """Loads `sections` from the files in `package` and the environment, like Spring Boot's `application.yml`.
29
+
30
+ Three sources; a later one overrides an earlier one key by key, so nested tables are merged:
31
+
32
+ application.toml every value the application runs with
33
+ application-<profile>.toml only when SNAKEBOOT_PROFILE names a profile
34
+ environment variables SECTION__KEY__SUBKEY, for example CHATBOT__LLM__MODEL
35
+
36
+ Nothing else is read: no `.env` file, no default in code, no command line. The files are package
37
+ data, so a source checkout and an installed wheel find them alike. Everything fails here, at
38
+ start-up: InvalidConfigurationPropertiesError for a broken model, ConfigurationFileNotFoundError
39
+ for a missing file, pydantic's ValidationError for a missing, unknown or invalid value.
40
+ """
41
+ sections_by_prefix = _sections_by_prefix(sections)
42
+ profile = _active_profile()
43
+ root_model = _root_model(sections_by_prefix, _configuration_files(package, profile))
44
+ loaded = root_model()
45
+ return Configuration(
46
+ profile=profile,
47
+ sections=MappingProxyType({section: getattr(loaded, prefix) for prefix, section in sections_by_prefix.items()}),
48
+ )
49
+
50
+
51
+ def _sections_by_prefix(sections: Sequence[type[BaseModel]]) -> dict[str, type[BaseModel]]:
52
+ by_prefix: dict[str, type[BaseModel]] = {}
53
+ for section in sections:
54
+ prefix = prefix_of(section)
55
+ if (earlier := by_prefix.get(prefix)) is not None:
56
+ raise InvalidConfigurationPropertiesError(
57
+ f"{earlier.__name__} and {section.__name__} both claim the section [{prefix}]."
58
+ )
59
+ refuse_loose_models(section)
60
+ by_prefix[prefix] = section
61
+ return by_prefix
62
+
63
+
64
+ def _active_profile() -> str | None:
65
+ profile = os.environ.get(PROFILE_VARIABLE, "")
66
+ if profile and not _PROFILE_NAME.fullmatch(profile):
67
+ raise ConfigurationFileNotFoundError(
68
+ f"{PROFILE_VARIABLE}={profile!r} cannot name a profile file: a profile is one lowercase name."
69
+ )
70
+ return profile or None
71
+
72
+
73
+ def _configuration_files(package: str, profile: str | None) -> list[Traversable]:
74
+ directory = files(package)
75
+ base = directory.joinpath(BASE_FILE)
76
+ if not base.is_file():
77
+ raise ConfigurationFileNotFoundError(f"{package} has no {BASE_FILE}: it holds every value of the application.")
78
+ if profile is None:
79
+ return [base]
80
+ profile_file = directory.joinpath(f"application-{profile}.toml")
81
+ if not profile_file.is_file():
82
+ raise ConfigurationFileNotFoundError(
83
+ f"{PROFILE_VARIABLE}={profile}, but {package} has no application-{profile}.toml."
84
+ )
85
+ return [base, profile_file]
86
+
87
+
88
+ def _root_model(sections_by_prefix: dict[str, type[BaseModel]], toml_files: list[Traversable]) -> type[BaseSettings]:
89
+ class ConfigurationSources(BaseSettings):
90
+ model_config = SettingsConfigDict(env_prefix="", env_nested_delimiter="__", extra="forbid", frozen=True)
91
+
92
+ @classmethod
93
+ @override
94
+ def settings_customise_sources( # reason: the signature is pydantic-settings' hook
95
+ cls,
96
+ settings_cls: type[BaseSettings],
97
+ init_settings: PydanticBaseSettingsSource,
98
+ env_settings: PydanticBaseSettingsSource,
99
+ dotenv_settings: PydanticBaseSettingsSource,
100
+ file_secret_settings: PydanticBaseSettingsSource,
101
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
102
+ # Only the environment and the files: no initial values, no .env file, no secrets directory.
103
+ return env_settings, TomlConfigSettingsSource(settings_cls, toml_file=toml_files, deep_merge=True)
104
+
105
+ fields: dict[str, Any] = {prefix: (section, ...) for prefix, section in sections_by_prefix.items()}
106
+ root: type[BaseSettings] = create_model("Configuration", __base__=ConfigurationSources, **fields)
107
+ return root
@@ -0,0 +1,56 @@
1
+ from collections.abc import Iterator
2
+ from typing import get_args, get_origin
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from snakeboot.configuration.configuration_errors import InvalidConfigurationPropertiesError
7
+
8
+
9
+ def refuse_loose_models(section: type[BaseModel]) -> None:
10
+ """Refuses a section whose model, or a model nested in it, could get a value from somewhere else.
11
+
12
+ A default in code is a second place for a value next to application.toml. A model that ignores
13
+ unknown keys turns a typo into a setting that silently does nothing. A mutable model lets a value
14
+ change after start-up.
15
+ """
16
+ for model in _models_in(section):
17
+ _refuse_open_model(model)
18
+ _refuse_defaults(model)
19
+
20
+
21
+ def _models_in(section: type[BaseModel]) -> list[type[BaseModel]]:
22
+ found: dict[type[BaseModel], None] = {}
23
+ waiting = [section]
24
+ while waiting:
25
+ model = waiting.pop()
26
+ if model not in found:
27
+ found[model] = None
28
+ for field in model.model_fields.values():
29
+ waiting.extend(_models_named_by(field.annotation))
30
+ return list(found)
31
+
32
+
33
+ def _models_named_by(annotation: object) -> Iterator[type[BaseModel]]:
34
+ # `tuple[Llm, ...]`, `Llm | None` and `Annotated[Llm | Extractive, ...]` all name models in their arguments.
35
+ if get_origin(annotation) is None and isinstance(annotation, type) and issubclass(annotation, BaseModel):
36
+ yield annotation
37
+ return
38
+ for argument in get_args(annotation):
39
+ yield from _models_named_by(argument)
40
+
41
+
42
+ def _refuse_open_model(model: type[BaseModel]) -> None:
43
+ if model.model_config.get("extra") != "forbid" or model.model_config.get("frozen") is not True:
44
+ raise InvalidConfigurationPropertiesError(
45
+ f"{model.__name__} must extend Section (extra='forbid', frozen=True): an unknown key is a typo that "
46
+ "must stop the start, and the configuration does not change while running."
47
+ )
48
+
49
+
50
+ def _refuse_defaults(model: type[BaseModel]) -> None:
51
+ for name, field in model.model_fields.items():
52
+ if not field.is_required():
53
+ raise InvalidConfigurationPropertiesError(
54
+ f"{model.__name__}.{name} has a default in code. Give it its value in application.toml or a "
55
+ "profile: every configuration value is written in one place."
56
+ )
@@ -0,0 +1,12 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class Section(BaseModel):
5
+ """A table of application.toml as a class: a frozen Pydantic model that refuses unknown keys.
6
+
7
+ Every section and every table nested in it extends `Section`, so a typo in a key stops the start and a value
8
+ cannot change while the application runs. Give no field a default: every value is written in application.toml
9
+ or in a profile, where everyone can read it.
10
+ """
11
+
12
+ model_config = ConfigDict(extra="forbid", frozen=True)
File without changes