xtr-workflow 5.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.
- xtr_workflow/.agents/skills/xtr-workflow/SKILL.md +343 -0
- xtr_workflow/.agents/skills/xtr-workflow/references/declaring-workflows.md +170 -0
- xtr_workflow/.agents/skills/xtr-workflow/references/events.md +123 -0
- xtr_workflow/__init__.py +54 -0
- xtr_workflow/_events_to_dispatch.py +57 -0
- xtr_workflow/arc.py +40 -0
- xtr_workflow/bundle/__init__.py +29 -0
- xtr_workflow/bundle/_built_transitions.py +61 -0
- xtr_workflow/bundle/_declaration_reader.py +250 -0
- xtr_workflow/bundle/_declared_workflows.py +140 -0
- xtr_workflow/bundle/_injecting_factory.py +47 -0
- xtr_workflow/bundle/_marked_constants.py +166 -0
- xtr_workflow/bundle/_place_name.py +34 -0
- xtr_workflow/bundle/_unit_of_work_guard_listener.py +69 -0
- xtr_workflow/bundle/arc_config.py +58 -0
- xtr_workflow/bundle/audit_trail_config.py +21 -0
- xtr_workflow/bundle/marking_store_config.py +55 -0
- xtr_workflow/bundle/place_config.py +30 -0
- xtr_workflow/bundle/transition_config.py +117 -0
- xtr_workflow/bundle/workflow_bundle.py +164 -0
- xtr_workflow/bundle/workflow_config.py +58 -0
- xtr_workflow/bundle/workflow_debug_pass.py +53 -0
- xtr_workflow/bundle/workflow_definition_config.py +263 -0
- xtr_workflow/bundle/workflow_service_registrar.py +302 -0
- xtr_workflow/command/__init__.py +10 -0
- xtr_workflow/command/workflow_dump_command.py +139 -0
- xtr_workflow/debug/__init__.py +15 -0
- xtr_workflow/debug/listener_extractor.py +274 -0
- xtr_workflow/debug/traceable_workflow.py +227 -0
- xtr_workflow/decorator/__init__.py +34 -0
- xtr_workflow/decorator/_build_event_name.py +29 -0
- xtr_workflow/decorator/_build_metadata.py +38 -0
- xtr_workflow/decorator/_resolved_listeners.py +157 -0
- xtr_workflow/decorator/_workflow_name.py +41 -0
- xtr_workflow/decorator/as_announce_listener.py +52 -0
- xtr_workflow/decorator/as_completed_listener.py +52 -0
- xtr_workflow/decorator/as_enter_listener.py +52 -0
- xtr_workflow/decorator/as_entered_listener.py +53 -0
- xtr_workflow/decorator/as_guard_listener.py +52 -0
- xtr_workflow/decorator/as_leave_listener.py +52 -0
- xtr_workflow/decorator/as_transition_listener.py +53 -0
- xtr_workflow/decorator/as_workflow.py +192 -0
- xtr_workflow/decorator/place.py +44 -0
- xtr_workflow/decorator/transition.py +65 -0
- xtr_workflow/decorator/workflow_listener_declaration.py +84 -0
- xtr_workflow/definition.py +121 -0
- xtr_workflow/definition_builder.py +104 -0
- xtr_workflow/dumper/__init__.py +17 -0
- xtr_workflow/dumper/_escaping.py +171 -0
- xtr_workflow/dumper/_graphviz_nodes.py +49 -0
- xtr_workflow/dumper/_options.py +44 -0
- xtr_workflow/dumper/dumper_interface.py +39 -0
- xtr_workflow/dumper/graphviz_dumper.py +285 -0
- xtr_workflow/dumper/mermaid_dumper.py +223 -0
- xtr_workflow/dumper/plant_uml_dumper.py +223 -0
- xtr_workflow/dumper/state_machine_graphviz_dumper.py +93 -0
- xtr_workflow/event/__init__.py +23 -0
- xtr_workflow/event/_event_name.py +32 -0
- xtr_workflow/event/_has_context.py +49 -0
- xtr_workflow/event/announce_event.py +28 -0
- xtr_workflow/event/completed_event.py +24 -0
- xtr_workflow/event/enter_event.py +24 -0
- xtr_workflow/event/entered_event.py +28 -0
- xtr_workflow/event/event.py +89 -0
- xtr_workflow/event/guard_event.py +82 -0
- xtr_workflow/event/leave_event.py +24 -0
- xtr_workflow/event/transition_event.py +35 -0
- xtr_workflow/event_listener/__init__.py +19 -0
- xtr_workflow/event_listener/audit_trail_listener.py +107 -0
- xtr_workflow/event_listener/guard_context.py +57 -0
- xtr_workflow/event_listener/guard_expression.py +53 -0
- xtr_workflow/event_listener/guard_listener.py +173 -0
- xtr_workflow/exception/__init__.py +23 -0
- xtr_workflow/exception/invalid_argument_error.py +15 -0
- xtr_workflow/exception/invalid_definition_error.py +17 -0
- xtr_workflow/exception/logic_error.py +16 -0
- xtr_workflow/exception/not_enabled_transition_error.py +55 -0
- xtr_workflow/exception/transition_error.py +67 -0
- xtr_workflow/exception/undefined_transition_error.py +41 -0
- xtr_workflow/exception/workflow_error.py +9 -0
- xtr_workflow/exception/workflow_runtime_error.py +11 -0
- xtr_workflow/marking.py +120 -0
- xtr_workflow/marking_store/__init__.py +8 -0
- xtr_workflow/marking_store/_marking_store_method.py +22 -0
- xtr_workflow/marking_store/marking_store_interface.py +43 -0
- xtr_workflow/marking_store/method_marking_store.py +293 -0
- xtr_workflow/metadata/__init__.py +9 -0
- xtr_workflow/metadata/get_metadata_trait.py +68 -0
- xtr_workflow/metadata/in_memory_metadata_store.py +71 -0
- xtr_workflow/metadata/metadata_store_interface.py +58 -0
- xtr_workflow/py.typed +0 -0
- xtr_workflow/recipe/__init__.py +16 -0
- xtr_workflow/recipe/files/config/workflow.py.tmpl +40 -0
- xtr_workflow/recipe/manifest.toml +31 -0
- xtr_workflow/registry.py +97 -0
- xtr_workflow/state_machine.py +63 -0
- xtr_workflow/support_strategy/__init__.py +8 -0
- xtr_workflow/support_strategy/instance_of_support_strategy.py +41 -0
- xtr_workflow/support_strategy/workflow_support_strategy_interface.py +28 -0
- xtr_workflow/transition.py +118 -0
- xtr_workflow/transition_blocker.py +151 -0
- xtr_workflow/transition_blocker_list.py +53 -0
- xtr_workflow/validator/__init__.py +9 -0
- xtr_workflow/validator/definition_validator_interface.py +40 -0
- xtr_workflow/validator/state_machine_validator.py +106 -0
- xtr_workflow/validator/workflow_validator.py +118 -0
- xtr_workflow/workflow.py +429 -0
- xtr_workflow/workflow_events.py +68 -0
- xtr_workflow/workflow_interface.py +105 -0
- xtr_workflow/workflow_trait.py +105 -0
- xtr_workflow/workflow_type.py +21 -0
- xtr_workflow-5.1.0.dist-info/METADATA +722 -0
- xtr_workflow-5.1.0.dist-info/RECORD +116 -0
- xtr_workflow-5.1.0.dist-info/WHEEL +4 -0
- xtr_workflow-5.1.0.dist-info/entry_points.txt +6 -0
- xtr_workflow-5.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-workflow
|
|
3
|
+
description: How to move an object through a workflow or a state machine with xtr-workflow — definitions built in code, in configuration or declared on a class, guards that block a transition, events at every step, and definitions dumped as Graphviz, PlantUML or Mermaid. Use when an object has a status, a state, a stage or a lifecycle (draft/review/published, new/paid/shipped, open/assigned/closed), when a move must be checked before it happens or refused with a reason, when something must react to a status change, when an object must be in several places at once or a step must wait for two others, when adding WorkflowBundle to an application on xtr-dependency-injection, or when drawing a state diagram with workflow:dump.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-workflow
|
|
7
|
+
|
|
8
|
+
A **definition** is the places an object can be in and the named transitions that move it between
|
|
9
|
+
them. A **state machine** keeps the object in exactly one place; a **workflow** is a Petri net — it
|
|
10
|
+
can be in several, and a transition consumes and produces weighted tokens. Events go out at every
|
|
11
|
+
step through xtr-event-dispatcher; see the `xtr-event-dispatcher` skill for listeners in general.
|
|
12
|
+
|
|
13
|
+
## Quick reference
|
|
14
|
+
|
|
15
|
+
- Build a definition: `DefinitionBuilder().add_places([...]).add_transitions([...]).build()`, or
|
|
16
|
+
`Definition(places, transitions, initial_places, metadata_store)`.
|
|
17
|
+
- Run it: `StateMachine(definition, marking_store, dispatcher, name)` or `Workflow(...)`, both from
|
|
18
|
+
`xtr_workflow`.
|
|
19
|
+
- Ask, then move: `await workflow.can(subject, "publish")`,
|
|
20
|
+
`await workflow.build_transition_blocker_list(subject, "publish")`,
|
|
21
|
+
`await workflow.apply(subject, "publish", context)`,
|
|
22
|
+
`await workflow.get_enabled_transitions(subject)`.
|
|
23
|
+
- Where the marking lives: `MethodMarkingStore(single_state, property)` from
|
|
24
|
+
`xtr_workflow.marking_store` — the subject's `get_<property>()` / `set_<property>()`, else its
|
|
25
|
+
attribute. Both its methods are `async`.
|
|
26
|
+
- Declare on a class: `@as_workflow(...)` with `Transition` / `Place` markers and
|
|
27
|
+
`as_*_listener` methods, from `xtr_workflow.decorator`. See
|
|
28
|
+
[references/declaring-workflows.md](references/declaring-workflows.md).
|
|
29
|
+
- Events, names and listener decorators: [references/events.md](references/events.md).
|
|
30
|
+
- Guard a transition: a callable taking a `GuardContext` (`xtr_workflow.event_listener`), set as
|
|
31
|
+
`guard=` on a `TransitionConfig` or a `Transition` marker. Needs `SecurityBundle` listed in
|
|
32
|
+
`config/bundles.py`: it never comes along with the workflow bundle.
|
|
33
|
+
- Draw it: `GraphvizDumper`, `StateMachineGraphvizDumper`, `PlantUmlDumper`, `MermaidDumper` from
|
|
34
|
+
`xtr_workflow.dumper`, or the `workflow:dump` command.
|
|
35
|
+
- In an application, with xtr-dependency-injection installed beside the package (it is not a
|
|
36
|
+
dependency): activate `WorkflowBundle` and configure `WorkflowConfig`. Without the container,
|
|
37
|
+
everything above works; only `xtr_workflow.bundle` imports it.
|
|
38
|
+
- Everything that may ask a listener is a coroutine; `get_name`, `get_definition`,
|
|
39
|
+
`get_marking_store`, `get_metadata_store`, definitions, markings, dumpers, validators and the
|
|
40
|
+
registry are not.
|
|
41
|
+
|
|
42
|
+
## Move an object through a state machine
|
|
43
|
+
|
|
44
|
+
The usual case: one place at a time, kept on the subject.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from xtr_workflow import DefinitionBuilder, StateMachine, Transition
|
|
48
|
+
from xtr_workflow.marking_store import MethodMarkingStore
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Article:
|
|
52
|
+
status: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
definition = (
|
|
56
|
+
DefinitionBuilder()
|
|
57
|
+
.add_places(["draft", "review", "published"])
|
|
58
|
+
.add_transitions(
|
|
59
|
+
[
|
|
60
|
+
Transition("request_review", "draft", "review"),
|
|
61
|
+
Transition("publish", "review", "published"),
|
|
62
|
+
Transition("reject", "review", "draft"),
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
.build()
|
|
66
|
+
)
|
|
67
|
+
workflow = StateMachine(definition, MethodMarkingStore(True, "status"), name="blog_publishing")
|
|
68
|
+
|
|
69
|
+
article = Article()
|
|
70
|
+
assert await workflow.can(article, "request_review")
|
|
71
|
+
await workflow.apply(article, "request_review")
|
|
72
|
+
assert article.status == "review"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The first place added is where a subject starts; `set_initial_places(...)` changes that. A subject
|
|
76
|
+
read for the first time is put in the initial places and an `entered` event announces it, with the
|
|
77
|
+
context `{"initial": True}`.
|
|
78
|
+
|
|
79
|
+
## Wait for two things at once
|
|
80
|
+
|
|
81
|
+
`Workflow` is the Petri net. `Arc(place, weight)` says how many tokens a transition takes from a
|
|
82
|
+
place or puts in it; a bare place name weighs 1.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from xtr_workflow import Arc, Definition, Transition, Workflow
|
|
86
|
+
|
|
87
|
+
definition = Definition(
|
|
88
|
+
["new", "paid", "picked", "shipped"],
|
|
89
|
+
[
|
|
90
|
+
Transition("split", "new", [Arc("paid"), Arc("picked", 2)]),
|
|
91
|
+
Transition("ship", [Arc("paid"), Arc("picked")], "shipped"), # needs both
|
|
92
|
+
],
|
|
93
|
+
"new",
|
|
94
|
+
)
|
|
95
|
+
workflow = Workflow(definition, name="order_fulfilment")
|
|
96
|
+
|
|
97
|
+
marking = await workflow.apply(order, "split")
|
|
98
|
+
assert marking.get_places() == {"paid": 1, "picked": 2}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Applying a name applies **every** transition of that name the marking enables, so a subject spread
|
|
102
|
+
over places moves out of all of them at once. A transition is known by its identity: two built
|
|
103
|
+
alike are two transitions, each with its own metadata.
|
|
104
|
+
|
|
105
|
+
## Refuse a move, and say why
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from xtr_workflow.exception import NotEnabledTransitionError, UndefinedTransitionError
|
|
109
|
+
|
|
110
|
+
blockers = await workflow.build_transition_blocker_list(order, "ship")
|
|
111
|
+
if not blockers.is_empty():
|
|
112
|
+
reasons = [blocker.get_message() for blocker in blockers]
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
await workflow.apply(order, "ship")
|
|
116
|
+
except NotEnabledTransitionError as error:
|
|
117
|
+
reasons = [b.get_message() for b in error.get_transition_blocker_list()]
|
|
118
|
+
except UndefinedTransitionError:
|
|
119
|
+
... # no transition of that name at all
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Codes on `TransitionBlocker`: `BLOCKED_BY_MARKING` (the places do not hold the tokens),
|
|
123
|
+
`BLOCKED_BY_EXPRESSION_GUARD_LISTENER` (a guard refused), `UNKNOWN` (a listener called
|
|
124
|
+
`set_blocked(True)` without a code of its own). A guard's refusal reads
|
|
125
|
+
`The expression blocks the transition.`, and its `get_parameters()["expression"]` names the guard —
|
|
126
|
+
module and qualified name, `"app.blog.only_an_editor"`. The names keep *expression* for parity with
|
|
127
|
+
that code, a fixed UUID: there is no expression language here.
|
|
128
|
+
|
|
129
|
+
## Guard a transition
|
|
130
|
+
|
|
131
|
+
A guard is a callable handed a `GuardContext`, answering whether the transition may run — at once
|
|
132
|
+
or through an awaitable. Every guard of the transition runs, and each refusal adds a blocker.
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
from xtr_workflow.event_listener import GuardContext
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def only_an_editor(context: GuardContext) -> bool:
|
|
139
|
+
return await context.authorization_checker.is_granted("ROLE_EDITOR", context.subject)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def not_locked(context: GuardContext) -> bool:
|
|
143
|
+
return not context.subject.locked
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The context carries `subject`, `token` (`None` when no one is authenticated), `user`, `role_names`
|
|
147
|
+
(the token's roles plus everything they reach), `authorization_checker` and `trust_resolver`.
|
|
148
|
+
Importing it needs no security package. Running one does: set it with
|
|
149
|
+
`TransitionConfig(..., guard=only_an_editor)` or `Transition(..., guard=only_an_editor)`, and list
|
|
150
|
+
`SecurityBundle` (`from xtr_security.bundle import SecurityBundle`) in `BUNDLES` beside
|
|
151
|
+
`WorkflowBundle`, or the build fails saying so. Listing the workflow bundle never activates it: it
|
|
152
|
+
brings the http-kernel bundle and its peers with it.
|
|
153
|
+
|
|
154
|
+
A guard reads the token of the unit of work its guard event is dispatched in. From a script or a
|
|
155
|
+
console command nobody is authenticated, unless the code opens that unit and says who acts:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from xtr_dependency_injection import unit_of_work
|
|
159
|
+
from xtr_security_core import TokenStorageInterface, UsernamePasswordToken
|
|
160
|
+
from xtr_workflow import WorkflowInterface
|
|
161
|
+
|
|
162
|
+
async with unit_of_work(container) as unit: # container: the booted kernel's
|
|
163
|
+
storage = await unit.get(TokenStorageInterface)
|
|
164
|
+
storage.set_token(UsernamePasswordToken(editor, "console", roles=editor.get_roles()))
|
|
165
|
+
blog = await unit.get(WorkflowInterface, "blog_publishing")
|
|
166
|
+
await blog.apply(article, "publish") # the guards see the editor
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Draw the definition
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
from xtr_workflow.dumper import MermaidDumper, StateMachineGraphvizDumper
|
|
173
|
+
|
|
174
|
+
print(MermaidDumper(MermaidDumper.TRANSITION_TYPE_STATEMACHINE).dump(definition, marking))
|
|
175
|
+
print(StateMachineGraphvizDumper().dump(definition, options={"label": "Blog"}))
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`GraphvizDumper` draws a workflow, `StateMachineGraphvizDumper` a state machine; `PlantUmlDumper`
|
|
179
|
+
and `MermaidDumper` take the transition style (`STATEMACHINE_TRANSITION` /
|
|
180
|
+
`TRANSITION_TYPE_STATEMACHINE` for labelled arrows, `WORKFLOW_TRANSITION` /
|
|
181
|
+
`TRANSITION_TYPE_WORKFLOW` for transition nodes). Metadata styles it: `label`, `bg_color`,
|
|
182
|
+
`description`, `color`, `arrow_color`.
|
|
183
|
+
|
|
184
|
+
With the console bundle active:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
<script> workflow:dump blog_publishing --dump-format=mermaid
|
|
188
|
+
<script> workflow:dump order_fulfilment paid --with-metadata --with-listeners
|
|
189
|
+
<script> workflow:dump order_fulfilment -l "Order fulfilment" | dot -Tpng > wf.png
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The places after the name are the marking to highlight; `--dump-format` is `dot` (default), `puml`
|
|
193
|
+
or `mermaid`, and any other value draws `dot`. Only `dot` reads the options: `-l` / `--label` and
|
|
194
|
+
`--with-metadata` for both kinds, `--with-listeners` for a workflow only — a state machine has no
|
|
195
|
+
transition node to list them on. PlantUML titles with the workflow's name; Mermaid reads no option.
|
|
196
|
+
The output is raw diagram source, meant to be piped.
|
|
197
|
+
|
|
198
|
+
## Testing
|
|
199
|
+
|
|
200
|
+
Nothing needs a kernel, a clock or a dispatcher: build the definition, build the workflow, assert.
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
from xtr_workflow import DefinitionBuilder, StateMachine, Transition
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def test_a_draft_cannot_be_published() -> None:
|
|
207
|
+
definition = (
|
|
208
|
+
DefinitionBuilder()
|
|
209
|
+
.add_places(["draft", "review", "published"])
|
|
210
|
+
.add_transitions([Transition("publish", "review", "published")])
|
|
211
|
+
.build()
|
|
212
|
+
)
|
|
213
|
+
workflow = StateMachine(definition, name="blog")
|
|
214
|
+
|
|
215
|
+
assert not await workflow.can(Article(), "publish")
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- To test what went out, hand the workflow a dispatcher that records what it is given.
|
|
219
|
+
- To test a guard, call it with a `GuardContext` built by hand: the fields are plain data and two
|
|
220
|
+
protocols, so a fake checker is a class with `is_granted`.
|
|
221
|
+
- To test a dump, compare the string; the dumpers are deterministic and read no clock.
|
|
222
|
+
- A bundle test calls `await assert_zero_config(WorkflowBundle)`: with no workflow it builds, boots,
|
|
223
|
+
and its `Registry` raises `Unable to find a workflow for class "object".`
|
|
224
|
+
|
|
225
|
+
## Use in an application
|
|
226
|
+
|
|
227
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
|
|
228
|
+
`WorkflowBundle` and writes `<app>/config/packages/workflow.py` with the bundle's own defaults — no
|
|
229
|
+
workflow — and a commented starter one. That is the steps below a recipe can do; the workflows
|
|
230
|
+
themselves, and listing the security bundle for guards, it prints for you to make.
|
|
231
|
+
|
|
232
|
+
1. **Install** — `uv add xtr-workflow xtr-dependency-injection`: the bundle needs the container
|
|
233
|
+
installed beside it, never as an extra. Install xtr-security beside it for guards, xtr-console
|
|
234
|
+
for `workflow:dump`, xtr-logging for the audit trail's `workflow` channel.
|
|
235
|
+
2. **Activate** — `WorkflowBundle: {"all": True}` in `BUNDLES` in `<app>/config/bundles.py`
|
|
236
|
+
(`from xtr_workflow.bundle import WorkflowBundle`). For guards, `SecurityBundle: {"all": True}`
|
|
237
|
+
too (`from xtr_security.bundle import SecurityBundle`), with the security bundle's own steps.
|
|
238
|
+
3. **Brings along** — the event dispatcher bundle always; the logging and console bundles when
|
|
239
|
+
their packages are installed. Never the security bundle — guards need it listed. A workflow
|
|
240
|
+
needing a bundle that is not active fails the build naming it.
|
|
241
|
+
4. **Configure** — `<app>/config/packages/workflow.py`. A workflow declared with `@as_workflow`
|
|
242
|
+
needs no entry; `WorkflowConfig()` with no workflow is valid.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
# <app>/config/packages/workflow.py
|
|
246
|
+
from xtr_dependency_injection import configure
|
|
247
|
+
from xtr_workflow import WorkflowType
|
|
248
|
+
from xtr_workflow.bundle import (
|
|
249
|
+
AuditTrailConfig,
|
|
250
|
+
MarkingStoreConfig,
|
|
251
|
+
TransitionConfig,
|
|
252
|
+
WorkflowConfig,
|
|
253
|
+
WorkflowDefinitionConfig,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
from app.blog import Article, only_an_editor
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@configure
|
|
260
|
+
def workflow() -> WorkflowConfig:
|
|
261
|
+
return WorkflowConfig(
|
|
262
|
+
workflows={
|
|
263
|
+
"blog_publishing": WorkflowDefinitionConfig(
|
|
264
|
+
type=WorkflowType.STATE_MACHINE,
|
|
265
|
+
supports=Article,
|
|
266
|
+
marking_store=MarkingStoreConfig(property="status"),
|
|
267
|
+
audit_trail=AuditTrailConfig(enabled=True),
|
|
268
|
+
places=("draft", "review", "published"),
|
|
269
|
+
transitions={
|
|
270
|
+
"request_review": TransitionConfig(froms="draft", tos="review"),
|
|
271
|
+
"publish": TransitionConfig(
|
|
272
|
+
froms="review", tos="published", guard=only_an_editor
|
|
273
|
+
),
|
|
274
|
+
"reject": TransitionConfig(froms="review", tos="draft"),
|
|
275
|
+
},
|
|
276
|
+
),
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Lists take the shortest shape that says them: one supported class or initial place on its own,
|
|
282
|
+
places as names, string-enum members or `PlaceConfig`s — or keyed by name — transitions keyed by
|
|
283
|
+
name, arcs as a name, an enum member, an `Arc` or an `ArcConfig`.
|
|
284
|
+
5. **Inject** — each workflow is registered once, as `WorkflowInterface` under its name:
|
|
285
|
+
`Annotated[WorkflowInterface, Target("blog_publishing")]`, or
|
|
286
|
+
`ServiceLocator[WorkflowInterface]` by name, or `Registry` by subject (`registry.get(article)`,
|
|
287
|
+
for a workflow listing `supports` or a `support_strategy`). Its `Definition` (`xtr_workflow`)
|
|
288
|
+
and `InMemoryMetadataStore` (`xtr_workflow.metadata`) are under the name too. In debug mode the
|
|
289
|
+
service is a `TraceableWorkflow` (`xtr_workflow.debug`): its `get_calls()` holds a record per
|
|
290
|
+
call — `method`, `duration` (ms), `args`, `previous_marking`, and `return` or `exception` — and
|
|
291
|
+
code needing the concrete class calls `get_inner()`.
|
|
292
|
+
6. **Environment** — nothing. **Ignore** — nothing.
|
|
293
|
+
7. **Check** — `debug:bundles` lists `workflow` as active; `workflow:dump <name>` prints the
|
|
294
|
+
definition the container built.
|
|
295
|
+
8. **Remove** — drop the `BUNDLES` entry, delete every `@as_workflow` class and workflow listener;
|
|
296
|
+
`uv run xtr-recipes recipes:remove xtr-workflow` deletes
|
|
297
|
+
`<app>/config/packages/workflow.py`, unless you edited it, in which case it is moved aside to
|
|
298
|
+
`workflow.py.removed`. Then `uv remove xtr-workflow`.
|
|
299
|
+
|
|
300
|
+
## Errors
|
|
301
|
+
|
|
302
|
+
All derive from `WorkflowError` (`xtr_workflow.exception`):
|
|
303
|
+
|
|
304
|
+
- `InvalidArgumentError` (also a `ValueError`) — a value it cannot use: a weight below 1, an empty
|
|
305
|
+
place name, unmarking a place that holds nothing, an unknown entry in `events_to_dispatch`, no
|
|
306
|
+
workflow for a subject or several, a direction or transition type a dumper does not know.
|
|
307
|
+
- `LogicError` — a state that cannot be: an empty marking with no initial place, a place the
|
|
308
|
+
definition lacks, a subject with neither accessor nor attribute for its marking, a class
|
|
309
|
+
decorated `@as_workflow` twice, a workflow name taken twice, a bundle a workflow needs that is
|
|
310
|
+
not active, a place outside the `places=` enum of an `@as_workflow` class, a `WorkflowTrait`
|
|
311
|
+
used before `set_workflow(...)`.
|
|
312
|
+
- `InvalidDefinitionError` — a validator refused the definition at build.
|
|
313
|
+
- `TransitionError` — carrying the subject, the transition's name, the workflow and the context:
|
|
314
|
+
`UndefinedTransitionError` (no transition of that name) and `NotEnabledTransitionError` (blocked,
|
|
315
|
+
with the blocker list).
|
|
316
|
+
- `WorkflowRuntimeError` — a failure while running.
|
|
317
|
+
|
|
318
|
+
`Transition` raises a plain `TypeError` for an arc that is neither a place name nor an `Arc`.
|
|
319
|
+
|
|
320
|
+
## Do not
|
|
321
|
+
|
|
322
|
+
- Do not call `apply`, `can`, `get_marking` or the blocker list without `await`: every one is a
|
|
323
|
+
coroutine, and so are both methods of a marking store.
|
|
324
|
+
- Do not import `Transition` from `xtr_workflow` where you mean the marker: the marker is
|
|
325
|
+
`xtr_workflow.decorator.Transition`, and the two are different classes.
|
|
326
|
+
- Do not write `from=` / `to=`: the keywords are `froms=` and `tos=`.
|
|
327
|
+
- Do not expect a guard to see a token outside a request or a message: the unit of work opened for
|
|
328
|
+
a guard event in a console command has nobody authenticated, so a role check refuses. Open the
|
|
329
|
+
unit yourself and set a token in it (see *Guard a transition*), or guard on the subject's own
|
|
330
|
+
data where a transition must run headless.
|
|
331
|
+
- Do not expect listing `WorkflowBundle` to activate the security bundle, even with xtr-security
|
|
332
|
+
installed: guards need `SecurityBundle` listed beside it.
|
|
333
|
+
- Do not leave a concrete class carrying `@self` workflow listeners inside a scanned package — the
|
|
334
|
+
build fails with nothing to resolve the name to. Make a shared listener base abstract, or keep it
|
|
335
|
+
out of the scan.
|
|
336
|
+
- Do not rely on `TransitionConfig(weight=...)`: it is validated and read by nothing. Weigh the arc
|
|
337
|
+
instead, with `ArcConfig(place, weight)`.
|
|
338
|
+
- Do not write a guard as a string expression; there is no expression language here, and no
|
|
339
|
+
`is_valid(...)`.
|
|
340
|
+
- Do not subclass a `@as_workflow` class expecting a second workflow: the declaration is the
|
|
341
|
+
decorated class's alone.
|
|
342
|
+
- Do not inject `Workflow` or `StateMachine` from the container; the key is `WorkflowInterface`
|
|
343
|
+
under the workflow's name.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Declaring a workflow on a class
|
|
2
|
+
|
|
3
|
+
`@as_workflow` records on a class what the configuration would otherwise say. The workflow bundle
|
|
4
|
+
reads the declaration from every class its scan finds and registers the workflow; without the
|
|
5
|
+
container the declaration is inert data, and the class is just a class.
|
|
6
|
+
|
|
7
|
+
Everything here comes from `xtr_workflow.decorator`.
|
|
8
|
+
|
|
9
|
+
## A full example
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import Annotated, ClassVar
|
|
14
|
+
|
|
15
|
+
from xtr_workflow import WorkflowTrait, WorkflowType
|
|
16
|
+
from xtr_workflow.decorator import Place, Transition, as_guard_listener, as_workflow
|
|
17
|
+
from xtr_workflow.event import GuardEvent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Step(StrEnum):
|
|
21
|
+
NEW = "new"
|
|
22
|
+
PROCESSING = "processing"
|
|
23
|
+
DONE = "done"
|
|
24
|
+
FAILED = "failed"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Task:
|
|
28
|
+
step: Step | None = None
|
|
29
|
+
blocked: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@as_workflow(
|
|
33
|
+
supports=Task,
|
|
34
|
+
marking_property="step",
|
|
35
|
+
places=Step,
|
|
36
|
+
place_markers={Step.NEW: Place(description="Waiting to be picked up", initial=True)},
|
|
37
|
+
metadata={"title": "Task"},
|
|
38
|
+
)
|
|
39
|
+
class TaskStateMachine(WorkflowTrait):
|
|
40
|
+
START: ClassVar[Annotated[str, Transition(froms=Step.NEW, tos=Step.PROCESSING)]] = "start"
|
|
41
|
+
FINISH: ClassVar[
|
|
42
|
+
Annotated[
|
|
43
|
+
str,
|
|
44
|
+
Transition(froms=Step.PROCESSING, tos=Step.DONE),
|
|
45
|
+
Transition(froms=Step.FAILED, tos=Step.DONE),
|
|
46
|
+
]
|
|
47
|
+
] = "finish"
|
|
48
|
+
FAIL: ClassVar[Annotated[str, Transition(froms=Step.PROCESSING, tos=Step.FAILED)]] = "fail"
|
|
49
|
+
|
|
50
|
+
async def start(self, task: Task) -> None:
|
|
51
|
+
_ = await self.apply(task, self.START, {"started_by": "start()"})
|
|
52
|
+
|
|
53
|
+
@as_guard_listener(transition=START)
|
|
54
|
+
def refuse_blocked_tasks(self, event: GuardEvent) -> None:
|
|
55
|
+
subject = event.get_subject()
|
|
56
|
+
if isinstance(subject, Task) and subject.blocked:
|
|
57
|
+
event.set_blocked(True, "The task is blocked.")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## `@as_workflow` arguments
|
|
61
|
+
|
|
62
|
+
| Argument | Does |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `name=` | The workflow's name; derived from the class otherwise |
|
|
65
|
+
| `type=` | `WorkflowType.STATE_MACHINE` (default) or `WorkflowType.WORKFLOW` |
|
|
66
|
+
| `supports=` | The class, or classes, of the subjects it moves — puts it in the `Registry` |
|
|
67
|
+
| `support_strategy=` | The qualifier of a `WorkflowSupportStrategyInterface` service instead |
|
|
68
|
+
| `initial_marking=` | The place, or places, a subject starts in |
|
|
69
|
+
| `marking_property=` | The subject's attribute (or `get_`/`set_` pair) keeping the marking |
|
|
70
|
+
| `marking_store=` | The qualifier of a `MarkingStoreInterface` service instead |
|
|
71
|
+
| `description=` | Kept under `metadata["description"]` |
|
|
72
|
+
| `metadata=` | The workflow's metadata |
|
|
73
|
+
| `audit_trail=` | `True` logs every move on the `workflow` channel |
|
|
74
|
+
| `events_to_dispatch=` | Which events ever go out; see `references/events.md` |
|
|
75
|
+
| `definition_validators=` | Validators run on the definition besides the type's own |
|
|
76
|
+
| `places=` | A string enum whose every member is a place, and outside which no place is named |
|
|
77
|
+
| `place_markers=` | A `Place` per enum member that needs one, keyed by the member |
|
|
78
|
+
|
|
79
|
+
- The derived name is the class's name snake-cased, without a `Workflow` or `StateMachine` suffix:
|
|
80
|
+
`TaskStateMachine` → `task`, `PullRequestStateMachine` → `pull_request`.
|
|
81
|
+
- `supports` with `support_strategy`, `marking_property` with `marking_store`, or a `description`
|
|
82
|
+
plus one in `metadata`, each raise `LogicError` at decoration.
|
|
83
|
+
- The declaration belongs to the decorated class alone: the reader looks in the class's own
|
|
84
|
+
`__dict__`, so a subclass declares nothing unless it is decorated too. Decorating one class twice
|
|
85
|
+
raises.
|
|
86
|
+
- A name already used by the configuration, or by another class, is a build error naming both.
|
|
87
|
+
- With neither `supports` nor `support_strategy` the workflow is not in the `Registry` — it is
|
|
88
|
+
reached by injection only.
|
|
89
|
+
|
|
90
|
+
## The `Transition` marker
|
|
91
|
+
|
|
92
|
+
`Transition` here is **not** `xtr_workflow.Transition`: it is the marker, and it goes inside
|
|
93
|
+
`Annotated` on a class constant valued with the transition's name.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
SUBMIT: ClassVar[Annotated[str, Transition(froms="draft", tos="review")]] = "submit"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Argument | Does |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `froms=` | The places it leaves: a name, a string-enum member, an `Arc`, or a sequence of those |
|
|
102
|
+
| `tos=` | The places it enters, the same way |
|
|
103
|
+
| `guard=` | A callable taking a `GuardContext`; the transition runs only when it answers true |
|
|
104
|
+
| `description=` | Kept under `metadata["description"]` |
|
|
105
|
+
| `metadata=` | The transition's metadata |
|
|
106
|
+
|
|
107
|
+
`from` and `to` are not the keywords — `from` is a reserved word, and `froms` / `tos` match
|
|
108
|
+
`Transition.get_froms()` / `get_tos()` on the built transition. Repeat the marker inside one
|
|
109
|
+
`Annotated` to declare several transitions of that name: a state machine needs one per pair of
|
|
110
|
+
places, and a workflow applies every enabled transition of the name at once.
|
|
111
|
+
|
|
112
|
+
Weigh an arc with `Arc(place, weight)` from `xtr_workflow`; a bare name weighs 1, which is all a
|
|
113
|
+
state machine ever uses.
|
|
114
|
+
|
|
115
|
+
## The `Place` marker
|
|
116
|
+
|
|
117
|
+
A place a transition leaves or enters is a place already. `Place` adds one nothing uses, fixes the
|
|
118
|
+
order of the places, or says something about one.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
ARCHIVED: ClassVar[Annotated[str, Place(description="Kept, never shown")]] = "archived"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
That holds without `places=`. With it, the enum is the whole list: a `Place` constant — or a
|
|
125
|
+
transition — naming a place outside it fails the build, the error saying the place
|
|
126
|
+
`is not a case of` the enum. Value such a constant with one of the enum's members.
|
|
127
|
+
|
|
128
|
+
| Argument | Does |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `description=` | Kept under `metadata["description"]` |
|
|
131
|
+
| `metadata=` | The place's metadata — `label`, `bg_color` and the rest a dumper reads |
|
|
132
|
+
| `initial=` | The place is in the initial marking, unless `initial_marking=` says otherwise |
|
|
133
|
+
|
|
134
|
+
**Enum members take no marker of their own.** An annotation on an enum member survives at runtime
|
|
135
|
+
but type checkers reject it, so the markers for an enum's members go in `@as_workflow`:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
@as_workflow(places=Step, place_markers={Step.NEW: Place(initial=True)})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
A key that is not a member of the `places` enum raises `LogicError` at decoration.
|
|
142
|
+
|
|
143
|
+
## `WorkflowTrait`
|
|
144
|
+
|
|
145
|
+
Mixing it in is optional, and makes the class answer for its own workflow: it delegates every
|
|
146
|
+
`WorkflowInterface` method — `get_marking`, `can`, `build_transition_blocker_list`, `apply`,
|
|
147
|
+
`get_enabled_transitions`, `get_enabled_transition`, `get_name`, `get_definition`,
|
|
148
|
+
`get_marking_store`, `get_metadata_store` — to the workflow handed to `set_workflow(...)`, which
|
|
149
|
+
the bundle calls after building it (the traced one, in debug mode). The class keeps whatever
|
|
150
|
+
constructor it likes.
|
|
151
|
+
|
|
152
|
+
Called before a workflow is set, every delegate raises `LogicError` naming the class. The workflow
|
|
153
|
+
is kept under a name-mangled attribute, so `_workflow` is free for your own use.
|
|
154
|
+
|
|
155
|
+
## Listeners on the class
|
|
156
|
+
|
|
157
|
+
The `as_*_listener` decorators name a workflow with their first positional argument. Inside a class
|
|
158
|
+
decorated `@as_workflow`, leave it out and the listener is that workflow's: the decorator records
|
|
159
|
+
the placeholder `workflow.@self.guard.start`, and `@as_workflow` rewrites it to
|
|
160
|
+
`workflow.task.guard.start` when it decorates the class.
|
|
161
|
+
|
|
162
|
+
- Keep `@as_workflow` **above** every listener decorator: it resolves what is already recorded.
|
|
163
|
+
- An inherited listener method is resolved per class, so two workflow classes sharing a base each
|
|
164
|
+
listen to their own workflow.
|
|
165
|
+
- A **concrete** class carrying such declarations inside a scanned package fails the build: there
|
|
166
|
+
is no workflow to resolve the name to. Make a shared listener base abstract, or keep it out of
|
|
167
|
+
the scanned packages.
|
|
168
|
+
- A plain function cannot use the placeholder: name the workflow, or listen to every one.
|
|
169
|
+
|
|
170
|
+
See [events.md](events.md) for the decorators and the names they build.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Workflow events
|
|
2
|
+
|
|
3
|
+
Every step of a move goes out as an event, through xtr-event-dispatcher. Without a dispatcher
|
|
4
|
+
nothing is asked and nothing told: every transition the marking enables is enabled.
|
|
5
|
+
|
|
6
|
+
## The seven events, in order
|
|
7
|
+
|
|
8
|
+
| Name | Class | When | A listener can |
|
|
9
|
+
| --- | --- | --- | --- |
|
|
10
|
+
| `workflow.guard` | `GuardEvent` | Before a transition is applied or reported enabled, for every transition the marking enables | Block it: `set_blocked(True, "why")`, or `add_transition_blocker(...)` |
|
|
11
|
+
| `workflow.leave` | `LeaveEvent` | Before the tokens leave each place | React |
|
|
12
|
+
| `workflow.transition` | `TransitionEvent` | While applying the transition | Replace the rest of the call's context: `set_context(...)` |
|
|
13
|
+
| `workflow.enter` | `EnterEvent` | Before the tokens enter each place, before the marking is stored | React |
|
|
14
|
+
| `workflow.entered` | `EnteredEvent` | After the marking is stored — and for a subject's first marking, with the context `{"initial": True}` | React |
|
|
15
|
+
| `workflow.completed` | `CompletedEvent` | The transition has been applied | React |
|
|
16
|
+
| `workflow.announce` | `AnnounceEvent` | Once per transition enabled **after** the move | React |
|
|
17
|
+
|
|
18
|
+
The guard is never turned off. `announce` asks every guard again to find what is now enabled, so it
|
|
19
|
+
is the one worth turning off when guards are expensive.
|
|
20
|
+
|
|
21
|
+
## Three names each
|
|
22
|
+
|
|
23
|
+
Each event is dispatched under its bare name, then narrowed to the workflow, then to each place or
|
|
24
|
+
transition concerned:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
workflow.enter
|
|
28
|
+
workflow.blog.enter
|
|
29
|
+
workflow.blog.enter.published # one per place the transition enters
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `leave`, `enter` and `entered` narrow to a **place** — one dispatch per arc.
|
|
33
|
+
- `guard`, `transition`, `completed` and `announce` narrow to a **transition**.
|
|
34
|
+
- `WorkflowEvents` (from `xtr_workflow`) holds the seven bare names, and `WorkflowEvents.ALIASES`
|
|
35
|
+
maps each event class to its name. With the bundle active, those aliases are given to the event
|
|
36
|
+
dispatcher, so a listener annotated `def on_guard(self, event: GuardEvent)` hears
|
|
37
|
+
`workflow.guard` without naming it.
|
|
38
|
+
|
|
39
|
+
Each event class has a static `get_name(workflow_name=None, node=None)` building the name:
|
|
40
|
+
`GuardEvent.get_name("blog", "publish")` is `"workflow.blog.guard.publish"`. A node without a
|
|
41
|
+
workflow raises `InvalidArgumentError`.
|
|
42
|
+
|
|
43
|
+
## Reading an event
|
|
44
|
+
|
|
45
|
+
Every event answers `get_subject()`, `get_marking()`, `get_transition()` (`None` only for an
|
|
46
|
+
initial marking), `get_workflow_name()` and `get_metadata(key, subject)`.
|
|
47
|
+
`GuardEvent.get_transition()` is never `None`. `LeaveEvent`, `TransitionEvent`, `EnterEvent`,
|
|
48
|
+
`EnteredEvent`, `CompletedEvent` and `AnnounceEvent` also carry `get_context()`.
|
|
49
|
+
|
|
50
|
+
## The listener decorators
|
|
51
|
+
|
|
52
|
+
From `xtr_workflow.decorator`. Each builds the event name and otherwise declares an event listener
|
|
53
|
+
the way `@as_event_listener` does, taking the same `method`, `priority`, `dispatcher`, `before` and
|
|
54
|
+
`after` keywords.
|
|
55
|
+
|
|
56
|
+
| Decorator | Second positional argument |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `as_guard_listener(workflow, transition)` | `transition=` |
|
|
59
|
+
| `as_leave_listener(workflow, place)` | `place=` |
|
|
60
|
+
| `as_transition_listener(workflow, transition)` | `transition=` |
|
|
61
|
+
| `as_enter_listener(workflow, place)` | `place=` |
|
|
62
|
+
| `as_entered_listener(workflow, place)` | `place=` |
|
|
63
|
+
| `as_completed_listener(workflow, transition)` | `transition=` |
|
|
64
|
+
| `as_announce_listener(workflow, transition)` | `transition=` |
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from xtr_workflow.decorator import as_entered_listener, as_guard_listener
|
|
68
|
+
from xtr_workflow.event import EnteredEvent, GuardEvent
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@as_entered_listener("blog", place="published") # workflow.blog.entered.published
|
|
72
|
+
async def ping_the_feed(event: EnteredEvent) -> None: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@as_guard_listener("blog") # workflow.blog.guard — every transition of that workflow
|
|
76
|
+
def refuse_unsigned(event: GuardEvent) -> None:
|
|
77
|
+
if not event.get_subject().author:
|
|
78
|
+
event.set_blocked(True, "An article needs an author before it goes out.")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Naming neither a workflow nor a node listens to every workflow. Naming a node without a workflow
|
|
82
|
+
means "the workflow of my class" — see
|
|
83
|
+
[declaring-workflows.md](declaring-workflows.md); outside a class declaring one it is a build
|
|
84
|
+
error.
|
|
85
|
+
|
|
86
|
+
Adding a listener by hand needs no decorator:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
dispatcher.add_listener("workflow.blog.guard.publish", refuse_unsigned)
|
|
90
|
+
dispatcher.add_listener(WorkflowEvents.ENTERED, announce)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Choosing what goes out
|
|
94
|
+
|
|
95
|
+
`events_to_dispatch`, on the constructor, in `WorkflowDefinitionConfig` or on `@as_workflow`, says
|
|
96
|
+
which events a workflow ever dispatches:
|
|
97
|
+
|
|
98
|
+
| Value | Dispatches |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `None` | Every event |
|
|
101
|
+
| `[]` | None of them — the guard still goes out |
|
|
102
|
+
| `["workflow.enter", "workflow.entered"]` | Only those |
|
|
103
|
+
| `["!workflow.announce"]` | Every one but those, so an event added later still goes out |
|
|
104
|
+
|
|
105
|
+
Mixing listed and `!`-prefixed entries, or blocking `workflow.guard`, raises
|
|
106
|
+
`InvalidArgumentError`.
|
|
107
|
+
|
|
108
|
+
For one call, pass the context key instead — each a constant on `Workflow`:
|
|
109
|
+
`DISABLE_LEAVE_EVENT`, `DISABLE_TRANSITION_EVENT`, `DISABLE_ENTER_EVENT`,
|
|
110
|
+
`DISABLE_ENTERED_EVENT`, `DISABLE_COMPLETED_EVENT`, `DISABLE_ANNOUNCE_EVENT`.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
await workflow.apply(article, "publish", {Workflow.DISABLE_ANNOUNCE_EVENT: True})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## The audit trail
|
|
117
|
+
|
|
118
|
+
`AuditTrailListener` (`xtr_workflow.event_listener`) logs an info line per place a subject leaves,
|
|
119
|
+
one for the transition, and one per place it enters, each naming the subject's class and the
|
|
120
|
+
workflow. Built by hand it takes a `LoggerInterface`; with the bundle,
|
|
121
|
+
`audit_trail=AuditTrailConfig(enabled=True)` registers it on the workflow's `leave`, `transition`
|
|
122
|
+
and `enter` events, writing to the `workflow` logging channel — which needs the event dispatcher
|
|
123
|
+
and logging bundles active.
|