django-tunables 0.1.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.
- django_tunables-0.1.0/LICENSE +21 -0
- django_tunables-0.1.0/PKG-INFO +265 -0
- django_tunables-0.1.0/README.md +222 -0
- django_tunables-0.1.0/pyproject.toml +73 -0
- django_tunables-0.1.0/setup.cfg +4 -0
- django_tunables-0.1.0/src/django_tunables.egg-info/PKG-INFO +265 -0
- django_tunables-0.1.0/src/django_tunables.egg-info/SOURCES.txt +63 -0
- django_tunables-0.1.0/src/django_tunables.egg-info/dependency_links.txt +1 -0
- django_tunables-0.1.0/src/django_tunables.egg-info/requires.txt +20 -0
- django_tunables-0.1.0/src/django_tunables.egg-info/top_level.txt +1 -0
- django_tunables-0.1.0/src/tunables/__init__.py +18 -0
- django_tunables-0.1.0/src/tunables/admin/__init__.py +221 -0
- django_tunables-0.1.0/src/tunables/admin/forms.py +56 -0
- django_tunables-0.1.0/src/tunables/api/__init__.py +0 -0
- django_tunables-0.1.0/src/tunables/api/actors.py +29 -0
- django_tunables-0.1.0/src/tunables/api/base.py +45 -0
- django_tunables-0.1.0/src/tunables/api/problems.py +62 -0
- django_tunables-0.1.0/src/tunables/api/serializers.py +58 -0
- django_tunables-0.1.0/src/tunables/api/urls.py +23 -0
- django_tunables-0.1.0/src/tunables/api/views.py +190 -0
- django_tunables-0.1.0/src/tunables/api/writes.py +112 -0
- django_tunables-0.1.0/src/tunables/apps.py +7 -0
- django_tunables-0.1.0/src/tunables/catalogue.py +133 -0
- django_tunables-0.1.0/src/tunables/changes.py +16 -0
- django_tunables-0.1.0/src/tunables/conf.py +31 -0
- django_tunables-0.1.0/src/tunables/document.py +41 -0
- django_tunables-0.1.0/src/tunables/errors.py +86 -0
- django_tunables-0.1.0/src/tunables/management/__init__.py +0 -0
- django_tunables-0.1.0/src/tunables/management/base.py +22 -0
- django_tunables-0.1.0/src/tunables/management/commands/__init__.py +0 -0
- django_tunables-0.1.0/src/tunables/management/commands/tunables_export.py +23 -0
- django_tunables-0.1.0/src/tunables/management/commands/tunables_import.py +37 -0
- django_tunables-0.1.0/src/tunables/management/commands/tunables_protect_history.py +55 -0
- django_tunables-0.1.0/src/tunables/management/commands/tunables_show.py +39 -0
- django_tunables-0.1.0/src/tunables/management/commands/tunables_sync.py +32 -0
- django_tunables-0.1.0/src/tunables/migrations/0001_initial.py +222 -0
- django_tunables-0.1.0/src/tunables/migrations/__init__.py +0 -0
- django_tunables-0.1.0/src/tunables/models.py +132 -0
- django_tunables-0.1.0/src/tunables/publishers.py +72 -0
- django_tunables-0.1.0/src/tunables/py.typed +0 -0
- django_tunables-0.1.0/src/tunables/registry.py +34 -0
- django_tunables-0.1.0/src/tunables/schema.py +80 -0
- django_tunables-0.1.0/src/tunables/schemas/snapshot-v1.schema.json +32 -0
- django_tunables-0.1.0/src/tunables/services.py +316 -0
- django_tunables-0.1.0/src/tunables/signals.py +3 -0
- django_tunables-0.1.0/src/tunables/sync.py +102 -0
- django_tunables-0.1.0/src/tunables/templates/tunables/admin/group_edit.html +45 -0
- django_tunables-0.1.0/src/tunables/templates/tunables/admin/group_index.html +31 -0
- django_tunables-0.1.0/src/tunables/templates/tunables/admin/rollback_confirm.html +36 -0
- django_tunables-0.1.0/src/tunables/types.py +276 -0
- django_tunables-0.1.0/tests/test_admin.py +270 -0
- django_tunables-0.1.0/tests/test_api_reads.py +332 -0
- django_tunables-0.1.0/tests/test_api_writes.py +349 -0
- django_tunables-0.1.0/tests/test_apps.py +9 -0
- django_tunables-0.1.0/tests/test_catalogue.py +185 -0
- django_tunables-0.1.0/tests/test_commands.py +164 -0
- django_tunables-0.1.0/tests/test_conf.py +28 -0
- django_tunables-0.1.0/tests/test_document.py +137 -0
- django_tunables-0.1.0/tests/test_models.py +179 -0
- django_tunables-0.1.0/tests/test_publishers.py +147 -0
- django_tunables-0.1.0/tests/test_registry.py +70 -0
- django_tunables-0.1.0/tests/test_schema.py +199 -0
- django_tunables-0.1.0/tests/test_services.py +347 -0
- django_tunables-0.1.0/tests/test_sync.py +144 -0
- django_tunables-0.1.0/tests/test_types.py +267 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ekiourk consulting ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-tunables
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime-tunable parameters for Django with change sets, versioned snapshots and schema-described forms.
|
|
5
|
+
Author: Ilias Kiourktsidis
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ekiourk/django-tunables
|
|
8
|
+
Project-URL: Repository, https://github.com/ekiourk/django-tunables
|
|
9
|
+
Project-URL: Changelog, https://github.com/ekiourk/django-tunables/blob/master/CHANGELOG.md
|
|
10
|
+
Keywords: django,settings,configuration,feature-flags,audit,rest
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
|
+
Classifier: Framework :: Django :: 6.0
|
|
15
|
+
Classifier: Framework :: Django :: 6.1
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: Django>=5.2
|
|
26
|
+
Requires-Dist: djangorestframework>=3.15
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-django; extra == "dev"
|
|
31
|
+
Requires-Dist: jsonschema[format]; extra == "dev"
|
|
32
|
+
Requires-Dist: django-tunables[fixtures]; extra == "dev"
|
|
33
|
+
Requires-Dist: ipdb; extra == "dev"
|
|
34
|
+
Provides-Extra: fixtures
|
|
35
|
+
Requires-Dist: testcontainers[postgres]==4.12.0; extra == "fixtures"
|
|
36
|
+
Requires-Dist: psycopg[binary]; extra == "fixtures"
|
|
37
|
+
Provides-Extra: lint
|
|
38
|
+
Requires-Dist: ruff; extra == "lint"
|
|
39
|
+
Requires-Dist: mypy; extra == "lint"
|
|
40
|
+
Requires-Dist: django-stubs; extra == "lint"
|
|
41
|
+
Requires-Dist: djangorestframework-stubs; extra == "lint"
|
|
42
|
+
Dynamic: license-file
|
|
43
|
+
|
|
44
|
+
# django-tunables
|
|
45
|
+
|
|
46
|
+
Runtime-tunable parameters for Django with a full audit trail. The host project declares
|
|
47
|
+
a catalogue of parameters in Python: keys, types, defaults, constraints, grouping.
|
|
48
|
+
Operators change values through the Django admin or a REST API. Every change is an
|
|
49
|
+
atomic, versioned change set, and every version produces a snapshot: one JSON document
|
|
50
|
+
holding the complete effective parameter set, ready for other processes to read from
|
|
51
|
+
the database or a file. Each group can also be described as JSON Schema plus a UI
|
|
52
|
+
schema, so an external client can render its own editing forms.
|
|
53
|
+
|
|
54
|
+
It is not a replacement for Django settings, and it does not patch `django.conf.settings`.
|
|
55
|
+
It has no per-user, per-tenant or per-environment scopes; one database holds one
|
|
56
|
+
catalogue with one effective value per key. Scheduled changes, nullable values, and
|
|
57
|
+
notifications beyond the publisher hook are out of scope.
|
|
58
|
+
|
|
59
|
+
## How it differs from django-constance and django-dynamic-preferences
|
|
60
|
+
|
|
61
|
+
Both of those store dynamic settings in the database and edit them in the admin.
|
|
62
|
+
django-tunables adds change sets with actor and reason, an append-only history, a
|
|
63
|
+
snapshot per version that other processes can read without importing the package, and a
|
|
64
|
+
schema endpoint that describes each group to external clients. Two ideas were borrowed:
|
|
65
|
+
the type to form field mapping comes from constance, and giving each type its own form
|
|
66
|
+
field, serializer and validator comes from dynamic-preferences.
|
|
67
|
+
|
|
68
|
+
## Requirements
|
|
69
|
+
|
|
70
|
+
Python 3.12 or newer, Django 5.2 or newer, Django REST Framework 3.15 or newer.
|
|
71
|
+
PostgreSQL 14 or newer for the optional database-level history protection. SQLite works
|
|
72
|
+
for everything else.
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
pip install django-tunables
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Quickstart
|
|
79
|
+
|
|
80
|
+
Add the apps and point the setting at your catalogue. In `settings.py`:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
INSTALLED_APPS = [
|
|
84
|
+
# ...
|
|
85
|
+
"rest_framework",
|
|
86
|
+
"tunables",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
TUNABLES = {"CATALOGUE": "myproject.tunables_catalogue.catalogue"}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Declare the catalogue. In `myproject/tunables_catalogue.py`:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from tunables import Boolean, Catalogue, Enum, Float, Group, List, Tunable
|
|
96
|
+
|
|
97
|
+
pricing = Group(
|
|
98
|
+
"pricing",
|
|
99
|
+
title="Pricing",
|
|
100
|
+
order=1,
|
|
101
|
+
tunables=[
|
|
102
|
+
Tunable("vat_rate", Float(min=0.0, max=1.0), 0.24, title="VAT rate"),
|
|
103
|
+
Tunable("free_shipping_over", Float(min=0.0), 50.0, title="Free shipping over", unit="EUR"),
|
|
104
|
+
Tunable("currencies", List(Enum(["EUR", "USD", "GBP"]), min_items=1, unique=True), ["EUR"]),
|
|
105
|
+
Tunable("allow_backorders", Boolean(), False),
|
|
106
|
+
],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
thermostat = Group(
|
|
110
|
+
"thermostat",
|
|
111
|
+
title="Thermostat",
|
|
112
|
+
order=2,
|
|
113
|
+
tunables=[
|
|
114
|
+
Tunable("target_c", Float(min=5.0, max=30.0), 21.0, title="Target temperature", unit="°C"),
|
|
115
|
+
Tunable("mode", Enum(["auto", "heat", "cool", "off"]), "auto"),
|
|
116
|
+
],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
catalogue = Catalogue([pricing, thermostat])
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Mount the API next to the admin. In `myproject/urls.py`:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from django.contrib import admin
|
|
126
|
+
from django.urls import include, path
|
|
127
|
+
|
|
128
|
+
urlpatterns = [
|
|
129
|
+
path("admin/", admin.site.urls),
|
|
130
|
+
path("api/tunables/", include("tunables.api.urls")),
|
|
131
|
+
]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Create the tables and the first snapshot:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
python manage.py migrate
|
|
138
|
+
python manage.py tunables_sync
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Read the effective values:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
curl -s http://localhost:8000/api/tunables/values/
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Change one:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
curl -s -X POST http://localhost:8000/api/tunables/changesets/ \
|
|
151
|
+
-H "Content-Type: application/json" \
|
|
152
|
+
-H "X-Tunables-Actor: alice" \
|
|
153
|
+
-d '{"changes": [{"key": "pricing.vat_rate", "value": 0.2}], "reason": "autumn rate"}'
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The response is the new change set with version 1. `tunables_show` prints the effective
|
|
157
|
+
values with an override marker, and the admin's Tunables page offers the same edit as a
|
|
158
|
+
form. Run `tunables_sync` after every deployment, after `migrate`, so the database
|
|
159
|
+
mirror follows the catalogue in code.
|
|
160
|
+
|
|
161
|
+
## Settings
|
|
162
|
+
|
|
163
|
+
All settings live in one dictionary, `TUNABLES`. Only `CATALOGUE` is required.
|
|
164
|
+
|
|
165
|
+
| Key | Default | Meaning |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `CATALOGUE` | required | Dotted path to a `Catalogue` instance or to a zero-argument callable returning one. |
|
|
168
|
+
| `ENVIRONMENT` | `""` | Written into every snapshot document as `environment`. |
|
|
169
|
+
| `PUBLISHERS` | `[]` | Dotted paths of publisher classes, instantiated once with no arguments. |
|
|
170
|
+
| `FILE_PUBLISHER_PATH` | `None` | Target file of `tunables.publishers.FilePublisher`. |
|
|
171
|
+
| `PAGE_SIZE` | `50` | Page size of the change set list endpoint. |
|
|
172
|
+
| `API_AUTHENTICATION_CLASSES` | `None` | DRF authentication classes for the API. `None` uses the host's DRF defaults. |
|
|
173
|
+
| `API_PERMISSION_CLASSES` | `None` | DRF permission classes for the API. `None` uses the host's DRF defaults. |
|
|
174
|
+
| `ACTOR_RESOLVER` | `tunables.api.actors.default_actor_resolver` | Callable turning a request into an `Actor`. |
|
|
175
|
+
| `EDITABLE_GROUPS` | `None` | Callable returning the group names a request may write, or `None` for all. |
|
|
176
|
+
| `ACTOR_HEADER` | `X-Tunables-Actor` | Header naming the actor of an unauthenticated request. |
|
|
177
|
+
| `CLIENT_HEADER` | `X-Tunables-Client` | Header naming the client program. |
|
|
178
|
+
| `REQUEST_ID_HEADER` | `X-Request-ID` | Header whose value is recorded as the change set's request id. |
|
|
179
|
+
|
|
180
|
+
## Management commands
|
|
181
|
+
|
|
182
|
+
| Command | Does |
|
|
183
|
+
|---|---|
|
|
184
|
+
| `tunables_sync [--check]` | Mirrors the catalogue into the database, creates the state row and snapshot 0 on a fresh database, and writes a system version when the catalogue structure changed. `--check` exits 1 when the stored catalogue version differs from the code, without writing. Idempotent. |
|
|
185
|
+
| `tunables_show [--group NAME] [--json]` | Prints the effective values of the latest snapshot, one key per line, marking overrides. `--json` prints the snapshot document. |
|
|
186
|
+
| `tunables_export [--output FILE]` | Writes the latest snapshot document as JSON to a file or to standard output. |
|
|
187
|
+
| `tunables_import FILE --actor NAME [--reason TEXT] [--strict]` | Applies the values of a snapshot document as one change set with `source: import`. Unknown keys are skipped with a warning, or rejected with `--strict`. |
|
|
188
|
+
| `tunables_protect_history [--remove] [--database ALIAS]` | Installs PostgreSQL triggers that reject `UPDATE`, `DELETE` and `TRUNCATE` on the history tables. |
|
|
189
|
+
|
|
190
|
+
`tunables_sync --check` compares the catalogue hash only. A change to a title or
|
|
191
|
+
description makes the mirror stale without changing the hash; the next `tunables_sync`
|
|
192
|
+
updates the mirror without writing a new version.
|
|
193
|
+
|
|
194
|
+
## Publishers and the signal
|
|
195
|
+
|
|
196
|
+
The snapshot row in the database is always written. In addition, every committed
|
|
197
|
+
snapshot is sent to the `tunables.signals.snapshot_published` signal, with the
|
|
198
|
+
`Snapshot` instance as `snapshot`, and then to each publisher named in `PUBLISHERS`.
|
|
199
|
+
A publisher is any object with a `publish(snapshot)` method. An exception inside
|
|
200
|
+
`publish` is logged under the `tunables.publishers` logger and does not affect the
|
|
201
|
+
write. Both the signal and the publishers run after the transaction commits.
|
|
202
|
+
|
|
203
|
+
`tunables.publishers.FilePublisher` writes the document to `FILE_PUBLISHER_PATH`, using
|
|
204
|
+
a temporary file in the same directory and a rename, so readers never see a partial
|
|
205
|
+
file:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
TUNABLES = {
|
|
209
|
+
"CATALOGUE": "myproject.tunables_catalogue.catalogue",
|
|
210
|
+
"PUBLISHERS": ["tunables.publishers.FilePublisher"],
|
|
211
|
+
"FILE_PUBLISHER_PATH": "/var/lib/myproject/tunables.json",
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Reading snapshots from other processes
|
|
216
|
+
|
|
217
|
+
A reader polls `tunables_state.current_version`, and when it changes fetches
|
|
218
|
+
`tunables_snapshot.document` for that version. The document format, the JSON Schema
|
|
219
|
+
shipped with the package, and the table contract are in
|
|
220
|
+
[docs/snapshot-format.md](docs/snapshot-format.md).
|
|
221
|
+
|
|
222
|
+
## API
|
|
223
|
+
|
|
224
|
+
| Method and path | Purpose |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `GET groups/`, `GET groups/{group}/` | groups and their definitions |
|
|
227
|
+
| `GET groups/{group}/schema/`, `GET schema/` | JSON Schema and UI schema |
|
|
228
|
+
| `GET values/`, `GET groups/{group}/values/` | effective values, with `ETag` |
|
|
229
|
+
| `GET changesets/`, `GET changesets/{version}/` | history, paginated and filterable |
|
|
230
|
+
| `GET snapshots/latest/`, `GET snapshots/{version}/`, `GET export/` | snapshot documents |
|
|
231
|
+
| `POST changesets/`, `POST validate/` | apply or dry-run a list of changes |
|
|
232
|
+
| `PATCH groups/{group}/values/` | form-shaped write of one group |
|
|
233
|
+
| `POST rollback/`, `POST import/` | restore a version, import a document |
|
|
234
|
+
|
|
235
|
+
Writes accept `If-Match` for optimistic concurrency and every response carries
|
|
236
|
+
`X-Tunables-Version`. Errors are RFC 9457 problem documents. The full reference,
|
|
237
|
+
including the problem types and error codes, is in [docs/api.md](docs/api.md).
|
|
238
|
+
|
|
239
|
+
## Admin
|
|
240
|
+
|
|
241
|
+
The Tunables page in the admin lists the groups and edits one group per form with a
|
|
242
|
+
reason field and a reset box per override. The change set history is read only and has
|
|
243
|
+
a rollback action. See [docs/admin.md](docs/admin.md).
|
|
244
|
+
|
|
245
|
+
A screenshot of the group edit form will be added here.
|
|
246
|
+
|
|
247
|
+
## Documentation
|
|
248
|
+
|
|
249
|
+
- [Declaring the catalogue](docs/catalogue.md)
|
|
250
|
+
- [REST API](docs/api.md)
|
|
251
|
+
- [Django admin](docs/admin.md)
|
|
252
|
+
- [Snapshot format and reader contract](docs/snapshot-format.md)
|
|
253
|
+
|
|
254
|
+
## Development
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
uv sync --all-extras
|
|
258
|
+
uv run pytest
|
|
259
|
+
uv run pytest --postgres # the whole suite on a PostgreSQL testcontainer, needs Docker
|
|
260
|
+
uv run ruff check . && uv run mypy src
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## License
|
|
264
|
+
|
|
265
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# django-tunables
|
|
2
|
+
|
|
3
|
+
Runtime-tunable parameters for Django with a full audit trail. The host project declares
|
|
4
|
+
a catalogue of parameters in Python: keys, types, defaults, constraints, grouping.
|
|
5
|
+
Operators change values through the Django admin or a REST API. Every change is an
|
|
6
|
+
atomic, versioned change set, and every version produces a snapshot: one JSON document
|
|
7
|
+
holding the complete effective parameter set, ready for other processes to read from
|
|
8
|
+
the database or a file. Each group can also be described as JSON Schema plus a UI
|
|
9
|
+
schema, so an external client can render its own editing forms.
|
|
10
|
+
|
|
11
|
+
It is not a replacement for Django settings, and it does not patch `django.conf.settings`.
|
|
12
|
+
It has no per-user, per-tenant or per-environment scopes; one database holds one
|
|
13
|
+
catalogue with one effective value per key. Scheduled changes, nullable values, and
|
|
14
|
+
notifications beyond the publisher hook are out of scope.
|
|
15
|
+
|
|
16
|
+
## How it differs from django-constance and django-dynamic-preferences
|
|
17
|
+
|
|
18
|
+
Both of those store dynamic settings in the database and edit them in the admin.
|
|
19
|
+
django-tunables adds change sets with actor and reason, an append-only history, a
|
|
20
|
+
snapshot per version that other processes can read without importing the package, and a
|
|
21
|
+
schema endpoint that describes each group to external clients. Two ideas were borrowed:
|
|
22
|
+
the type to form field mapping comes from constance, and giving each type its own form
|
|
23
|
+
field, serializer and validator comes from dynamic-preferences.
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
Python 3.12 or newer, Django 5.2 or newer, Django REST Framework 3.15 or newer.
|
|
28
|
+
PostgreSQL 14 or newer for the optional database-level history protection. SQLite works
|
|
29
|
+
for everything else.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install django-tunables
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
Add the apps and point the setting at your catalogue. In `settings.py`:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
INSTALLED_APPS = [
|
|
41
|
+
# ...
|
|
42
|
+
"rest_framework",
|
|
43
|
+
"tunables",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
TUNABLES = {"CATALOGUE": "myproject.tunables_catalogue.catalogue"}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Declare the catalogue. In `myproject/tunables_catalogue.py`:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from tunables import Boolean, Catalogue, Enum, Float, Group, List, Tunable
|
|
53
|
+
|
|
54
|
+
pricing = Group(
|
|
55
|
+
"pricing",
|
|
56
|
+
title="Pricing",
|
|
57
|
+
order=1,
|
|
58
|
+
tunables=[
|
|
59
|
+
Tunable("vat_rate", Float(min=0.0, max=1.0), 0.24, title="VAT rate"),
|
|
60
|
+
Tunable("free_shipping_over", Float(min=0.0), 50.0, title="Free shipping over", unit="EUR"),
|
|
61
|
+
Tunable("currencies", List(Enum(["EUR", "USD", "GBP"]), min_items=1, unique=True), ["EUR"]),
|
|
62
|
+
Tunable("allow_backorders", Boolean(), False),
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
thermostat = Group(
|
|
67
|
+
"thermostat",
|
|
68
|
+
title="Thermostat",
|
|
69
|
+
order=2,
|
|
70
|
+
tunables=[
|
|
71
|
+
Tunable("target_c", Float(min=5.0, max=30.0), 21.0, title="Target temperature", unit="°C"),
|
|
72
|
+
Tunable("mode", Enum(["auto", "heat", "cool", "off"]), "auto"),
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
catalogue = Catalogue([pricing, thermostat])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Mount the API next to the admin. In `myproject/urls.py`:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from django.contrib import admin
|
|
83
|
+
from django.urls import include, path
|
|
84
|
+
|
|
85
|
+
urlpatterns = [
|
|
86
|
+
path("admin/", admin.site.urls),
|
|
87
|
+
path("api/tunables/", include("tunables.api.urls")),
|
|
88
|
+
]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Create the tables and the first snapshot:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
python manage.py migrate
|
|
95
|
+
python manage.py tunables_sync
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Read the effective values:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
curl -s http://localhost:8000/api/tunables/values/
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Change one:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
curl -s -X POST http://localhost:8000/api/tunables/changesets/ \
|
|
108
|
+
-H "Content-Type: application/json" \
|
|
109
|
+
-H "X-Tunables-Actor: alice" \
|
|
110
|
+
-d '{"changes": [{"key": "pricing.vat_rate", "value": 0.2}], "reason": "autumn rate"}'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The response is the new change set with version 1. `tunables_show` prints the effective
|
|
114
|
+
values with an override marker, and the admin's Tunables page offers the same edit as a
|
|
115
|
+
form. Run `tunables_sync` after every deployment, after `migrate`, so the database
|
|
116
|
+
mirror follows the catalogue in code.
|
|
117
|
+
|
|
118
|
+
## Settings
|
|
119
|
+
|
|
120
|
+
All settings live in one dictionary, `TUNABLES`. Only `CATALOGUE` is required.
|
|
121
|
+
|
|
122
|
+
| Key | Default | Meaning |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `CATALOGUE` | required | Dotted path to a `Catalogue` instance or to a zero-argument callable returning one. |
|
|
125
|
+
| `ENVIRONMENT` | `""` | Written into every snapshot document as `environment`. |
|
|
126
|
+
| `PUBLISHERS` | `[]` | Dotted paths of publisher classes, instantiated once with no arguments. |
|
|
127
|
+
| `FILE_PUBLISHER_PATH` | `None` | Target file of `tunables.publishers.FilePublisher`. |
|
|
128
|
+
| `PAGE_SIZE` | `50` | Page size of the change set list endpoint. |
|
|
129
|
+
| `API_AUTHENTICATION_CLASSES` | `None` | DRF authentication classes for the API. `None` uses the host's DRF defaults. |
|
|
130
|
+
| `API_PERMISSION_CLASSES` | `None` | DRF permission classes for the API. `None` uses the host's DRF defaults. |
|
|
131
|
+
| `ACTOR_RESOLVER` | `tunables.api.actors.default_actor_resolver` | Callable turning a request into an `Actor`. |
|
|
132
|
+
| `EDITABLE_GROUPS` | `None` | Callable returning the group names a request may write, or `None` for all. |
|
|
133
|
+
| `ACTOR_HEADER` | `X-Tunables-Actor` | Header naming the actor of an unauthenticated request. |
|
|
134
|
+
| `CLIENT_HEADER` | `X-Tunables-Client` | Header naming the client program. |
|
|
135
|
+
| `REQUEST_ID_HEADER` | `X-Request-ID` | Header whose value is recorded as the change set's request id. |
|
|
136
|
+
|
|
137
|
+
## Management commands
|
|
138
|
+
|
|
139
|
+
| Command | Does |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `tunables_sync [--check]` | Mirrors the catalogue into the database, creates the state row and snapshot 0 on a fresh database, and writes a system version when the catalogue structure changed. `--check` exits 1 when the stored catalogue version differs from the code, without writing. Idempotent. |
|
|
142
|
+
| `tunables_show [--group NAME] [--json]` | Prints the effective values of the latest snapshot, one key per line, marking overrides. `--json` prints the snapshot document. |
|
|
143
|
+
| `tunables_export [--output FILE]` | Writes the latest snapshot document as JSON to a file or to standard output. |
|
|
144
|
+
| `tunables_import FILE --actor NAME [--reason TEXT] [--strict]` | Applies the values of a snapshot document as one change set with `source: import`. Unknown keys are skipped with a warning, or rejected with `--strict`. |
|
|
145
|
+
| `tunables_protect_history [--remove] [--database ALIAS]` | Installs PostgreSQL triggers that reject `UPDATE`, `DELETE` and `TRUNCATE` on the history tables. |
|
|
146
|
+
|
|
147
|
+
`tunables_sync --check` compares the catalogue hash only. A change to a title or
|
|
148
|
+
description makes the mirror stale without changing the hash; the next `tunables_sync`
|
|
149
|
+
updates the mirror without writing a new version.
|
|
150
|
+
|
|
151
|
+
## Publishers and the signal
|
|
152
|
+
|
|
153
|
+
The snapshot row in the database is always written. In addition, every committed
|
|
154
|
+
snapshot is sent to the `tunables.signals.snapshot_published` signal, with the
|
|
155
|
+
`Snapshot` instance as `snapshot`, and then to each publisher named in `PUBLISHERS`.
|
|
156
|
+
A publisher is any object with a `publish(snapshot)` method. An exception inside
|
|
157
|
+
`publish` is logged under the `tunables.publishers` logger and does not affect the
|
|
158
|
+
write. Both the signal and the publishers run after the transaction commits.
|
|
159
|
+
|
|
160
|
+
`tunables.publishers.FilePublisher` writes the document to `FILE_PUBLISHER_PATH`, using
|
|
161
|
+
a temporary file in the same directory and a rename, so readers never see a partial
|
|
162
|
+
file:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
TUNABLES = {
|
|
166
|
+
"CATALOGUE": "myproject.tunables_catalogue.catalogue",
|
|
167
|
+
"PUBLISHERS": ["tunables.publishers.FilePublisher"],
|
|
168
|
+
"FILE_PUBLISHER_PATH": "/var/lib/myproject/tunables.json",
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Reading snapshots from other processes
|
|
173
|
+
|
|
174
|
+
A reader polls `tunables_state.current_version`, and when it changes fetches
|
|
175
|
+
`tunables_snapshot.document` for that version. The document format, the JSON Schema
|
|
176
|
+
shipped with the package, and the table contract are in
|
|
177
|
+
[docs/snapshot-format.md](docs/snapshot-format.md).
|
|
178
|
+
|
|
179
|
+
## API
|
|
180
|
+
|
|
181
|
+
| Method and path | Purpose |
|
|
182
|
+
|---|---|
|
|
183
|
+
| `GET groups/`, `GET groups/{group}/` | groups and their definitions |
|
|
184
|
+
| `GET groups/{group}/schema/`, `GET schema/` | JSON Schema and UI schema |
|
|
185
|
+
| `GET values/`, `GET groups/{group}/values/` | effective values, with `ETag` |
|
|
186
|
+
| `GET changesets/`, `GET changesets/{version}/` | history, paginated and filterable |
|
|
187
|
+
| `GET snapshots/latest/`, `GET snapshots/{version}/`, `GET export/` | snapshot documents |
|
|
188
|
+
| `POST changesets/`, `POST validate/` | apply or dry-run a list of changes |
|
|
189
|
+
| `PATCH groups/{group}/values/` | form-shaped write of one group |
|
|
190
|
+
| `POST rollback/`, `POST import/` | restore a version, import a document |
|
|
191
|
+
|
|
192
|
+
Writes accept `If-Match` for optimistic concurrency and every response carries
|
|
193
|
+
`X-Tunables-Version`. Errors are RFC 9457 problem documents. The full reference,
|
|
194
|
+
including the problem types and error codes, is in [docs/api.md](docs/api.md).
|
|
195
|
+
|
|
196
|
+
## Admin
|
|
197
|
+
|
|
198
|
+
The Tunables page in the admin lists the groups and edits one group per form with a
|
|
199
|
+
reason field and a reset box per override. The change set history is read only and has
|
|
200
|
+
a rollback action. See [docs/admin.md](docs/admin.md).
|
|
201
|
+
|
|
202
|
+
A screenshot of the group edit form will be added here.
|
|
203
|
+
|
|
204
|
+
## Documentation
|
|
205
|
+
|
|
206
|
+
- [Declaring the catalogue](docs/catalogue.md)
|
|
207
|
+
- [REST API](docs/api.md)
|
|
208
|
+
- [Django admin](docs/admin.md)
|
|
209
|
+
- [Snapshot format and reader contract](docs/snapshot-format.md)
|
|
210
|
+
|
|
211
|
+
## Development
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
uv sync --all-extras
|
|
215
|
+
uv run pytest
|
|
216
|
+
uv run pytest --postgres # the whole suite on a PostgreSQL testcontainer, needs Docker
|
|
217
|
+
uv run ruff check . && uv run mypy src
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "django-tunables"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.12"
|
|
5
|
+
description = "Runtime-tunable parameters for Django with change sets, versioned snapshots and schema-described forms."
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [{name = "Ilias Kiourktsidis"}]
|
|
9
|
+
keywords = ["django", "settings", "configuration", "feature-flags", "audit", "rest"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Framework :: Django",
|
|
13
|
+
"Framework :: Django :: 5.2",
|
|
14
|
+
"Framework :: Django :: 6.0",
|
|
15
|
+
"Framework :: Django :: 6.1",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["Django>=5.2", "djangorestframework>=3.15"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/ekiourk/django-tunables"
|
|
27
|
+
Repository = "https://github.com/ekiourk/django-tunables"
|
|
28
|
+
Changelog = "https://github.com/ekiourk/django-tunables/blob/master/CHANGELOG.md"
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest", "pytest-cov", "pytest-django", "jsonschema[format]", "django-tunables[fixtures]", "ipdb"]
|
|
32
|
+
fixtures = ["testcontainers[postgres]==4.12.0", "psycopg[binary]"]
|
|
33
|
+
lint = ["ruff", "mypy", "django-stubs", "djangorestframework-stubs"]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools>=61.0"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools]
|
|
40
|
+
package-dir = {"" = "src"}
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["src"]
|
|
43
|
+
[tool.setuptools.package-data]
|
|
44
|
+
tunables = ["py.typed", "schemas/*.json", "templates/**/*.html"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
src = ["src", "."]
|
|
48
|
+
line-length = 120
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E", "W", "F", "I", "B", "C4", "UP", "A", "ARG", "PTH", "SIM", "T20"]
|
|
51
|
+
ignore = ["E501", "B008"]
|
|
52
|
+
[tool.ruff.lint.per-file-ignores]
|
|
53
|
+
"src/tunables/api/views.py" = ["ARG002"]
|
|
54
|
+
"src/tunables/api/writes.py" = ["ARG002"]
|
|
55
|
+
"src/tunables/admin/__init__.py" = ["ARG002"]
|
|
56
|
+
"test_*.py" = ["ARG001", "ARG002"]
|
|
57
|
+
"conftest.py" = ["ARG001", "ARG002"]
|
|
58
|
+
[tool.ruff.format]
|
|
59
|
+
quote-style = "double"
|
|
60
|
+
indent-style = "space"
|
|
61
|
+
|
|
62
|
+
[tool.mypy]
|
|
63
|
+
python_version = "3.12"
|
|
64
|
+
strict = true
|
|
65
|
+
plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"]
|
|
66
|
+
[tool.django-stubs]
|
|
67
|
+
django_settings_module = "tests.settings"
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
71
|
+
pythonpath = ["."]
|
|
72
|
+
addopts = "--strict-markers"
|
|
73
|
+
markers = ["postgres: needs a PostgreSQL testcontainer"]
|