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.
Files changed (116) hide show
  1. xtr_workflow/.agents/skills/xtr-workflow/SKILL.md +343 -0
  2. xtr_workflow/.agents/skills/xtr-workflow/references/declaring-workflows.md +170 -0
  3. xtr_workflow/.agents/skills/xtr-workflow/references/events.md +123 -0
  4. xtr_workflow/__init__.py +54 -0
  5. xtr_workflow/_events_to_dispatch.py +57 -0
  6. xtr_workflow/arc.py +40 -0
  7. xtr_workflow/bundle/__init__.py +29 -0
  8. xtr_workflow/bundle/_built_transitions.py +61 -0
  9. xtr_workflow/bundle/_declaration_reader.py +250 -0
  10. xtr_workflow/bundle/_declared_workflows.py +140 -0
  11. xtr_workflow/bundle/_injecting_factory.py +47 -0
  12. xtr_workflow/bundle/_marked_constants.py +166 -0
  13. xtr_workflow/bundle/_place_name.py +34 -0
  14. xtr_workflow/bundle/_unit_of_work_guard_listener.py +69 -0
  15. xtr_workflow/bundle/arc_config.py +58 -0
  16. xtr_workflow/bundle/audit_trail_config.py +21 -0
  17. xtr_workflow/bundle/marking_store_config.py +55 -0
  18. xtr_workflow/bundle/place_config.py +30 -0
  19. xtr_workflow/bundle/transition_config.py +117 -0
  20. xtr_workflow/bundle/workflow_bundle.py +164 -0
  21. xtr_workflow/bundle/workflow_config.py +58 -0
  22. xtr_workflow/bundle/workflow_debug_pass.py +53 -0
  23. xtr_workflow/bundle/workflow_definition_config.py +263 -0
  24. xtr_workflow/bundle/workflow_service_registrar.py +302 -0
  25. xtr_workflow/command/__init__.py +10 -0
  26. xtr_workflow/command/workflow_dump_command.py +139 -0
  27. xtr_workflow/debug/__init__.py +15 -0
  28. xtr_workflow/debug/listener_extractor.py +274 -0
  29. xtr_workflow/debug/traceable_workflow.py +227 -0
  30. xtr_workflow/decorator/__init__.py +34 -0
  31. xtr_workflow/decorator/_build_event_name.py +29 -0
  32. xtr_workflow/decorator/_build_metadata.py +38 -0
  33. xtr_workflow/decorator/_resolved_listeners.py +157 -0
  34. xtr_workflow/decorator/_workflow_name.py +41 -0
  35. xtr_workflow/decorator/as_announce_listener.py +52 -0
  36. xtr_workflow/decorator/as_completed_listener.py +52 -0
  37. xtr_workflow/decorator/as_enter_listener.py +52 -0
  38. xtr_workflow/decorator/as_entered_listener.py +53 -0
  39. xtr_workflow/decorator/as_guard_listener.py +52 -0
  40. xtr_workflow/decorator/as_leave_listener.py +52 -0
  41. xtr_workflow/decorator/as_transition_listener.py +53 -0
  42. xtr_workflow/decorator/as_workflow.py +192 -0
  43. xtr_workflow/decorator/place.py +44 -0
  44. xtr_workflow/decorator/transition.py +65 -0
  45. xtr_workflow/decorator/workflow_listener_declaration.py +84 -0
  46. xtr_workflow/definition.py +121 -0
  47. xtr_workflow/definition_builder.py +104 -0
  48. xtr_workflow/dumper/__init__.py +17 -0
  49. xtr_workflow/dumper/_escaping.py +171 -0
  50. xtr_workflow/dumper/_graphviz_nodes.py +49 -0
  51. xtr_workflow/dumper/_options.py +44 -0
  52. xtr_workflow/dumper/dumper_interface.py +39 -0
  53. xtr_workflow/dumper/graphviz_dumper.py +285 -0
  54. xtr_workflow/dumper/mermaid_dumper.py +223 -0
  55. xtr_workflow/dumper/plant_uml_dumper.py +223 -0
  56. xtr_workflow/dumper/state_machine_graphviz_dumper.py +93 -0
  57. xtr_workflow/event/__init__.py +23 -0
  58. xtr_workflow/event/_event_name.py +32 -0
  59. xtr_workflow/event/_has_context.py +49 -0
  60. xtr_workflow/event/announce_event.py +28 -0
  61. xtr_workflow/event/completed_event.py +24 -0
  62. xtr_workflow/event/enter_event.py +24 -0
  63. xtr_workflow/event/entered_event.py +28 -0
  64. xtr_workflow/event/event.py +89 -0
  65. xtr_workflow/event/guard_event.py +82 -0
  66. xtr_workflow/event/leave_event.py +24 -0
  67. xtr_workflow/event/transition_event.py +35 -0
  68. xtr_workflow/event_listener/__init__.py +19 -0
  69. xtr_workflow/event_listener/audit_trail_listener.py +107 -0
  70. xtr_workflow/event_listener/guard_context.py +57 -0
  71. xtr_workflow/event_listener/guard_expression.py +53 -0
  72. xtr_workflow/event_listener/guard_listener.py +173 -0
  73. xtr_workflow/exception/__init__.py +23 -0
  74. xtr_workflow/exception/invalid_argument_error.py +15 -0
  75. xtr_workflow/exception/invalid_definition_error.py +17 -0
  76. xtr_workflow/exception/logic_error.py +16 -0
  77. xtr_workflow/exception/not_enabled_transition_error.py +55 -0
  78. xtr_workflow/exception/transition_error.py +67 -0
  79. xtr_workflow/exception/undefined_transition_error.py +41 -0
  80. xtr_workflow/exception/workflow_error.py +9 -0
  81. xtr_workflow/exception/workflow_runtime_error.py +11 -0
  82. xtr_workflow/marking.py +120 -0
  83. xtr_workflow/marking_store/__init__.py +8 -0
  84. xtr_workflow/marking_store/_marking_store_method.py +22 -0
  85. xtr_workflow/marking_store/marking_store_interface.py +43 -0
  86. xtr_workflow/marking_store/method_marking_store.py +293 -0
  87. xtr_workflow/metadata/__init__.py +9 -0
  88. xtr_workflow/metadata/get_metadata_trait.py +68 -0
  89. xtr_workflow/metadata/in_memory_metadata_store.py +71 -0
  90. xtr_workflow/metadata/metadata_store_interface.py +58 -0
  91. xtr_workflow/py.typed +0 -0
  92. xtr_workflow/recipe/__init__.py +16 -0
  93. xtr_workflow/recipe/files/config/workflow.py.tmpl +40 -0
  94. xtr_workflow/recipe/manifest.toml +31 -0
  95. xtr_workflow/registry.py +97 -0
  96. xtr_workflow/state_machine.py +63 -0
  97. xtr_workflow/support_strategy/__init__.py +8 -0
  98. xtr_workflow/support_strategy/instance_of_support_strategy.py +41 -0
  99. xtr_workflow/support_strategy/workflow_support_strategy_interface.py +28 -0
  100. xtr_workflow/transition.py +118 -0
  101. xtr_workflow/transition_blocker.py +151 -0
  102. xtr_workflow/transition_blocker_list.py +53 -0
  103. xtr_workflow/validator/__init__.py +9 -0
  104. xtr_workflow/validator/definition_validator_interface.py +40 -0
  105. xtr_workflow/validator/state_machine_validator.py +106 -0
  106. xtr_workflow/validator/workflow_validator.py +118 -0
  107. xtr_workflow/workflow.py +429 -0
  108. xtr_workflow/workflow_events.py +68 -0
  109. xtr_workflow/workflow_interface.py +105 -0
  110. xtr_workflow/workflow_trait.py +105 -0
  111. xtr_workflow/workflow_type.py +21 -0
  112. xtr_workflow-5.1.0.dist-info/METADATA +722 -0
  113. xtr_workflow-5.1.0.dist-info/RECORD +116 -0
  114. xtr_workflow-5.1.0.dist-info/WHEEL +4 -0
  115. xtr_workflow-5.1.0.dist-info/entry_points.txt +6 -0
  116. 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.