django-phase 0.1.0__py3-none-any.whl
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_phase-0.1.0.dist-info/METADATA +188 -0
- django_phase-0.1.0.dist-info/RECORD +12 -0
- django_phase-0.1.0.dist-info/WHEEL +4 -0
- django_phase-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- phase/__init__.py +3 -0
- phase/apps.py +9 -0
- phase/checks.py +57 -0
- phase/exceptions.py +96 -0
- phase/fields.py +174 -0
- phase/mixins/__init__.py +0 -0
- phase/mixins/phase_guard.py +64 -0
- phase/transitions.py +16 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-phase
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Race-safe state machines for Django models: a protected PhaseField and transitions as one conditional UPDATE
|
|
5
|
+
Project-URL: Homepage, https://github.com/beyonder-deutschland/django-phase/
|
|
6
|
+
Project-URL: Documentation, https://django-phase.readthedocs.io/en/latest/index.html
|
|
7
|
+
Project-URL: Maintained by, https://beyonder.de/
|
|
8
|
+
Project-URL: Bugtracker, https://github.com/beyonder-deutschland/django-phase/issues
|
|
9
|
+
Project-URL: Changelog, https://django-phase.readthedocs.io/en/latest/features/changelog.html
|
|
10
|
+
Author-email: Beyonder Deutschland <hello@beyonder.de>
|
|
11
|
+
License: MIT License
|
|
12
|
+
|
|
13
|
+
Copyright (c) 2026 Beyonder Deutschland GmbH
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
16
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
17
|
+
in the Software without restriction, including without limitation the rights
|
|
18
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
19
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
20
|
+
furnished to do so, subject to the following conditions:
|
|
21
|
+
|
|
22
|
+
The above copyright notice and this permission notice shall be included in all
|
|
23
|
+
copies or substantial portions of the Software.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
27
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
28
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
29
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
30
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
31
|
+
SOFTWARE.
|
|
32
|
+
License-File: LICENSE.md
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Environment :: Web Environment
|
|
35
|
+
Classifier: Framework :: Django
|
|
36
|
+
Classifier: Framework :: Django :: 5.2
|
|
37
|
+
Classifier: Framework :: Django :: 6.0
|
|
38
|
+
Classifier: Framework :: Django :: 6.1
|
|
39
|
+
Classifier: Intended Audience :: Developers
|
|
40
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
41
|
+
Classifier: Natural Language :: English
|
|
42
|
+
Classifier: Operating System :: OS Independent
|
|
43
|
+
Classifier: Programming Language :: Python
|
|
44
|
+
Classifier: Programming Language :: Python :: 3
|
|
45
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
46
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
47
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
48
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
49
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
50
|
+
Classifier: Topic :: Utilities
|
|
51
|
+
Requires-Python: >=3.11
|
|
52
|
+
Requires-Dist: django>=5.2
|
|
53
|
+
Provides-Extra: dev
|
|
54
|
+
Requires-Dist: ambient-package-update; extra == 'dev'
|
|
55
|
+
Requires-Dist: keyring~=25.7; extra == 'dev'
|
|
56
|
+
Requires-Dist: pre-commit~=4.7; extra == 'dev'
|
|
57
|
+
Requires-Dist: pytest-cov~=7.1; extra == 'dev'
|
|
58
|
+
Requires-Dist: pytest-django~=4.14; extra == 'dev'
|
|
59
|
+
Requires-Dist: pytest-mock~=3.17; extra == 'dev'
|
|
60
|
+
Requires-Dist: sphinx-mdinclude~=0.6; extra == 'dev'
|
|
61
|
+
Requires-Dist: sphinx-rtd-theme~=3.1; extra == 'dev'
|
|
62
|
+
Requires-Dist: sphinx~=8.2; extra == 'dev'
|
|
63
|
+
Requires-Dist: typer~=0.27; extra == 'dev'
|
|
64
|
+
Requires-Dist: uv~=0.13; extra == 'dev'
|
|
65
|
+
Description-Content-Type: text/markdown
|
|
66
|
+
|
|
67
|
+
[](https://pypi.org/project/django-phase/)
|
|
68
|
+
[](https://pepy.tech/project/django-phase)
|
|
69
|
+
[](https://github.com/beyonder-deutschland/django-phase/actions?workflow=CI)
|
|
70
|
+
[](https://github.com/astral-sh/ruff)
|
|
71
|
+
[](https://github.com/astral-sh/ruff)
|
|
72
|
+
[](https://django-phase.readthedocs.io/en/latest/?badge=latest)
|
|
73
|
+
|
|
74
|
+
Race-safe state machines for Django models: a protected `PhaseField`, transitions as one conditional `UPDATE`, and
|
|
75
|
+
guards that tell humans and machines why an action was refused.
|
|
76
|
+
|
|
77
|
+
[PyPI](https://pypi.org/project/django-phase/) • [GitHub](https://github.com/beyonder-deutschland/django-phase) • [Full documentation](https://django-phase.readthedocs.io/en/latest/index.html)
|
|
78
|
+
|
|
79
|
+
Creator & Maintainer: [Beyonder Deutschland](https://beyonder.de/)
|
|
80
|
+
|
|
81
|
+
## Features
|
|
82
|
+
|
|
83
|
+
* Declare the allowed transitions once, as a table on the field
|
|
84
|
+
* Assigning the phase directly raises; only `move()` changes it
|
|
85
|
+
* `move()` writes phase and further fields in **one** conditional `UPDATE`, so of two concurrent transitions from the
|
|
86
|
+
same phase exactly one succeeds
|
|
87
|
+
* Every `save()` is guarded against a stale phase, so it can't silently undo a concurrent transition
|
|
88
|
+
* Migrations see a plain `CharField` and never import `phase`
|
|
89
|
+
* System checks catch a missing mixin, a second phase field or one inherited from a concrete parent model
|
|
90
|
+
* `PhaseGuardError` gives refused actions a stable `code` for machines and a `detail` for humans
|
|
91
|
+
* Overrides no private Django method
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from django.db import models
|
|
95
|
+
from django.utils import timezone
|
|
96
|
+
|
|
97
|
+
from phase.fields import PhaseField
|
|
98
|
+
from phase.mixins.phase_guard import PhaseGuardMixin
|
|
99
|
+
from phase.transitions import move
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Order(PhaseGuardMixin, models.Model):
|
|
103
|
+
class Status(models.TextChoices):
|
|
104
|
+
OPEN = "open"
|
|
105
|
+
PAID = "paid"
|
|
106
|
+
SHIPPED = "shipped"
|
|
107
|
+
CANCELLED = "cancelled"
|
|
108
|
+
|
|
109
|
+
status = PhaseField(
|
|
110
|
+
max_length=10,
|
|
111
|
+
choices=Status.choices,
|
|
112
|
+
default=Status.OPEN,
|
|
113
|
+
transitions={
|
|
114
|
+
Status.OPEN: (Status.PAID, Status.CANCELLED),
|
|
115
|
+
Status.PAID: (Status.SHIPPED,),
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
paid_at = models.DateTimeField(null=True, blank=True)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
order = Order.objects.create()
|
|
122
|
+
move(obj=order, to=Order.Status.PAID, paid_at=timezone.now())
|
|
123
|
+
|
|
124
|
+
order.status = Order.Status.OPEN # raises ProtectedPhaseFieldError
|
|
125
|
+
move(obj=order, to=Order.Status.OPEN) # raises IllegalPhaseTransitionError
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Installation
|
|
129
|
+
|
|
130
|
+
- Install the package via pip:
|
|
131
|
+
|
|
132
|
+
`pip install django_phase`
|
|
133
|
+
|
|
134
|
+
or via uv:
|
|
135
|
+
|
|
136
|
+
`uv add django_phase`
|
|
137
|
+
|
|
138
|
+
- Add module to `INSTALLED_APPS` within the main django `settings.py`:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
INSTALLED_APPS = (
|
|
142
|
+
# ...
|
|
143
|
+
"phase",
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Releasing a new version
|
|
148
|
+
|
|
149
|
+
Releases are fully automated. Push a version tag and the pipeline will build, sign with
|
|
150
|
+
[Sigstore](https://www.sigstore.dev/), publish to PyPI via
|
|
151
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/), and create a GitHub Release —
|
|
152
|
+
no API tokens needed.
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
git tag v<version> # e.g. git tag v1.2.3
|
|
156
|
+
git push origin v<version>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Tags **must** start with `v`. Tags without the prefix won't trigger the pipeline.
|
|
160
|
+
|
|
161
|
+
### First-time setup
|
|
162
|
+
|
|
163
|
+
Before the pipeline can run for the first time, an admin must:
|
|
164
|
+
|
|
165
|
+
1. **Create GitHub Environment `pypi`**
|
|
166
|
+
- Go to *Settings → Environments → New environment*, name it exactly `pypi`
|
|
167
|
+
- Under *Deployment branches and tags*, add a tag rule with pattern `v*`
|
|
168
|
+
- Optionally add required reviewers for a manual approval gate
|
|
169
|
+
|
|
170
|
+
2. **Configure PyPI Trusted Publisher**
|
|
171
|
+
- Go to *PyPI → Project settings → Publishing → Add a new publisher*
|
|
172
|
+
- Fill in: Owner `beyonder-deutschland`, Repository `django-phase`,
|
|
173
|
+
Workflow `release.yml`, Environment `pypi`
|
|
174
|
+
|
|
175
|
+
### Publish to ReadTheDocs.io
|
|
176
|
+
|
|
177
|
+
- Fetch the latest changes in GitHub mirror and push them
|
|
178
|
+
- Trigger new build at ReadTheDocs.io (follow instructions in admin panel at RTD) if the GitHub webhook is not yet set
|
|
179
|
+
up.
|
|
180
|
+
|
|
181
|
+
### Maintenance
|
|
182
|
+
|
|
183
|
+
Please note that this package supports the [ambient-package-update](https://pypi.org/project/ambient-package-update/).
|
|
184
|
+
So you don't have to worry about the maintenance of this package. This updater is rendering all important
|
|
185
|
+
configuration and setup files. It works similar to well-known updaters like `pyupgrade` or `django-upgrade`.
|
|
186
|
+
|
|
187
|
+
To run an update, refer to the [documentation page](https://pypi.org/project/ambient-package-update/)
|
|
188
|
+
of the "ambient-package-update".
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
phase/__init__.py,sha256=JnIHCWjHzrgr1bkK6AvjLOxNeE1aYICkL57oSOLJmiI,138
|
|
2
|
+
phase/apps.py,sha256=xLVNKm3jHfvxOgqJKnJYBeCzToaly7DgKUFG_1Y43AQ,258
|
|
3
|
+
phase/checks.py,sha256=njuvHSJEXGDddSwmM2-gFap7E7eWMXUPylzk2wXwgMo,2438
|
|
4
|
+
phase/exceptions.py,sha256=M0Gd9tJD5ar4rqn62wAc4i0KNXRxn5dqy7KXuAuBItc,4074
|
|
5
|
+
phase/fields.py,sha256=k6BJN7ideFY0pB5G_O_q-_e9Iy8daW00XeByAe7vFhM,8224
|
|
6
|
+
phase/transitions.py,sha256=XvzMEsRLCZV1lNBJuiA11vmSPAYvr76Sg8bet62eDOY,572
|
|
7
|
+
phase/mixins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
phase/mixins/phase_guard.py,sha256=hcygbhETdglz06Y7mo-vJmCntLur8O7IaYU7nENE4pY,2965
|
|
9
|
+
django_phase-0.1.0.dist-info/METADATA,sha256=v7VKCSRwfpegDSKK7eeBiiETyx-hBl0CCiNRRZgdBsM,8197
|
|
10
|
+
django_phase-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
django_phase-0.1.0.dist-info/licenses/LICENSE.md,sha256=b7UhMFD7Rhj2PPUk0Pqg815sqtLOvZFUcM8nTsiBf7k,1082
|
|
12
|
+
django_phase-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Beyonder Deutschland GmbH
|
|
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.
|
phase/__init__.py
ADDED
phase/apps.py
ADDED
phase/checks.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from itertools import chain
|
|
2
|
+
|
|
3
|
+
from django.apps import AppConfig, apps
|
|
4
|
+
from django.core import checks
|
|
5
|
+
|
|
6
|
+
from phase.fields import get_phase_fields
|
|
7
|
+
from phase.mixins.phase_guard import PhaseGuardMixin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@checks.register(checks.Tags.models)
|
|
11
|
+
def check_phase_fields(app_configs: list[AppConfig] | None = None, **kwargs: object) -> list[checks.Error]:
|
|
12
|
+
"""
|
|
13
|
+
Every model with a PhaseField inherits PhaseGuardMixin, has exactly one PhaseField and stores it in its own table.
|
|
14
|
+
|
|
15
|
+
Without the mixin the model can't be reloaded, because the field refuses the second assignment. With two phase
|
|
16
|
+
fields `phase.transitions.move()` couldn't tell which one to move. A phase field in the table of a concrete parent
|
|
17
|
+
makes Django split the UPDATE of `move()` into a SELECT of the primary keys and an UPDATE without the condition on
|
|
18
|
+
the phase, so two concurrent transitions could both succeed. Proxy models share their concrete model's fields and
|
|
19
|
+
are reported there.
|
|
20
|
+
"""
|
|
21
|
+
if app_configs is None:
|
|
22
|
+
models = apps.get_models()
|
|
23
|
+
else:
|
|
24
|
+
models = chain.from_iterable(app_config.get_models() for app_config in app_configs)
|
|
25
|
+
|
|
26
|
+
errors = []
|
|
27
|
+
for model in models:
|
|
28
|
+
if model._meta.proxy:
|
|
29
|
+
continue
|
|
30
|
+
phase_fields = get_phase_fields(model=model)
|
|
31
|
+
if phase_fields and not issubclass(model, PhaseGuardMixin):
|
|
32
|
+
errors.append(
|
|
33
|
+
checks.Error(
|
|
34
|
+
f"{model._meta.label} has a PhaseField but doesn't inherit from PhaseGuardMixin.",
|
|
35
|
+
obj=model,
|
|
36
|
+
id="phase.E001",
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
if len(phase_fields) > 1:
|
|
40
|
+
errors.append(
|
|
41
|
+
checks.Error(
|
|
42
|
+
f"{model._meta.label} has more than one PhaseField.",
|
|
43
|
+
hint="A model has one lifecycle; a second one usually belongs on its own model.",
|
|
44
|
+
obj=model,
|
|
45
|
+
id="phase.E002",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
if any(field.model is not model for field in phase_fields):
|
|
49
|
+
errors.append(
|
|
50
|
+
checks.Error(
|
|
51
|
+
f"{model._meta.label} inherits its PhaseField from a concrete parent model.",
|
|
52
|
+
hint="Make the parent abstract, or give the lifecycle its own model without concrete children.",
|
|
53
|
+
obj=model,
|
|
54
|
+
id="phase.E003",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
return errors
|
phase/exceptions.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
class IllegalPhaseTransitionError(Exception):
|
|
2
|
+
"""
|
|
3
|
+
The transition isn't in the table, or the row changed or vanished since `obj` was loaded.
|
|
4
|
+
|
|
5
|
+
`source` is the phase in the database when the row changed in the meantime, and `None` when the row doesn't exist.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
def __init__(self, *, model_name: str, pk: int | None, source: str | None, target: str):
|
|
9
|
+
self.model_name = model_name
|
|
10
|
+
self.pk = pk
|
|
11
|
+
self.source = source
|
|
12
|
+
self.target = target
|
|
13
|
+
if source is None:
|
|
14
|
+
message = f"{model_name} ID #{pk} doesn't exist in the database and cannot change to '{target}'."
|
|
15
|
+
else:
|
|
16
|
+
message = f"{model_name} ID #{pk} cannot change from '{source}' to '{target}'."
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
def __reduce__(self) -> tuple:
|
|
20
|
+
# The parallel test runner sends failures between processes; the default reduce calls __init__ positionally.
|
|
21
|
+
return _rebuild_illegal_phase_transition_error, (self.model_name, self.pk, self.source, self.target)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _rebuild_illegal_phase_transition_error(
|
|
25
|
+
model_name: str, pk: int | None, source: str | None, target: str
|
|
26
|
+
) -> IllegalPhaseTransitionError:
|
|
27
|
+
return IllegalPhaseTransitionError(model_name=model_name, pk=pk, source=source, target=target)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StalePhaseError(Exception):
|
|
31
|
+
def __init__(self, *, model_name: str, pk: int, phases: dict[str, str]):
|
|
32
|
+
self.model_name = model_name
|
|
33
|
+
self.pk = pk
|
|
34
|
+
self.phases = phases
|
|
35
|
+
loaded = ", ".join(f"{field}='{phase}'" for field, phase in phases.items())
|
|
36
|
+
super().__init__(f"{model_name} ID #{pk} no longer has {loaded} in the database; reload it before saving.")
|
|
37
|
+
|
|
38
|
+
def __reduce__(self) -> tuple:
|
|
39
|
+
return _rebuild_stale_phase_error, (self.model_name, self.pk, self.phases)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _rebuild_stale_phase_error(model_name: str, pk: int, phases: dict[str, str]) -> StalePhaseError:
|
|
43
|
+
return StalePhaseError(model_name=model_name, pk=pk, phases=phases)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RebuiltWithoutInitMixin:
|
|
47
|
+
"""
|
|
48
|
+
Pickle an exception whose constructor takes keyword arguments.
|
|
49
|
+
|
|
50
|
+
The default reduce calls `__init__` positionally with the message, which such a constructor refuses. The copy is
|
|
51
|
+
rebuilt from message and attributes instead, so it works for any constructor signature a subclass defines.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __reduce__(self) -> tuple:
|
|
55
|
+
return _rebuild_without_init, (type(self), self.args, dict(self.__dict__))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _rebuild_without_init(error_class: type[BaseException], args: tuple, attributes: dict) -> BaseException:
|
|
59
|
+
error = error_class.__new__(error_class)
|
|
60
|
+
error.args = args
|
|
61
|
+
error.__dict__.update(attributes)
|
|
62
|
+
return error
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PhaseGuardError(RebuiltWithoutInitMixin, Exception):
|
|
66
|
+
"""
|
|
67
|
+
An action refuses to run because a condition on the current phase beyond the transition table isn't met.
|
|
68
|
+
|
|
69
|
+
Every guard is its own subclass with a stable `code` for machines and a `detail` for humans, which tells the user
|
|
70
|
+
why the action was refused and what to do next. `phase` doesn't translate; the subclass passes a translated text.
|
|
71
|
+
The message given to the constructor is meant for logs and may contain IDs. A subclass is free in its constructor
|
|
72
|
+
signature and stays picklable.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
code: str
|
|
76
|
+
detail: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ProtectedPhaseFieldError(RebuiltWithoutInitMixin, AttributeError):
|
|
80
|
+
def __init__(self, *, field_name: str):
|
|
81
|
+
super().__init__(f"'{field_name}' is a phase field and can only change through a transition.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class EditablePhaseFieldError(TypeError):
|
|
85
|
+
def __init__(self, *args):
|
|
86
|
+
super().__init__("A PhaseField can't be editable; its value only changes through a transition.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InvalidTransitionTableError(RebuiltWithoutInitMixin, TypeError):
|
|
90
|
+
def __init__(self, *, source: str):
|
|
91
|
+
super().__init__(f"Targets of phase '{source}' must be a tuple, e.g. ('declined',) for a single target.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class MissingPhaseFieldError(RebuiltWithoutInitMixin, TypeError):
|
|
95
|
+
def __init__(self, *, model_name: str):
|
|
96
|
+
super().__init__(f"{model_name} has no PhaseField.")
|
phase/fields.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
|
|
3
|
+
from django.db import models, router
|
|
4
|
+
from django.db.models import signals
|
|
5
|
+
from django.db.models.query_utils import DeferredAttribute
|
|
6
|
+
|
|
7
|
+
from phase.exceptions import (
|
|
8
|
+
EditablePhaseFieldError,
|
|
9
|
+
IllegalPhaseTransitionError,
|
|
10
|
+
InvalidTransitionTableError,
|
|
11
|
+
MissingPhaseFieldError,
|
|
12
|
+
ProtectedPhaseFieldError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Set on the instance while `PhaseGuardMixin.refresh_from_db()` runs, so the reload may overwrite the phase.
|
|
16
|
+
REFRESHING_FLAG = "_phase_field_refreshing"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProtectedPhaseDescriptor(DeferredAttribute):
|
|
20
|
+
"""
|
|
21
|
+
Allow a phase value to be set once per instance, refuse every later assignment.
|
|
22
|
+
|
|
23
|
+
Django sets field values with `setattr` when it builds an instance (constructor, loading from the database) and
|
|
24
|
+
when it fetches a deferred field; in all these cases the instance holds no value yet. A reload through
|
|
25
|
+
`PhaseGuardMixin.refresh_from_db()` may overwrite it, because it writes what the database holds. So may Django
|
|
26
|
+
after an INSERT, when it replaces an expression such as a `db_default` with the value the database returned.
|
|
27
|
+
|
|
28
|
+
Assigning the value the instance already holds changes nothing and passes. Django does that in
|
|
29
|
+
`Model.clean_fields()` and when it writes back the columns an INSERT returned.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __set__(self, instance: models.Model, value: str) -> None:
|
|
33
|
+
if (
|
|
34
|
+
self.field.attname in instance.__dict__
|
|
35
|
+
and not instance.__dict__.get(REFRESHING_FLAG)
|
|
36
|
+
and not is_expression(instance.__dict__[self.field.attname])
|
|
37
|
+
and instance.__dict__[self.field.attname] != value
|
|
38
|
+
):
|
|
39
|
+
raise ProtectedPhaseFieldError(field_name=self.field.attname)
|
|
40
|
+
instance.__dict__[self.field.attname] = value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_expression(value: object) -> bool:
|
|
44
|
+
"""A placeholder the database resolves, e.g. `DatabaseDefault` for a field with `db_default` before the INSERT."""
|
|
45
|
+
return hasattr(value, "resolve_expression")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PhaseField(models.CharField):
|
|
49
|
+
"""
|
|
50
|
+
A CharField whose value only changes along `transitions`, via `move()`.
|
|
51
|
+
|
|
52
|
+
A phase missing from `transitions` is final. `move()` writes with a conditional UPDATE on the source phase, so two
|
|
53
|
+
concurrent transitions from the same phase cannot both succeed: the second one updates no row and is rejected like
|
|
54
|
+
any other illegal transition.
|
|
55
|
+
|
|
56
|
+
The field is never editable: admin and model forms leave it out, because saving a form would assign it directly.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
descriptor_class = ProtectedPhaseDescriptor
|
|
60
|
+
|
|
61
|
+
def __init__(self, *args: object, transitions: Mapping[str, tuple[str, ...]], **kwargs: object) -> None:
|
|
62
|
+
# `(DECLINED)` without a trailing comma is a plain string, and `in` on a string matches substrings.
|
|
63
|
+
for source, targets in transitions.items():
|
|
64
|
+
if not isinstance(targets, tuple):
|
|
65
|
+
raise InvalidTransitionTableError(source=source)
|
|
66
|
+
if kwargs.get("editable", False):
|
|
67
|
+
raise EditablePhaseFieldError
|
|
68
|
+
kwargs["editable"] = False
|
|
69
|
+
self.transitions = transitions
|
|
70
|
+
super().__init__(*args, **kwargs)
|
|
71
|
+
|
|
72
|
+
def deconstruct(self) -> tuple:
|
|
73
|
+
# Migrations see a plain CharField: the table and the editability are behaviour, not schema, and must not pin
|
|
74
|
+
# migrations to phase.
|
|
75
|
+
name, _, args, kwargs = super().deconstruct()
|
|
76
|
+
kwargs.pop("editable", None)
|
|
77
|
+
return name, "django.db.models.CharField", args, kwargs
|
|
78
|
+
|
|
79
|
+
def clone(self) -> "PhaseField":
|
|
80
|
+
# Field.clone() rebuilds from deconstruct(), which drops the table on purpose; the clone still needs it.
|
|
81
|
+
_, _, args, kwargs = self.deconstruct()
|
|
82
|
+
return self.__class__(*args, transitions=self.transitions, **kwargs)
|
|
83
|
+
|
|
84
|
+
def targets(self, *, obj: models.Model) -> tuple[str, ...]:
|
|
85
|
+
return self.transitions.get(getattr(obj, self.attname), ())
|
|
86
|
+
|
|
87
|
+
def can_transition(self, *, obj: models.Model, to: str) -> bool:
|
|
88
|
+
return to in self.targets(obj=obj)
|
|
89
|
+
|
|
90
|
+
def move(self, *, obj: models.Model, to: str, **fields: object) -> None:
|
|
91
|
+
"""
|
|
92
|
+
Move `obj` to phase `to` and write `fields` and the model's `auto_now` fields in the same UPDATE.
|
|
93
|
+
|
|
94
|
+
Towards signals this behaves like `obj.save(update_fields=[<phase>, *fields, *<auto_now fields>])`: `pre_save`
|
|
95
|
+
sees the new values on `obj` and may change those fields, `post_save` follows a successful UPDATE.
|
|
96
|
+
|
|
97
|
+
On success `obj` is refreshed, so field values given as expressions are read back as stored values. On failure
|
|
98
|
+
`obj` stays as it was: its phase no longer matches the database, so a later `save()` is refused as stale
|
|
99
|
+
instead of overwriting what the winner of a race wrote.
|
|
100
|
+
"""
|
|
101
|
+
model = type(obj)
|
|
102
|
+
using = router.db_for_write(model, instance=obj)
|
|
103
|
+
rows = model._base_manager.using(using).filter(pk=obj.pk)
|
|
104
|
+
|
|
105
|
+
source = getattr(obj, self.attname)
|
|
106
|
+
if is_expression(source):
|
|
107
|
+
# On Django 5.2 a backend without INSERT … RETURNING leaves the `db_default` placeholder on the instance.
|
|
108
|
+
source = rows.values_list(self.attname, flat=True).first()
|
|
109
|
+
if source is not None:
|
|
110
|
+
obj.__dict__[self.attname] = source
|
|
111
|
+
if to not in self.transitions.get(source, ()):
|
|
112
|
+
raise IllegalPhaseTransitionError(model_name=obj._meta.label, pk=obj.pk, source=source, target=to)
|
|
113
|
+
|
|
114
|
+
written_fields = self._written_fields(obj=obj, fields=fields)
|
|
115
|
+
update_fields = frozenset(field.name for field in written_fields)
|
|
116
|
+
|
|
117
|
+
loaded_state = (dict(obj.__dict__), dict(obj._state.fields_cache))
|
|
118
|
+
try:
|
|
119
|
+
obj.__dict__[self.attname] = to
|
|
120
|
+
for name, value in fields.items():
|
|
121
|
+
setattr(obj, name, value)
|
|
122
|
+
signals.pre_save.send(sender=model, instance=obj, raw=False, using=using, update_fields=update_fields)
|
|
123
|
+
# Like `save()`: values are read after `pre_save`, and `Field.pre_save()` stamps the `auto_now` fields.
|
|
124
|
+
values = {field.attname: field.pre_save(obj, add=False) for field in written_fields}
|
|
125
|
+
updated_rows = rows.filter(**{self.attname: source}).update(**values)
|
|
126
|
+
except BaseException:
|
|
127
|
+
self._restore(obj=obj, loaded_state=loaded_state)
|
|
128
|
+
raise
|
|
129
|
+
if not updated_rows:
|
|
130
|
+
self._restore(obj=obj, loaded_state=loaded_state)
|
|
131
|
+
raise IllegalPhaseTransitionError(
|
|
132
|
+
model_name=obj._meta.label,
|
|
133
|
+
pk=obj.pk,
|
|
134
|
+
source=rows.values_list(self.attname, flat=True).first(),
|
|
135
|
+
target=to,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
obj.refresh_from_db(using=using, fields=[field.attname for field in written_fields])
|
|
139
|
+
signals.post_save.send(
|
|
140
|
+
sender=model, instance=obj, created=False, update_fields=update_fields, raw=False, using=using
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _written_fields(self, *, obj: models.Model, fields: Mapping[str, object]) -> list[models.Field]:
|
|
144
|
+
"""The phase, `fields` and the `auto_now` fields not among them, as `save(update_fields=…)` would write them."""
|
|
145
|
+
written_fields = [self, *(obj._meta.get_field(name) for name in fields)]
|
|
146
|
+
written_fields += [
|
|
147
|
+
field
|
|
148
|
+
for field in obj._meta.concrete_fields
|
|
149
|
+
if getattr(field, "auto_now", False) and field not in written_fields
|
|
150
|
+
]
|
|
151
|
+
return written_fields
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _restore(*, obj: models.Model, loaded_state: tuple[dict, dict]) -> None:
|
|
155
|
+
instance_dict, fields_cache = loaded_state
|
|
156
|
+
obj.__dict__.clear()
|
|
157
|
+
obj.__dict__.update(instance_dict)
|
|
158
|
+
obj._state.fields_cache = fields_cache
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_phase_fields(*, model: type[models.Model]) -> tuple[PhaseField, ...]:
|
|
162
|
+
return tuple(field for field in model._meta.concrete_fields if isinstance(field, PhaseField))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_phase_field(*, model: type[models.Model]) -> PhaseField:
|
|
166
|
+
"""
|
|
167
|
+
Return the one `PhaseField` of `model`.
|
|
168
|
+
|
|
169
|
+
A model has at most one phase field (system check `phase.E002`), so callers never name the field.
|
|
170
|
+
"""
|
|
171
|
+
phase_fields = get_phase_fields(model=model)
|
|
172
|
+
if not phase_fields:
|
|
173
|
+
raise MissingPhaseFieldError(model_name=model._meta.label)
|
|
174
|
+
return phase_fields[0]
|
phase/mixins/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from django.db import models, router, transaction
|
|
2
|
+
|
|
3
|
+
from phase.exceptions import StalePhaseError
|
|
4
|
+
from phase.fields import REFRESHING_FLAG, PhaseField, get_phase_fields, is_expression
|
|
5
|
+
|
|
6
|
+
# The arguments of `Model.save()` in positional order.
|
|
7
|
+
SAVE_ARGUMENTS = ("force_insert", "force_update", "using", "update_fields")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PhaseGuardMixin(models.Model):
|
|
11
|
+
"""
|
|
12
|
+
Companion of `PhaseField`: lets reloads past the field's write protection and guards every save against a stale
|
|
13
|
+
phase.
|
|
14
|
+
|
|
15
|
+
A save of an instance whose phase no longer matches the database would silently undo a concurrent transition, so
|
|
16
|
+
it is refused. Because a `PhaseField` only changes through `move()`, the in-memory value always is the value last
|
|
17
|
+
read from or written to the database, and comparing it with a locked row is enough.
|
|
18
|
+
|
|
19
|
+
This also covers a new instance with an explicit primary key: Django tries an UPDATE first, which would otherwise
|
|
20
|
+
overwrite the phase of an existing row without a transition.
|
|
21
|
+
|
|
22
|
+
System check `phase.E001` makes sure every model with a `PhaseField` inherits this mixin.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
class Meta:
|
|
26
|
+
abstract = True
|
|
27
|
+
|
|
28
|
+
def save(self, *args: object, **kwargs: object) -> None:
|
|
29
|
+
loaded_phases = {
|
|
30
|
+
field.attname: self.__dict__[field.attname]
|
|
31
|
+
for field in self.phase_fields()
|
|
32
|
+
if field.attname in self.__dict__ and not is_expression(self.__dict__[field.attname])
|
|
33
|
+
}
|
|
34
|
+
# Django 5.2 accepts the arguments of `save()` positionally as well.
|
|
35
|
+
options = dict(zip(SAVE_ARGUMENTS, args, strict=False)) | kwargs
|
|
36
|
+
update_fields = options.get("update_fields")
|
|
37
|
+
is_skipped_by_django = update_fields is not None and not update_fields
|
|
38
|
+
# Nothing to compare, an INSERT anyway, or a save Django skips (empty `update_fields`).
|
|
39
|
+
if not loaded_phases or self.pk is None or options.get("force_insert") or is_skipped_by_django:
|
|
40
|
+
super().save(*args, **kwargs)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
using = options.get("using") or router.db_for_write(type(self), instance=self)
|
|
44
|
+
with transaction.atomic(using=using):
|
|
45
|
+
stored_phases = (
|
|
46
|
+
type(self)._base_manager.using(using).select_for_update().filter(pk=self.pk).values(*loaded_phases)
|
|
47
|
+
).first()
|
|
48
|
+
if stored_phases is None and self._state.adding:
|
|
49
|
+
super().save(*args, **kwargs)
|
|
50
|
+
return
|
|
51
|
+
if stored_phases != loaded_phases:
|
|
52
|
+
raise StalePhaseError(model_name=self._meta.label, pk=self.pk, phases=loaded_phases)
|
|
53
|
+
super().save(*args, **kwargs)
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def phase_fields(cls) -> tuple[PhaseField, ...]:
|
|
57
|
+
return get_phase_fields(model=cls)
|
|
58
|
+
|
|
59
|
+
def refresh_from_db(self, *args: object, **kwargs: object) -> None:
|
|
60
|
+
self.__dict__[REFRESHING_FLAG] = True
|
|
61
|
+
try:
|
|
62
|
+
super().refresh_from_db(*args, **kwargs)
|
|
63
|
+
finally:
|
|
64
|
+
del self.__dict__[REFRESHING_FLAG]
|
phase/transitions.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from django.db import models
|
|
2
|
+
|
|
3
|
+
from phase.fields import get_phase_field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def move(*, obj: models.Model, to: str, **fields: object) -> None:
|
|
7
|
+
"""Move `obj` to phase `to` and write `fields` in the same conditional UPDATE, see `PhaseField.move()`."""
|
|
8
|
+
get_phase_field(model=type(obj)).move(obj=obj, to=to, **fields)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def can_transition(*, obj: models.Model, to: str) -> bool:
|
|
12
|
+
return get_phase_field(model=type(obj)).can_transition(obj=obj, to=to)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def targets(*, obj: models.Model) -> tuple[str, ...]:
|
|
16
|
+
return get_phase_field(model=type(obj)).targets(obj=obj)
|