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.
- {snakeboot-0.1.2 → snakeboot-0.3.0}/PKG-INFO +42 -5
- {snakeboot-0.1.2 → snakeboot-0.3.0}/README.md +38 -3
- {snakeboot-0.1.2 → snakeboot-0.3.0}/pyproject.toml +31 -2
- {snakeboot-0.1.2 → snakeboot-0.3.0}/pyproject.toml.orig +40 -3
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/__init__.py +1 -0
- snakeboot-0.3.0/src/snakeboot/configuration/__init__.py +41 -0
- snakeboot-0.3.0/src/snakeboot/configuration/cache_properties.py +16 -0
- snakeboot-0.3.0/src/snakeboot/configuration/configuration.py +55 -0
- snakeboot-0.3.0/src/snakeboot/configuration/configuration_errors.py +12 -0
- snakeboot-0.3.0/src/snakeboot/configuration/configuration_properties.py +48 -0
- snakeboot-0.3.0/src/snakeboot/configuration/database_properties.py +29 -0
- snakeboot-0.3.0/src/snakeboot/configuration/django_properties.py +19 -0
- snakeboot-0.3.0/src/snakeboot/configuration/load_configuration.py +107 -0
- snakeboot-0.3.0/src/snakeboot/configuration/properties_rules.py +56 -0
- snakeboot-0.3.0/src/snakeboot/configuration/section.py +12 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/LICENSE +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/component_scan/__init__.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/component_scan/application_root.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/component_scan/component_scan.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/django/__init__.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/django/django_atomic_transaction_manager.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/__init__.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/application_event_listener.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/application_event_publisher.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/application_event_publisher_injectable.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/event_listener_markers.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/py.typed +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/__init__.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/authenticated_principal.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/controller_endpoint_factory.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/error_answer.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_advice.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_errors.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_mappings.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_mounter.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/rest_controller_scanner.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/rest/route_markers.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/testing/__init__.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/testing/declared_classes.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/testing/recording_transaction_manager.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/__init__.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/after_commit.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/transaction_manager.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/transaction_manager_not_installed_error.py +0 -0
- {snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/transaction_manager_registry.py +0 -0
- {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.
|
|
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
|
|
133
|
-
* Fail at start-up, not at the first request: a broken controller, listener or
|
|
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
|
|
100
|
-
* Fail at start-up, not at the first request: a broken controller, listener or
|
|
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.
|
|
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.
|
|
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 = [
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/django/django_atomic_transaction_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/events/application_event_publisher_injectable.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{snakeboot-0.1.2 → snakeboot-0.3.0}/src/snakeboot/transaction/transaction_manager_registry.py
RENAMED
|
File without changes
|
|
File without changes
|