camunda-python 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.
Files changed (46) hide show
  1. camunda/__init__.py +10 -0
  2. camunda/api/__init__.py +14 -0
  3. camunda/api/app.py +80 -0
  4. camunda/api/deps.py +17 -0
  5. camunda/api/errors.py +84 -0
  6. camunda/api/pagination.py +74 -0
  7. camunda/api/routers/__init__.py +5 -0
  8. camunda/api/routers/decision.py +57 -0
  9. camunda/api/routers/deployment.py +139 -0
  10. camunda/api/routers/history.py +128 -0
  11. camunda/api/routers/process_definition.py +49 -0
  12. camunda/api/routers/process_instance.py +106 -0
  13. camunda/api/routers/task.py +92 -0
  14. camunda/api/schemas.py +200 -0
  15. camunda/common/__init__.py +19 -0
  16. camunda/common/clock.py +30 -0
  17. camunda/common/exceptions.py +34 -0
  18. camunda/common/idgen.py +23 -0
  19. camunda/common/timers.py +106 -0
  20. camunda/dmn/__init__.py +5 -0
  21. camunda/dmn/engine.py +219 -0
  22. camunda/dmn/feel.py +392 -0
  23. camunda/engine/__init__.py +9 -0
  24. camunda/engine/behavior.py +51 -0
  25. camunda/engine/expression.py +126 -0
  26. camunda/engine/process_engine.py +3237 -0
  27. camunda/job/__init__.py +9 -0
  28. camunda/job/executor.py +136 -0
  29. camunda/model/__init__.py +48 -0
  30. camunda/model/bpmn.py +326 -0
  31. camunda/model/dmn.py +101 -0
  32. camunda/model/execution.py +121 -0
  33. camunda/model/job.py +88 -0
  34. camunda/model/task.py +33 -0
  35. camunda/model/variable.py +35 -0
  36. camunda/parser/__init__.py +5 -0
  37. camunda/parser/bpmn_parser.py +646 -0
  38. camunda/parser/dmn_parser.py +225 -0
  39. camunda/persistence/__init__.py +21 -0
  40. camunda/persistence/entities.py +202 -0
  41. camunda/persistence/store.py +721 -0
  42. camunda_python-0.1.0.dist-info/METADATA +377 -0
  43. camunda_python-0.1.0.dist-info/RECORD +46 -0
  44. camunda_python-0.1.0.dist-info/WHEEL +5 -0
  45. camunda_python-0.1.0.dist-info/licenses/LICENSE +200 -0
  46. camunda_python-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,646 @@
