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.
Files changed (65) hide show
  1. django_tunables-0.1.0/LICENSE +21 -0
  2. django_tunables-0.1.0/PKG-INFO +265 -0
  3. django_tunables-0.1.0/README.md +222 -0
  4. django_tunables-0.1.0/pyproject.toml +73 -0
  5. django_tunables-0.1.0/setup.cfg +4 -0
  6. django_tunables-0.1.0/src/django_tunables.egg-info/PKG-INFO +265 -0
  7. django_tunables-0.1.0/src/django_tunables.egg-info/SOURCES.txt +63 -0
  8. django_tunables-0.1.0/src/django_tunables.egg-info/dependency_links.txt +1 -0
  9. django_tunables-0.1.0/src/django_tunables.egg-info/requires.txt +20 -0
  10. django_tunables-0.1.0/src/django_tunables.egg-info/top_level.txt +1 -0
  11. django_tunables-0.1.0/src/tunables/__init__.py +18 -0
  12. django_tunables-0.1.0/src/tunables/admin/__init__.py +221 -0
  13. django_tunables-0.1.0/src/tunables/admin/forms.py +56 -0
  14. django_tunables-0.1.0/src/tunables/api/__init__.py +0 -0
  15. django_tunables-0.1.0/src/tunables/api/actors.py +29 -0
  16. django_tunables-0.1.0/src/tunables/api/base.py +45 -0
  17. django_tunables-0.1.0/src/tunables/api/problems.py +62 -0
  18. django_tunables-0.1.0/src/tunables/api/serializers.py +58 -0
  19. django_tunables-0.1.0/src/tunables/api/urls.py +23 -0
  20. django_tunables-0.1.0/src/tunables/api/views.py +190 -0
  21. django_tunables-0.1.0/src/tunables/api/writes.py +112 -0
  22. django_tunables-0.1.0/src/tunables/apps.py +7 -0
  23. django_tunables-0.1.0/src/tunables/catalogue.py +133 -0
  24. django_tunables-0.1.0/src/tunables/changes.py +16 -0
  25. django_tunables-0.1.0/src/tunables/conf.py +31 -0
  26. django_tunables-0.1.0/src/tunables/document.py +41 -0
  27. django_tunables-0.1.0/src/tunables/errors.py +86 -0
  28. django_tunables-0.1.0/src/tunables/management/__init__.py +0 -0
  29. django_tunables-0.1.0/src/tunables/management/base.py +22 -0
  30. django_tunables-0.1.0/src/tunables/management/commands/__init__.py +0 -0
  31. django_tunables-0.1.0/src/tunables/management/commands/tunables_export.py +23 -0
  32. django_tunables-0.1.0/src/tunables/management/commands/tunables_import.py +37 -0
  33. django_tunables-0.1.0/src/tunables/management/commands/tunables_protect_history.py +55 -0
  34. django_tunables-0.1.0/src/tunables/management/commands/tunables_show.py +39 -0
  35. django_tunables-0.1.0/src/tunables/management/commands/tunables_sync.py +32 -0
  36. django_tunables-0.1.0/src/tunables/migrations/0001_initial.py +222 -0
  37. django_tunables-0.1.0/src/tunables/migrations/__init__.py +0 -0
  38. django_tunables-0.1.0/src/tunables/models.py +132 -0
  39. django_tunables-0.1.0/src/tunables/publishers.py +72 -0
  40. django_tunables-0.1.0/src/tunables/py.typed +0 -0
  41. django_tunables-0.1.0/src/tunables/registry.py +34 -0
  42. django_tunables-0.1.0/src/tunables/schema.py +80 -0
  43. django_tunables-0.1.0/src/tunables/schemas/snapshot-v1.schema.json +32 -0
  44. django_tunables-0.1.0/src/tunables/services.py +316 -0
  45. django_tunables-0.1.0/src/tunables/signals.py +3 -0
  46. django_tunables-0.1.0/src/tunables/sync.py +102 -0
  47. django_tunables-0.1.0/src/tunables/templates/tunables/admin/group_edit.html +45 -0
  48. django_tunables-0.1.0/src/tunables/templates/tunables/admin/group_index.html +31 -0
  49. django_tunables-0.1.0/src/tunables/templates/tunables/admin/rollback_confirm.html +36 -0
  50. django_tunables-0.1.0/src/tunables/types.py +276 -0
  51. django_tunables-0.1.0/tests/test_admin.py +270 -0
  52. django_tunables-0.1.0/tests/test_api_reads.py +332 -0
  53. django_tunables-0.1.0/tests/test_api_writes.py +349 -0
  54. django_tunables-0.1.0/tests/test_apps.py +9 -0
  55. django_tunables-0.1.0/tests/test_catalogue.py +185 -0
  56. django_tunables-0.1.0/tests/test_commands.py +164 -0
  57. django_tunables-0.1.0/tests/test_conf.py +28 -0
  58. django_tunables-0.1.0/tests/test_document.py +137 -0
  59. django_tunables-0.1.0/tests/test_models.py +179 -0
  60. django_tunables-0.1.0/tests/test_publishers.py +147 -0
  61. django_tunables-0.1.0/tests/test_registry.py +70 -0
  62. django_tunables-0.1.0/tests/test_schema.py +199 -0
  63. django_tunables-0.1.0/tests/test_services.py +347 -0
  64. django_tunables-0.1.0/tests/test_sync.py +144 -0
  65. 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+