1
+ """BPMN 2.0 XML 解析器(lxml)。
2
+
3
+ 职责(对齐 Camunda bpmn-model 的解析部分):
4
+ 1. 解析 XML -> BpmnModel(含多个 Process)
5
+ 2. 每个 Process 内:先收集 sequenceFlow,再按 tag 分派 flowNode 类型
6
+ 3. 处理 camunda 扩展属性:delegateExpression / class(serviceTask 实现解析)
7
+ 4. 校验:节点入边/出边引用存在、流程至少一个 startEvent
8
+
9
+ 命名空间处理策略:只用 localName 分派(BPMN 语义元素),属性同时接受
10
+ camunda:xxx 与自带命名空间前缀。extensionElements 仅 serviceTask 需要,
11
+ 取 camunda:class / camunda:delegateExpression。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Dict, Optional
17
+
18
+ from lxml import etree
19
+
20
+ from camunda.common.exceptions import DeploymentException
21
+ from camunda.common.timers import parse_iso_duration
22
+ from camunda.model.bpmn import (
23
+ BpmnModel,
24
+ BoundaryEvent,
25
+ BusinessRuleTask,
26
+ EndEvent,
27
+ MultiInstance,
28
+ Process,
29
+ SequenceFlow,
30
+ FLOW_NODE_TYPES,
31
+ FlowNode,
32
+ IntermediateCatchEvent,
33
+ IntermediateThrowEvent,
34
+ ServiceTask,
35
+ StartEvent,
36
+ SubProcess,
37
+ TimerDefinition,
38
+ UserTask,
39
+ )
40
+
41
+ # BPMN 默认命名空间(用于无前缀 tag 匹配,localName 分派时其实不需要,保留常量以文档化)
42
+ BPMN_NS = "http://www.omg.org/spec/BPMN/20100524/MODEL"
43
+ CAMUNDA_NS = "http://camunda.org/schema/1.0/bpmn"
44
+
45
+ # timerEventDefinition 子元素名 -> kind
46
+ _TIMER_KIND = {
47
+ "timeDuration": "duration",
48
+ "timeDate": "date",
49
+ "timeCycle": "cycle",
50
+ }
51
+
52
+
53
+ def _local(tag: str) -> str:
54
+ """lxml tag 形如 {ns}localName -> localName。"""
55
+ return tag.rsplit("}", 1)[-1]
56
+
57
+
58
+ def parse_bpmn_xml(xml_text: str, source_name: Optional[str] = None) -> BpmnModel:
59
+ """解析 BPMN 2.0 XML 文本 -> BpmnModel。失败抛 DeploymentException。"""
60
+ try:
61
+ root = etree.fromstring(xml_text.encode("utf-8"))
62
+ except etree.XMLSyntaxError as e:
63
+ raise DeploymentException(f"BPMN XML 语法错误: {e}") from e
64
+
65
+ model = BpmnModel(source_name=source_name, source_xml=xml_text)
66
+
67
+ # 顶层事件声明收集:<definitions> 下的 <error id errorCode> / <message id name> /
68
+ # <signal id name>(errorEventDefinition errorRef / messageEventDefinition messageRef /
69
+ # signalEventDefinition signalRef 解析期回填,模型不保留声明表——事件槽内联
70
+ # code/name,运行时零查找)
71
+ error_by_id: Dict[str, str] = {}
72
+ message_by_id: Dict[str, str] = {}
73
+ signal_by_id: Dict[str, str] = {}
74
+ for el in root.iter():
75
+ if not isinstance(el.tag, str): # 跳过注释/PI 节点
76
+ continue
77
+ ln = _local(el.tag)
78
+ if ln == "error":
79
+ error_by_id[el.get("id", "")] = el.get("errorCode") or el.get("id") or ""
80
+ elif ln == "message":
81
+ message_by_id[el.get("id", "")] = el.get("name") or el.get("id") or ""
82
+ elif ln == "signal":
83
+ signal_by_id[el.get("id", "")] = el.get("name") or el.get("id") or ""
84
+
85
+ # 顶层按出现顺序找 <process>(注意可能嵌套在 collaboration 外或内,直接遍历即可)
86
+ for process_el in root.iter():
87
+ if not isinstance(process_el.tag, str): # 跳过注释/PI 节点
88
+ continue
89
+ if _local(process_el.tag) != "process":
90
+ continue
91
+ model.processes.append(
92
+ _parse_process(process_el, error_by_id, message_by_id, signal_by_id)
93
+ )
94
+
95
+ if not model.processes:
96
+ raise DeploymentException("BPMN 文件中未找到任何 <process> 元素")
97
+
98
+ return model
99
+
100
+
101
+ def parse_bpmn_file(path: str) -> BpmnModel:
102
+ with open(path, "r", encoding="utf-8") as f:
103
+ return parse_bpmn_xml(f.read(), source_name=path)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Process 解析
108
+ # ---------------------------------------------------------------------------
109
+ def _parse_process(
110
+ process_el, error_by_id=None, message_by_id=None, signal_by_id=None
111
+ ) -> Process:
112
+ proc = Process(
113
+ id=process_el.get("id") or process_el.get("name") or "process",
114
+ name=process_el.get("name"),
115
+ is_executable=(process_el.get("isExecutable", "true").lower() == "true"),
116
+ )
117
+ _fill_container(
118
+ process_el,
119
+ proc,
120
+ error_by_id=error_by_id,
121
+ message_by_id=message_by_id,
122
+ signal_by_id=signal_by_id,
123
+ )
124
+ proc.__post_init__() # 重算 start_events
125
+ return proc
126
+
127
+
128
+ def _fill_container(
129
+ el,
130
+ proc: Process,
131
+ require_start: bool = True,
132
+ error_by_id: Optional[Dict[str, str]] = None,
133
+ message_by_id: Optional[Dict[str, str]] = None,
134
+ signal_by_id: Optional[Dict[str, str]] = None,
135
+ ) -> None:
136
+ """把一个 BPMN 容器元素(<process> 或 <subProcess>)的节点/连线解析进 Process。
137
+
138
+ 四遍结构(M4-2a 起容器递归):
139
+ 1. sequenceFlow 先行(节点只存引用 id)
140
+ 2. flowNode / subProcess:subProcess 先登记为父容器节点,再递归解析其内部容器
141
+ 3. incoming/outgoing 连线挂接(同容器校验:跨容器引用自然报错)
142
+ 4. 边界事件按 attachedToRef 归属到宿主
143
+ require_start=False 用于事件子流程(内部 start 是事件触发入口,独立校验)。
144
+ 结束调 __post_init__ 重算 start_events。
145
+ """
146
+ # 第一遍:sequenceFlow(连线必须先于节点存在,节点只存引用 id)
147
+ for child in el:
148
+ if not isinstance(child.tag, str): # 跳过注释/PI 节点
149
+ continue
150
+ if _local(child.tag) == "sequenceFlow":
151
+ flow = _parse_sequence_flow(child)
152
+ proc.sequence_flows[flow.id] = flow
153
+
154
+ # 第二遍:flowNode 家族(task/event/gateway/subProcess 等)
155
+ for child in el:
156
+ if not isinstance(child.tag, str):
157
+ continue
158
+ node_type = _local(child.tag)
159
+ if node_type == "subProcess":
160
+ sub = _parse_sub_process(child, error_by_id, message_by_id, signal_by_id)
161
+ proc.flow_nodes[sub.id] = sub
162
+ elif node_type in FLOW_NODE_TYPES:
163
+ node = _parse_flow_node(child, node_type, error_by_id, message_by_id, signal_by_id)
164
+ proc.flow_nodes[node.id] = node
165
+
166
+ # 第三遍:把连线引用挂到节点上(incoming/outgoing 子元素按 XML 顺序)
167
+ _wire_flows(proc)
168
+ # 第四遍:边界事件按 attachedToRef 归属到宿主活动
169
+ _attach_boundaries(proc)
170
+
171
+ # 校验 + 重算 start_events
172
+ _validate_process(proc, require_start=require_start)
173
+ proc.__post_init__()
174
+
175
+
176
+ def _parse_sub_process(
177
+ el, error_by_id=None, message_by_id=None, signal_by_id=None
178
+ ) -> SubProcess:
179
+ """解析 <subProcess>:父容器登记节点 + 递归解析内部容器。
180
+
181
+ 通用属性(id/name/camunda:asyncBefore 等)走 _parse_flow_node;内部子元素
182
+ (内部 sequenceFlow / flowNode / 嵌套 subProcess / boundary 归属)递归进
183
+ _fill_container。事件子流程(triggeredByEvent=true):内部 start 事件驱动
184
+ (无普通 startEvent),解析后做事件子流程专项校验(_validate_event_subprocess)。
185
+ """
186
+ node = _parse_flow_node(el, "subProcess", error_by_id, message_by_id, signal_by_id)
187
+ assert isinstance(node, SubProcess)
188
+ node.triggered_by_event = el.get("triggeredByEvent", "false").lower() == "true"
189
+ inner = Process(id=f"{node.id}::inner", name=node.name, is_executable=True)
190
+ _fill_container(
191
+ el,
192
+ inner,
193
+ require_start=not node.triggered_by_event,
194
+ error_by_id=error_by_id,
195
+ message_by_id=message_by_id,
196
+ signal_by_id=signal_by_id,
197
+ )
198
+ node.process = inner
199
+ if node.triggered_by_event:
200
+ _validate_event_subprocess(node, inner)
201
+ return node
202
+
203
+
204
+ def _validate_event_subprocess(sub: SubProcess, inner: Process) -> None:
205
+ """事件子流程容器规范校验(triggeredByEvent=true,M4-2b)。
206
+
207
+ 事件子流程不参与 sequenceFlow 流转(无 incoming/outgoing),内部 startEvent
208
+ 是唯一触发入口,因此:
209
+ - 至少一个 startEvent;
210
+ - 每个 startEvent 必须恰好带一个事件定义(timer / error / message / signal)——
211
+ none start 无法触发事件子流程;
212
+ - error start 强制 isInterrupting=true(BPMN 规范:错误事件只能中断式);
213
+ - startEvent 不得有入边(事件子流程不与 sequenceFlow 相连)。
214
+ message/signal start 解析与运行时订阅 M4-2d 落地(correlate_message /
215
+ throw_signal 触发,中断/非中断随 isInterrupting)。
216
+ """
217
+ starts = [n for n in inner.flow_nodes.values() if isinstance(n, StartEvent)]
218
+ if not starts:
219
+ raise DeploymentException(
220
+ f"事件子流程 {sub.id!r} 缺少 startEvent(事件触发入口)"
221
+ )
222
+ for st in starts:
223
+ has_event = (
224
+ st.timer is not None
225
+ or st.error_code is not None
226
+ or st.message_name is not None
227
+ or st.signal_name is not None
228
+ )
229
+ if not has_event:
230
+ raise DeploymentException(
231
+ f"事件子流程 {sub.id!r} 的 startEvent {st.id!r} 缺少事件定义"
232
+ "(none start 不能触发事件子流程)"
233
+ )
234
+ if st.error_code is not None and not st.is_interrupting:
235
+ raise DeploymentException(
236
+ f"事件子流程 {sub.id!r} 的 error start {st.id!r} 声明 "
237
+ "isInterrupting=false:BPMN 规范错误事件只支持中断式"
238
+ )
239
+ if st.incoming:
240
+ raise DeploymentException(
241
+ f"事件子流程 {sub.id!r} 的 startEvent {st.id!r} 有入边:"
242
+ "事件子流程不参与 sequenceFlow 流转"
243
+ )
244
+
245
+
246
+ def _parse_sequence_flow(el) -> SequenceFlow:
247
+ flow = SequenceFlow(
248
+ id=el.get("id", ""),
249
+ source_ref=el.get("sourceRef", ""),
250
+ target_ref=el.get("targetRef", ""),
251
+ name=el.get("name"),
252
+ )
253
+ # conditionExpression 是 sequenceFlow 的子元素:<conditionExpression xsi:type="tFormalExpression">${...}</...>
254
+ for child in el:
255
+ if not isinstance(child.tag, str):
256
+ continue
257
+ if _local(child.tag) == "conditionExpression":
258
+ text = (child.text or "").strip()
259
+ if text:
260
+ flow.condition_expression = text
261
+ return flow
262
+
263
+
264
+ def _parse_flow_node(
265
+ el, node_type: str, error_by_id=None, message_by_id=None, signal_by_id=None
266
+ ) -> FlowNode:
267
+ cls = FLOW_NODE_TYPES[node_type]
268
+ node_id = el.get("id", "")
269
+ node = cls(
270
+ id=node_id,
271
+ name=el.get("name"),
272
+ default_flow=el.get("default"),
273
+ )
274
+ # 收集 camunda 命名空间直接属性(如 camunda:class / camunda:delegateExpression
275
+ # 常直接写在元素属性上,也可能藏在 extensionElements 子元素里——两者都收)
276
+ camunda_attrs: Dict[str, Optional[str]] = {}
277
+ for attr_name, attr_val in el.attrib.items():
278
+ if "}" in attr_name and attr_name.rsplit("}", 1)[0].strip("{}") == CAMUNDA_NS:
279
+ camunda_attrs[attr_name.rsplit("}", 1)[-1]] = attr_val.strip()
280
+
281
+ # async continuation 标志(camunda:asyncBefore / asyncAfter="true")
282
+ if "asyncBefore" in camunda_attrs:
283
+ node.async_before = camunda_attrs["asyncBefore"].lower() == "true"
284
+ if "asyncAfter" in camunda_attrs:
285
+ node.async_after = camunda_attrs["asyncAfter"].lower() == "true"
286
+
287
+ # 解析子元素:incoming/outgoing 由 _wire_flows 回填,此处仅处理 extensionElements
288
+ _parse_node_children(el, node)
289
+
290
+ # boundaryEvent: attachedToRef / cancelActivity(事件定义解析走下面事件共通路径)
291
+ if isinstance(node, BoundaryEvent):
292
+ node.attached_to = el.get("attachedToRef")
293
+ node.cancel_activity = el.get("cancelActivity", "true").lower() != "false"
294
+
295
+ # startEvent: isInterrupting(事件子流程中断/非中断标志;普通流程恒 true)
296
+ if isinstance(node, StartEvent):
297
+ node.is_interrupting = el.get("isInterrupting", "true").lower() != "false"
298
+
299
+ # 事件类节点:事件定义统一解析(timer M3 / error、message M4-2b)
300
+ if isinstance(
301
+ node,
302
+ (StartEvent, IntermediateCatchEvent, BoundaryEvent, EndEvent, IntermediateThrowEvent),
303
+ ):
304
+ timer, error_code, message_name, signal_name = _parse_event_definitions(
305
+ el, error_by_id, message_by_id, signal_by_id
306
+ )
307
+ defined = [x is not None for x in (timer, error_code, message_name, signal_name)]
308
+ if sum(defined) > 1:
309
+ raise DeploymentException(
310
+ f"节点 {node.id!r} 携带多个事件定义(timer/error/message/signal 互斥)"
311
+ )
312
+ if isinstance(node, (StartEvent, IntermediateCatchEvent, BoundaryEvent)):
313
+ node.timer = timer
314
+ node.error_code = error_code
315
+ node.message_name = message_name
316
+ node.signal_name = signal_name
317
+ elif isinstance(node, (EndEvent, IntermediateThrowEvent)):
318
+ # throw 类事件(M4-2d):end/中间抛出支持 error/message/signal;
319
+ # timer throw 无意义(拒绝)
320
+ node.error_code = error_code
321
+ node.message_name = message_name
322
+ node.signal_name = signal_name
323
+ if timer is not None:
324
+ raise DeploymentException(
325
+ f"{type(node).__name__} {node.id!r} 不支持 timer throw 事件"
326
+ "(文档化差异)"
327
+ )
328
+ else: # IntermediateCatchEvent / BoundaryEvent 之外的类型不会进入此分支
329
+ raise DeploymentException(
330
+ f"节点 {node.id!r} 类型 {type(node).__name__} 不支持事件定义"
331
+ )
332
+
333
+ # serviceTask: 解析实现引用(属性优先级高于扩展子元素)
334
+ if isinstance(node, ServiceTask):
335
+ ref = (
336
+ camunda_attrs.get("delegateExpression")
337
+ or camunda_attrs.get("class")
338
+ or node.extension.get("delegateExpression")
339
+ or node.extension.get("class")
340
+ )
341
+ node.implementation_ref = _resolve_impl_ref(ref)
342
+
343
+ # businessRuleTask(M5):DMN 决策引用必填;结果变量缺省 "result"
344
+ if isinstance(node, BusinessRuleTask):
345
+ ref = camunda_attrs.get("decisionRef") or node.extension.get("decisionRef")
346
+ if not ref:
347
+ raise DeploymentException(
348
+ f"businessRuleTask {node.id!r} 缺少 camunda:decisionRef"
349
+ )
350
+ node.decision_ref = ref
351
+ node.result_variable = (
352
+ camunda_attrs.get("resultVariable")
353
+ or node.extension.get("resultVariable")
354
+ or "result"
355
+ )
356
+
357
+ # 其余 camunda 属性并入 extension(供未来里程碑使用)
358
+ if camunda_attrs:
359
+ node.extension.update(camunda_attrs)
360
+ return node
361
+
362
+
363
+ def _parse_node_children(el, node: FlowNode) -> None:
364
+ """解析节点子元素:incoming/outgoing 引用在 _wire_flows 处理;
365
+ 这里处理 extensionElements 里的简单 camunda:xxx 扩展与 multiInstanceLoopCharacteristics。"""
366
+ camunda_attrs: Dict[str, Optional[str]] = {}
367
+
368
+ # 遍历子元素(XML 顺序)
369
+ for child in el:
370
+ if not isinstance(child.tag, str):
371
+ continue
372
+ lname = _local(child.tag)
373
+ # 注意:incoming/outgoing 引用由 _wire_flows 统一从 sequenceFlow 回填,
374
+ # 这里不收集,避免与连线挂接逻辑重复。
375
+ if lname in ("incoming", "outgoing"):
376
+ continue
377
+ if lname == "conditionExpression":
378
+ # 条件表达式挂在 sequenceFlow 上,此处不会出现;防御性忽略
379
+ pass
380
+ elif lname == "multiInstanceLoopCharacteristics":
381
+ # 多实例循环特征(M4-2c):宿主白名单 userTask / serviceTask /
382
+ # subProcess;其余类型(事件/网关等)部署即报错(文档化差异)
383
+ if not isinstance(node, (UserTask, ServiceTask, SubProcess)):
384
+ raise DeploymentException(
385
+ f"节点 {node.id!r} 声明 multiInstanceLoopCharacteristics:M4-2c "
386
+ f"仅支持 userTask / serviceTask / subProcess 宿主,"
387
+ f"{type(node).__name__} 不支持(文档化差异)"
388
+ )
389
+ node.multi_instance = _parse_multi_instance(child)
390
+ elif lname == "extensionElements":
391
+ _collect_camunda_extension(child, camunda_attrs)
392
+
393
+ if camunda_attrs:
394
+ node.extension.update(camunda_attrs)
395
+
396
+
397
+ def _parse_multi_instance(el) -> MultiInstance:
398
+ """解析 bpmn:multiInstanceLoopCharacteristics 子元素。
399
+
400
+ XML 结构:
401
+ <bpmn:multiInstanceLoopCharacteristics isSequential="false"
402
+ camunda:collection="${reviewers}" camunda:elementVariable="reviewer">
403
+ <bpmn:loopCardinality xsi:type="bpmn:tFormalExpression">3</...>
404
+ <bpmn:completionCondition xsi:type="bpmn:tFormalExpression">
405
+ ${nrOfCompletedInstances >= 2}</...>
406
+ </bpmn:multiInstanceLoopCharacteristics>
407
+
408
+ 表达式一律保留原文(含 ${}),求值由引擎在运行时做。collection 与
409
+ loopCardinality 至少其一(同时提供 collection 优先),否则部署报错。
410
+ """
411
+ camunda_attrs: Dict[str, Optional[str]] = {}
412
+ for attr_name, attr_val in el.attrib.items():
413
+ if "}" in attr_name and attr_name.rsplit("}", 1)[0].strip("{}") == CAMUNDA_NS:
414
+ camunda_attrs[attr_name.rsplit("}", 1)[-1]] = attr_val.strip()
415
+
416
+ sequential = el.get("isSequential", "false").strip().lower() == "true"
417
+ collection = camunda_attrs.get("collection")
418
+ element_variable = camunda_attrs.get("elementVariable")
419
+ cardinality: Optional[str] = None
420
+ completion: Optional[str] = None
421
+ for child in el:
422
+ if not isinstance(child.tag, str):
423
+ continue
424
+ lname = _local(child.tag)
425
+ if lname == "loopCardinality":
426
+ cardinality = (child.text or "").strip()
427
+ elif lname == "completionCondition":
428
+ completion = (child.text or "").strip()
429
+ if collection is None and cardinality is None:
430
+ raise DeploymentException(
431
+ "multiInstanceLoopCharacteristics 必须提供 camunda:collection 或 "
432
+ "loopCardinality(M4-2c),当前两者皆缺"
433
+ )
434
+ if element_variable is not None and collection is None:
435
+ raise DeploymentException(
436
+ "camunda:elementVariable 仅与 camunda:collection 配合使用"
437
+ )
438
+ return MultiInstance(
439
+ sequential=sequential,
440
+ collection_expr=collection,
441
+ loop_cardinality_expr=cardinality,
442
+ element_variable=element_variable,
443
+ completion_condition_expr=completion,
444
+ )
445
+
446
+
447
+ def _collect_camunda_extension(extension_el, out: Dict[str, Optional[str]]) -> None:
448
+ """从 extensionElements 里收集 camunda:* 子元素的文本值。
449
+
450
+ camunda:properties / camunda:inputOutput 等复杂结构 M1 不解析,只收集简单标量。
451
+ """
452
+ for child in extension_el:
453
+ lname = _local(child.tag)
454
+ # 跳过复杂容器(properties/inputOutput/connector/field/script...),M4 再支持
455
+ if lname in ("properties", "inputOutput", "connector", "field", "script"):
456
+ continue
457
+ text = (child.text or "").strip()
458
+ # 只保留有文本的简单扩展,如 <camunda:failedJobRetryTimeCycle>PT5M</...>
459
+ if text:
460
+ out[lname] = text
461
+
462
+
463
+ def _parse_timer_definition(el) -> Optional[TimerDefinition]:
464
+ """读事件元素里的 timerEventDefinition。
465
+
466
+ XML 结构:
467
+ <bpmn:intermediateCatchEvent id="wait">
468
+ <bpmn:timerEventDefinition>
469
+ <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">PT30S</...>
470
+ duration 在解析期算好 delay_seconds(运行时免重解析),非法时长部署期即报错。
471
+ """
472
+ for child in el:
473
+ if not isinstance(child.tag, str):
474
+ continue
475
+ if _local(child.tag) != "timerEventDefinition":
476
+ continue
477
+ for sub in child:
478
+ if not isinstance(sub.tag, str):
479
+ continue
480
+ kind = _TIMER_KIND.get(_local(sub.tag))
481
+ if kind is None:
482
+ continue
483
+ text = (sub.text or "").strip()
484
+ if not text:
485
+ continue
486
+ if kind == "duration":
487
+ try:
488
+ delay = parse_iso_duration(text)
489
+ except ValueError as e:
490
+ raise DeploymentException(
491
+ f"timer timeDuration 非法 {text!r}: {e}"
492
+ ) from e
493
+ return TimerDefinition(kind="duration", value=text, delay_seconds=delay)
494
+ return TimerDefinition(kind=kind, value=text)
495
+ return None
496
+
497
+
498
+ def _parse_event_definitions(el, error_by_id=None, message_by_id=None, signal_by_id=None):
499
+ """解析事件元素的全部事件定义 -> (timer, error_code, message_name, signal_name)。
500
+
501
+ - timerEventDefinition -> _parse_timer_definition(M3)
502
+ - errorEventDefinition -> errorRef 关联顶层 <error errorCode>,解析期回填 code
503
+ (兼容直接在定义上写 errorCode 的建模工具;无 code 部署即报错)
504
+ - messageEventDefinition -> messageRef 关联顶层 <message name>,回填 name
505
+ (兼容 messageEventDefinition 直接写 name)
506
+ - signalEventDefinition -> signalRef 关联顶层 <signal name>,回填 name
507
+ (兼容 signalEventDefinition 直接写 name)(M4-2d)
508
+ 事件元素至多一个事件定义(互斥校验在调用方)。
509
+ """
510
+ timer = _parse_timer_definition(el)
511
+ error_code = None
512
+ message_name = None
513
+ signal_name = None
514
+ for child in el:
515
+ if not isinstance(child.tag, str):
516
+ continue
517
+ ln = _local(child.tag)
518
+ if ln == "errorEventDefinition":
519
+ ref = child.get("errorRef")
520
+ code = None
521
+ if ref:
522
+ code = (error_by_id or {}).get(ref)
523
+ if code is None:
524
+ raise DeploymentException(
525
+ f"errorEventDefinition 引用未知 error 声明: {ref!r}"
526
+ )
527
+ code = code or child.get("errorCode")
528
+ if not code:
529
+ raise DeploymentException(
530
+ "errorEventDefinition 缺少可解析的 errorCode(无 errorRef 指向 "
531
+ "的顶层声明,也未直接声明 errorCode)"
532
+ )
533
+ error_code = code
534
+ elif ln == "messageEventDefinition":
535
+ ref = child.get("messageRef")
536
+ name = None
537
+ if ref:
538
+ name = (message_by_id or {}).get(ref)
539
+ if name is None:
540
+ raise DeploymentException(
541
+ f"messageEventDefinition 引用未知 message 声明: {ref!r}"
542
+ )
543
+ name = name or child.get("name")
544
+ if not name:
545
+ raise DeploymentException(
546
+ "messageEventDefinition 缺少可解析的 message name(无 messageRef "
547
+ "指向的顶层声明,也未直接声明 name)"
548
+ )
549
+ message_name = name
550
+ elif ln == "signalEventDefinition":
551
+ ref = child.get("signalRef")
552
+ name = None
553
+ if ref:
554
+ name = (signal_by_id or {}).get(ref)
555
+ if name is None:
556
+ raise DeploymentException(
557
+ f"signalEventDefinition 引用未知 signal 声明: {ref!r}"
558
+ )
559
+ name = name or child.get("name")
560
+ if not name:
561
+ raise DeploymentException(
562
+ "signalEventDefinition 缺少可解析的 signal name(无 signalRef "
563
+ "指向的顶层声明,也未直接声明 name)"
564
+ )
565
+ signal_name = name
566
+ return timer, error_code, message_name, signal_name
567
+
568
+
569
+ def _resolve_impl_ref(ref: Optional[str]) -> Optional[str]:
570
+ """把 camunda 实现引用转成注册名。
571
+
572
+ - delegateExpression="${myBean}" -> "myBean"
573
+ - class="com.example.MyDelegate" -> "MyDelegate"(短名;M1 用短名注册 Python 可调用)
574
+ """
575
+ if not ref:
576
+ return None
577
+ ref = ref.strip()
578
+ if ref.startswith("${") and ref.endswith("}"):
579
+ return ref[2:-1].strip()
580
+ if "." in ref:
581
+ return ref.rsplit(".", 1)[-1]
582
+ return ref
583
+
584
+
585
+ # ---------------------------------------------------------------------------
586
+ # 连线挂接与校验
587
+ # ---------------------------------------------------------------------------
588
+ def _wire_flows(proc: Process) -> None:
589
+ """校验所有 sequenceFlow 的 source/target 存在于 flow_nodes,并回填节点出/入边。"""
590
+ for flow in proc.sequence_flows.values():
591
+ src = proc.flow_nodes.get(flow.source_ref)
592
+ tgt = proc.flow_nodes.get(flow.target_ref)
593
+ if src is None:
594
+ raise DeploymentException(
595
+ f"sequenceFlow {flow.id!r} 的 sourceRef {flow.source_ref!r} 不存在于 process {proc.id!r}"
596
+ )
597
+ if tgt is None:
598
+ raise DeploymentException(
599
+ f"sequenceFlow {flow.id!r} 的 targetRef {flow.target_ref!r} 不存在于 process {proc.id!r}"
600
+ )
601
+ src.outgoing.append(flow.id)
602
+ tgt.incoming.append(flow.id)
603
+
604
+
605
+ def _attach_boundaries(proc: Process) -> None:
606
+ """把 boundaryEvent 挂到宿主活动(attachedToRef 回填 flow_nodes[host].boundary_events)。
607
+
608
+ 边界事件不入主流转(无 incoming),仅出边参与流转;触发由引擎在宿主
609
+ 等待期间调度(见 engine timer-boundary job)。BPMN 约束:宿主必须是活动
610
+ 或事件(不能是 start/end/boundary/网关),M4-1 引擎进一步限制为有等待点
611
+ 的活动(userTask / asyncBefore 节点),运行时明确报错。
612
+ """
613
+ for node in proc.flow_nodes.values():
614
+ if not isinstance(node, BoundaryEvent):
615
+ continue
616
+ if not node.attached_to:
617
+ raise DeploymentException(
618
+ f"boundaryEvent {node.id!r} 缺少 attachedToRef(未指定宿主活动)"
619
+ )
620
+ host = proc.flow_nodes.get(node.attached_to)
621
+ if host is None:
622
+ raise DeploymentException(
623
+ f"boundaryEvent {node.id!r} 的 attachedToRef {node.attached_to!r} 不存在于 process {proc.id!r}"
624
+ )
625
+ if isinstance(host, (StartEvent, EndEvent, BoundaryEvent)):
626
+ raise DeploymentException(
627
+ f"boundaryEvent {node.id!r} 不能挂在 {type(host).__name__} {host.id!r} 上"
628
+ )
629
+ host.boundary_events.append(node.id)
630
+
631
+
632
+ def _validate_process(proc: Process, require_start: bool = True) -> None:
633
+ """容器结构校验。
634
+
635
+ require_start=False 用于事件子流程等「内部没有普通 startEvent」的容器
636
+ (M4-2b 前宽容;运行时语义由引擎明确报错)。
637
+ """
638
+ if not proc.flow_nodes:
639
+ raise DeploymentException(f"process {proc.id!r} 没有任何 flowNode")
640
+
641
+ if require_start and not any(
642
+ isinstance(n, StartEvent) for n in proc.flow_nodes.values()
643
+ ):
644
+ raise DeploymentException(f"process {proc.id!r} 缺少 startEvent")
645
+
646
+ # 排他网关出边必须有 default 或条件(宽松校验:不强制,运行时无条件时随机走第一条 -> 引擎内决定)