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.
- camunda/__init__.py +10 -0
- camunda/api/__init__.py +14 -0
- camunda/api/app.py +80 -0
- camunda/api/deps.py +17 -0
- camunda/api/errors.py +84 -0
- camunda/api/pagination.py +74 -0
- camunda/api/routers/__init__.py +5 -0
- camunda/api/routers/decision.py +57 -0
- camunda/api/routers/deployment.py +139 -0
- camunda/api/routers/history.py +128 -0
- camunda/api/routers/process_definition.py +49 -0
- camunda/api/routers/process_instance.py +106 -0
- camunda/api/routers/task.py +92 -0
- camunda/api/schemas.py +200 -0
- camunda/common/__init__.py +19 -0
- camunda/common/clock.py +30 -0
- camunda/common/exceptions.py +34 -0
- camunda/common/idgen.py +23 -0
- camunda/common/timers.py +106 -0
- camunda/dmn/__init__.py +5 -0
- camunda/dmn/engine.py +219 -0
- camunda/dmn/feel.py +392 -0
- camunda/engine/__init__.py +9 -0
- camunda/engine/behavior.py +51 -0
- camunda/engine/expression.py +126 -0
- camunda/engine/process_engine.py +3237 -0
- camunda/job/__init__.py +9 -0
- camunda/job/executor.py +136 -0
- camunda/model/__init__.py +48 -0
- camunda/model/bpmn.py +326 -0
- camunda/model/dmn.py +101 -0
- camunda/model/execution.py +121 -0
- camunda/model/job.py +88 -0
- camunda/model/task.py +33 -0
- camunda/model/variable.py +35 -0
- camunda/parser/__init__.py +5 -0
- camunda/parser/bpmn_parser.py +646 -0
- camunda/parser/dmn_parser.py +225 -0
- camunda/persistence/__init__.py +21 -0
- camunda/persistence/entities.py +202 -0
- camunda/persistence/store.py +721 -0
- camunda_python-0.1.0.dist-info/METADATA +377 -0
- camunda_python-0.1.0.dist-info/RECORD +46 -0
- camunda_python-0.1.0.dist-info/WHEEL +5 -0
- camunda_python-0.1.0.dist-info/licenses/LICENSE +200 -0
- camunda_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""流程实例端点(M6-3):启动 / 查询 / 变量 / 删除。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Body, Query, Request
|
|
8
|
+
|
|
9
|
+
from camunda.api.deps import get_engine
|
|
10
|
+
from camunda.api.pagination import DEFAULT_MAX_RESULTS, paginate
|
|
11
|
+
from camunda.api.schemas import (
|
|
12
|
+
StartProcessInstanceDto,
|
|
13
|
+
from_variable_map,
|
|
14
|
+
process_instance_dto,
|
|
15
|
+
to_variable_map,
|
|
16
|
+
)
|
|
17
|
+
from camunda.common.exceptions import InvalidRequestException
|
|
18
|
+
|
|
19
|
+
router = APIRouter(tags=["process-instance"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.post("/process-instance", summary="按 key 启动流程实例")
|
|
23
|
+
def start_process_instance(
|
|
24
|
+
request: Request, body: StartProcessInstanceDto
|
|
25
|
+
) -> Dict[str, Any]:
|
|
26
|
+
engine = get_engine(request)
|
|
27
|
+
if not body.definitionKey:
|
|
28
|
+
raise InvalidRequestException(
|
|
29
|
+
"启动流程须提供 definitionKey(Camunda 的 definitionId / message 启动 M6 不支持)"
|
|
30
|
+
)
|
|
31
|
+
pi = engine.start_process_instance_by_key(
|
|
32
|
+
body.definitionKey,
|
|
33
|
+
variables=from_variable_map(body.variables),
|
|
34
|
+
business_key=body.businessKey,
|
|
35
|
+
)
|
|
36
|
+
return process_instance_dto(pi, bare=bool(body.withVariablesInReturn))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/process-instance", summary="流程实例列表(含已结束)")
|
|
40
|
+
def list_process_instances(
|
|
41
|
+
request: Request,
|
|
42
|
+
processDefinitionKey: Optional[str] = Query(default=None),
|
|
43
|
+
businessKey: Optional[str] = Query(default=None),
|
|
44
|
+
active: Optional[bool] = Query(default=None, description="true=只看运行中"),
|
|
45
|
+
bare: bool = Query(default=False, description="变量退化为裸值 map"),
|
|
46
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
47
|
+
maxResults: int = Query(
|
|
48
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
49
|
+
),
|
|
50
|
+
) -> List[Dict[str, Any]]:
|
|
51
|
+
engine = get_engine(request)
|
|
52
|
+
instances = engine.list_process_instances()
|
|
53
|
+
if processDefinitionKey is not None:
|
|
54
|
+
instances = [
|
|
55
|
+
pi for pi in instances if pi.process_definition_key == processDefinitionKey
|
|
56
|
+
]
|
|
57
|
+
if businessKey is not None:
|
|
58
|
+
instances = [pi for pi in instances if pi.business_key == businessKey]
|
|
59
|
+
if active is not None:
|
|
60
|
+
instances = [pi for pi in instances if (not pi.is_completed) == active]
|
|
61
|
+
items = [process_instance_dto(pi, bare=bare) for pi in instances]
|
|
62
|
+
return paginate(items, firstResult, maxResults)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.get("/process-instance/{instance_id}", summary="按 id 取流程实例")
|
|
66
|
+
def get_process_instance(
|
|
67
|
+
request: Request, instance_id: str, bare: bool = Query(default=False)
|
|
68
|
+
) -> Dict[str, Any]:
|
|
69
|
+
engine = get_engine(request)
|
|
70
|
+
return process_instance_dto(engine.get_process_instance(instance_id), bare=bare)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.delete("/process-instance/{instance_id}", summary="删除流程实例(历史保留)")
|
|
74
|
+
def delete_process_instance(
|
|
75
|
+
request: Request, instance_id: str, reason: Optional[str] = Query(default=None)
|
|
76
|
+
) -> Dict[str, Any]:
|
|
77
|
+
"""删除实例:清运行时态 + RU 行,HI_PROCINST 置 DELETED(对齐 Camunda 默认)。"""
|
|
78
|
+
engine = get_engine(request)
|
|
79
|
+
engine.delete_process_instance(instance_id, reason=reason)
|
|
80
|
+
return {"deleted": True, "id": instance_id, "reason": reason}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get("/process-instance/{instance_id}/variables", summary="实例变量列表")
|
|
84
|
+
def get_variables(
|
|
85
|
+
request: Request, instance_id: str, bare: bool = Query(default=False)
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
engine = get_engine(request)
|
|
88
|
+
pi = engine.get_process_instance(instance_id)
|
|
89
|
+
return to_variable_map(pi.variables, bare=bare)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.put(
|
|
93
|
+
"/process-instance/{instance_id}/variables/{name}", summary="设置单个实例变量"
|
|
94
|
+
)
|
|
95
|
+
def put_variable(
|
|
96
|
+
request: Request,
|
|
97
|
+
instance_id: str,
|
|
98
|
+
name: str,
|
|
99
|
+
# Body() 显式标注:否则 Any + 默认值会被 FastAPI 当成非 body 参数(静默收不到)
|
|
100
|
+
body: Any = Body(default=None),
|
|
101
|
+
) -> Dict[str, Any]:
|
|
102
|
+
"""设置变量。body 支持包装形态 {"value": x} 与裸值(如直接传 20000 / "abc")。"""
|
|
103
|
+
engine = get_engine(request)
|
|
104
|
+
value = from_variable_map({"v": body})["v"] if isinstance(body, dict) else body
|
|
105
|
+
engine.set_variable(instance_id, name, value)
|
|
106
|
+
return {"name": name, "value": value, "processInstanceId": instance_id}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""任务端点(M6-4):查询 / 认领 / 完成 / 变量。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Query, Request
|
|
8
|
+
|
|
9
|
+
from camunda.api.deps import get_engine
|
|
10
|
+
from camunda.api.pagination import DEFAULT_MAX_RESULTS, paginate
|
|
11
|
+
from camunda.api.schemas import (
|
|
12
|
+
CompleteTaskDto,
|
|
13
|
+
UserIdDto,
|
|
14
|
+
from_variable_map,
|
|
15
|
+
task_dto,
|
|
16
|
+
to_variable_map,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
router = APIRouter(tags=["task"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/task", summary="任务列表(活跃待办)")
|
|
23
|
+
def list_tasks(
|
|
24
|
+
request: Request,
|
|
25
|
+
processInstanceId: Optional[str] = Query(default=None),
|
|
26
|
+
assignee: Optional[str] = Query(default=None),
|
|
27
|
+
candidateUser: Optional[str] = Query(default=None),
|
|
28
|
+
unassigned: Optional[bool] = Query(default=None, description="true=只看未认领"),
|
|
29
|
+
firstResult: int = Query(default=0, ge=0, description="分页起点(0 基)"),
|
|
30
|
+
maxResults: int = Query(
|
|
31
|
+
default=DEFAULT_MAX_RESULTS, ge=1, description="分页上限(<=1000)"
|
|
32
|
+
),
|
|
33
|
+
) -> List[Dict[str, Any]]:
|
|
34
|
+
engine = get_engine(request)
|
|
35
|
+
tasks = engine.create_task_query(process_instance_id=processInstanceId)
|
|
36
|
+
if assignee is not None:
|
|
37
|
+
tasks = [t for t in tasks if t.assignee == assignee]
|
|
38
|
+
if candidateUser is not None:
|
|
39
|
+
tasks = [t for t in tasks if candidateUser in t.candidate_users]
|
|
40
|
+
if unassigned:
|
|
41
|
+
tasks = [t for t in tasks if t.assignee is None]
|
|
42
|
+
items = [task_dto(t) for t in tasks]
|
|
43
|
+
return paginate(items, firstResult, maxResults)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.get("/task/{task_id}", summary="按 id 取任务")
|
|
47
|
+
def get_task(request: Request, task_id: str) -> Dict[str, Any]:
|
|
48
|
+
engine = get_engine(request)
|
|
49
|
+
return task_dto(engine.get_task(task_id))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/task/{task_id}/claim", summary="认领任务")
|
|
53
|
+
def claim_task(request: Request, task_id: str, body: UserIdDto) -> Dict[str, Any]:
|
|
54
|
+
engine = get_engine(request)
|
|
55
|
+
if not body.userId:
|
|
56
|
+
from camunda.common.exceptions import InvalidRequestException
|
|
57
|
+
|
|
58
|
+
raise InvalidRequestException("claim 需要 userId")
|
|
59
|
+
return task_dto(engine.claim_task(task_id, body.userId))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("/task/{task_id}/unclaim", summary="取消认领")
|
|
63
|
+
def unclaim_task(request: Request, task_id: str) -> Dict[str, Any]:
|
|
64
|
+
engine = get_engine(request)
|
|
65
|
+
return task_dto(engine.unclaim_task(task_id))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.post("/task/{task_id}/assignee", summary="直接指派(不做已认领校验)")
|
|
69
|
+
def set_assignee(request: Request, task_id: str, body: UserIdDto) -> Dict[str, Any]:
|
|
70
|
+
engine = get_engine(request)
|
|
71
|
+
return task_dto(engine.set_assignee(task_id, body.userId))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.post("/task/{task_id}/complete", summary="完成任务(合并变量后推进)")
|
|
75
|
+
def complete_task(
|
|
76
|
+
request: Request, task_id: str, body: Optional[CompleteTaskDto] = None
|
|
77
|
+
) -> Dict[str, Any]:
|
|
78
|
+
engine = get_engine(request)
|
|
79
|
+
variables = from_variable_map(body.variables) if body is not None else {}
|
|
80
|
+
engine.complete_task(task_id, variables=variables)
|
|
81
|
+
return {"completed": True, "id": task_id}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get("/task/{task_id}/variables", summary="任务可见变量(= 实例变量)")
|
|
85
|
+
def get_task_variables(
|
|
86
|
+
request: Request, task_id: str, bare: bool = Query(default=False)
|
|
87
|
+
) -> Dict[str, Any]:
|
|
88
|
+
"""任务变量:本项目变量为实例级(文档化差异),故返回所属实例的变量全集。"""
|
|
89
|
+
engine = get_engine(request)
|
|
90
|
+
task = engine.get_task(task_id)
|
|
91
|
+
pi = engine.get_process_instance(task.process_instance_id)
|
|
92
|
+
return to_variable_map(pi.variables, bare=bare)
|
camunda/api/schemas.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""REST 层 DTO 与变量序列化(M6-1)。
|
|
2
|
+
|
|
3
|
+
变量形态(M6 关键兼容点,文档化差异见 docs/ARCHITECTURE.md):
|
|
4
|
+
- Camunda 7 REST 用「包装形态」:{"amount": {"value": 20000, "type": "Long"}}
|
|
5
|
+
- 本实现**入参两种都收**:包装形态与裸值 {"amount": 20000} 均可(裸值按 Python 类型推断 type)
|
|
6
|
+
- 出参默认走包装形态(对齐 Camunda),带 `?bare=true` 时退化成裸值 map(便于脚本直用)
|
|
7
|
+
|
|
8
|
+
类型推断:Python 类型 -> Camunda 类型名(String/Boolean/Integer/Long/Double/Null/Object)。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from camunda.model.execution import ProcessInstance
|
|
18
|
+
from camunda.model.task import Task
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# 变量序列化
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
def _type_of(value: Any) -> str:
|
|
24
|
+
"""Python 值 -> Camunda 变量类型名。"""
|
|
25
|
+
if value is None:
|
|
26
|
+
return "Null"
|
|
27
|
+
if isinstance(value, bool):
|
|
28
|
+
return "Boolean"
|
|
29
|
+
if isinstance(value, int):
|
|
30
|
+
return "Long"
|
|
31
|
+
if isinstance(value, float):
|
|
32
|
+
return "Double"
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
return "String"
|
|
35
|
+
return "Object" # dict / list / 其余:JSON 序列化对象
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_variable_dto(value: Any) -> Dict[str, Any]:
|
|
39
|
+
"""单变量 -> Camunda VariableValueDto。"""
|
|
40
|
+
dto: Dict[str, Any] = {"value": value, "type": _type_of(value)}
|
|
41
|
+
if dto["type"] == "Object":
|
|
42
|
+
dto["valueInfo"] = {"serializationDataFormat": "application/json"}
|
|
43
|
+
return dto
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def to_variable_map(variables: Optional[Dict[str, Any]], bare: bool = False) -> Dict[str, Any]:
|
|
47
|
+
"""变量 dict -> 响应形态(bare=True 时退化为裸值 map)。"""
|
|
48
|
+
variables = variables or {}
|
|
49
|
+
if bare:
|
|
50
|
+
return dict(variables)
|
|
51
|
+
return {k: to_variable_dto(v) for k, v in variables.items()}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_variable_map(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
55
|
+
"""请求变量 dict -> 引擎变量 dict(兼容包装形态与裸值)。
|
|
56
|
+
|
|
57
|
+
包装形态:{"amount": {"value": 20000, "type": "Long"}} -> {"amount": 20000}
|
|
58
|
+
裸值形态:{"amount": 20000} -> {"amount": 20000}
|
|
59
|
+
"""
|
|
60
|
+
if not payload:
|
|
61
|
+
return {}
|
|
62
|
+
out: Dict[str, Any] = {}
|
|
63
|
+
for name, spec in payload.items():
|
|
64
|
+
if isinstance(spec, dict) and "value" in spec:
|
|
65
|
+
out[name] = spec["value"] # type 仅文档化,引擎按 Python 原生类型处理
|
|
66
|
+
else:
|
|
67
|
+
out[name] = spec
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# 请求 DTO
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
class VariableValueDto(BaseModel):
|
|
75
|
+
"""Camunda VariableValueDto(入参;type/valueInfo 仅文档化,引擎按原生类型处理)。"""
|
|
76
|
+
|
|
77
|
+
value: Any = None
|
|
78
|
+
type: Optional[str] = None
|
|
79
|
+
valueInfo: Optional[Dict[str, Any]] = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class StartProcessInstanceDto(BaseModel):
|
|
83
|
+
"""POST /process-instance 请求体。variables 支持包装与裸值两种形态。
|
|
84
|
+
|
|
85
|
+
definitionKey 必填(Camunda 还支持 definitionId / message 启动,M6 不支持——
|
|
86
|
+
见 docs/ARCHITECTURE.md 的 M6 文档化差异)。
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
definitionKey: Optional[str] = None
|
|
90
|
+
variables: Optional[Dict[str, Any]] = None
|
|
91
|
+
businessKey: Optional[str] = None
|
|
92
|
+
withVariablesInReturn: Optional[bool] = False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class CompleteTaskDto(BaseModel):
|
|
96
|
+
"""POST /task/{id}/complete 请求体。"""
|
|
97
|
+
|
|
98
|
+
variables: Optional[Dict[str, Any]] = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class UserIdDto(BaseModel):
|
|
102
|
+
"""POST /task/{id}/claim|unclaim|assignee 请求体。"""
|
|
103
|
+
|
|
104
|
+
userId: Optional[str] = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class DeploymentDto(BaseModel):
|
|
108
|
+
"""POST /deployment/create(JSON 便捷通道;multipart 见路由说明)。"""
|
|
109
|
+
|
|
110
|
+
xml: str = Field(..., description="BPMN 2.0 XML 文本")
|
|
111
|
+
name: Optional[str] = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class EvaluateDecisionDto(BaseModel):
|
|
115
|
+
"""POST /decision-definition/key/{key}/evaluate 请求体。"""
|
|
116
|
+
|
|
117
|
+
variables: Optional[Dict[str, Any]] = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# 响应 DTO(直接构造 dict,由 FastAPI 序列化)
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
def process_instance_dto(pi: ProcessInstance, bare: bool = False) -> Dict[str, Any]:
|
|
124
|
+
"""ProcessInstance -> Camunda ProcessInstanceDto。"""
|
|
125
|
+
return {
|
|
126
|
+
"id": pi.id,
|
|
127
|
+
"definitionId": pi.process_definition_key,
|
|
128
|
+
"businessKey": pi.business_key,
|
|
129
|
+
"ended": pi.is_completed,
|
|
130
|
+
"suspended": False,
|
|
131
|
+
"variables": to_variable_map(pi.variables, bare=bare),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def task_dto(t: Task) -> Dict[str, Any]:
|
|
136
|
+
"""Task -> Camunda TaskDto。"""
|
|
137
|
+
return {
|
|
138
|
+
"id": t.id,
|
|
139
|
+
"name": t.name,
|
|
140
|
+
"assignee": t.assignee,
|
|
141
|
+
"created": t.create_time,
|
|
142
|
+
"processInstanceId": t.process_instance_id,
|
|
143
|
+
"executionId": t.execution_id,
|
|
144
|
+
"taskDefinitionKey": t.task_definition_key,
|
|
145
|
+
"candidateUsers": list(t.candidate_users),
|
|
146
|
+
"candidateGroups": list(t.candidate_groups),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def process_definition_dto(
|
|
151
|
+
key: str, name: Optional[str], version: int
|
|
152
|
+
) -> Dict[str, Any]:
|
|
153
|
+
"""流程定义 -> Camunda ProcessDefinitionDto。"""
|
|
154
|
+
return {
|
|
155
|
+
"id": f"{key}:{version}",
|
|
156
|
+
"key": key,
|
|
157
|
+
"name": name,
|
|
158
|
+
"version": version,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def activity_instance_dto(ai: Any) -> Dict[str, Any]:
|
|
163
|
+
"""ActivityInstance -> Camunda HistoricActivityInstanceDto。"""
|
|
164
|
+
return {
|
|
165
|
+
"id": ai.id,
|
|
166
|
+
"activityId": ai.activity_id,
|
|
167
|
+
"activityName": ai.activity_name,
|
|
168
|
+
"activityType": None,
|
|
169
|
+
"processInstanceId": ai.process_instance_id,
|
|
170
|
+
"executionId": ai.execution_id,
|
|
171
|
+
"startTime": ai.start_time,
|
|
172
|
+
"endTime": ai.end_time,
|
|
173
|
+
"durationInMillis": None,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def historic_task_dto(t: Task) -> Dict[str, Any]:
|
|
178
|
+
"""已归档 Task -> Camunda HistoricTaskInstanceDto。"""
|
|
179
|
+
return {
|
|
180
|
+
**task_dto(t),
|
|
181
|
+
"endTime": t.end_time,
|
|
182
|
+
"deleteReason": None,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def historic_process_instance_dto(pi: ProcessInstance) -> Dict[str, Any]:
|
|
187
|
+
"""ProcessInstance -> Camunda HistoricProcessInstanceDto。"""
|
|
188
|
+
return {
|
|
189
|
+
"id": pi.id,
|
|
190
|
+
"processDefinitionKey": pi.process_definition_key,
|
|
191
|
+
"businessKey": pi.business_key,
|
|
192
|
+
"startTime": pi.start_time,
|
|
193
|
+
"endTime": pi.end_time,
|
|
194
|
+
"state": pi.state.value if hasattr(pi.state, "value") else str(pi.state),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def decision_definition_dto(key: str, version: int, name: Optional[str]) -> Dict[str, Any]:
|
|
199
|
+
"""决策定义 -> Camunda DecisionDefinitionDto。"""
|
|
200
|
+
return {"id": f"{key}:{version}", "key": key, "name": name, "version": version}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""公共基础:异常层次、ID 生成器。"""
|
|
2
|
+
|
|
3
|
+
from camunda.common.exceptions import (
|
|
4
|
+
CamundaException,
|
|
5
|
+
NotFoundException,
|
|
6
|
+
DeploymentException,
|
|
7
|
+
ProcessInstanceException,
|
|
8
|
+
InvalidRequestException,
|
|
9
|
+
)
|
|
10
|
+
from camunda.common.idgen import IdGenerator
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CamundaException",
|
|
14
|
+
"NotFoundException",
|
|
15
|
+
"DeploymentException",
|
|
16
|
+
"ProcessInstanceException",
|
|
17
|
+
"InvalidRequestException",
|
|
18
|
+
"IdGenerator",
|
|
19
|
+
]
|
camunda/common/clock.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""可注入时钟:引擎与 JobExecutor 统一取当前时间。
|
|
2
|
+
|
|
3
|
+
M2 用 time.strftime 散落各处取字符串时间;M3 起统一走本模块:
|
|
4
|
+
- 输出为本地时区定长 ISO 字符串 "%Y-%m-%dT%H:%M:%S"(同长可字典序比较 due)
|
|
5
|
+
- 测试可用 set_clock 冻结 / 拨快时间,无需真 sleep 即可验证定时器到期语义
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
_DEFAULT = lambda: time.strftime("%Y-%m-%dT%H:%M:%S") # noqa: E731
|
|
14
|
+
_fn: Callable[[], str] = _DEFAULT
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def now() -> str:
|
|
18
|
+
return _fn()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_clock(fn: Callable[[], str]) -> None:
|
|
22
|
+
"""替换当前时间来源(测试注入 fake clock)。"""
|
|
23
|
+
global _fn
|
|
24
|
+
_fn = fn
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reset_clock() -> None:
|
|
28
|
+
"""恢复真实系统时钟。"""
|
|
29
|
+
global _fn
|
|
30
|
+
_fn = _DEFAULT
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""camunda-python 统一异常层次。
|
|
2
|
+
|
|
3
|
+
对齐 Camunda 语义:
|
|
4
|
+
- CamundaException 引擎根异常(对应 Java ProcessEngineException)
|
|
5
|
+
- NotFoundException 对象不存在(对应 ProcessEngineException 派生的 NotFound 语义)
|
|
6
|
+
- DeploymentException 部署失败(XML 解析/校验错误)
|
|
7
|
+
- ProcessInstanceException 流程实例状态非法操作
|
|
8
|
+
- InvalidRequestException 参数/调用不合法
|
|
9
|
+
- ExpressionEvaluationException 表达式求值失败(FEEL/UEL,M5)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CamundaException(Exception):
|
|
14
|
+
"""引擎根异常。"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotFoundException(CamundaException):
|
|
18
|
+
"""按 id/key 查找对象不存在。"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeploymentException(CamundaException):
|
|
22
|
+
"""BPMN 部署失败:XML 格式错误、语义校验不通过。"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProcessInstanceException(CamundaException):
|
|
26
|
+
"""流程实例上的非法状态操作(如对已结束实例 start/complete)。"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InvalidRequestException(CamundaException):
|
|
30
|
+
"""参数或调用不合法。"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExpressionEvaluationException(CamundaException):
|
|
34
|
+
"""表达式求值失败:FEEL 语法不支持 / 类型不匹配 / 未定义变量路径(M5)。"""
|
camunda/common/idgen.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""ID 生成器:对齐 Camunda 实体主键语义(UUID 版本)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
# 可注入的 ID 生成函数(测试可替换为序列号以便断言)。
|
|
9
|
+
_generator: Callable[[], str] = lambda: uuid.uuid4().hex
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def set_id_generator(fn: Callable[[], str]) -> None:
|
|
13
|
+
"""替换全局 ID 生成策略(主要用于测试确定性)。"""
|
|
14
|
+
global _generator
|
|
15
|
+
_generator = fn
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class IdGenerator:
|
|
19
|
+
"""每次调用生成一个新的实体 ID。"""
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def next_id() -> str:
|
|
23
|
+
return _generator()
|
camunda/common/timers.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""ISO-8601 时长 / 周期与 cron 触发时间计算(M3)。
|
|
2
|
+
|
|
3
|
+
BPMN timerEventDefinition 取值对齐 Camunda 7 语义:
|
|
4
|
+
- timeDuration : ISO-8601 时长(如 PT30S / PT1H / P1D)→ 相对 duedate
|
|
5
|
+
- timeDate : 绝对时间点(ISO-8601,可带 Z/时区偏移)→ 一次性 duedate
|
|
6
|
+
- timeCycle : cron 表达式(quartz 风格,croniter 求值)或 ISO-8601 重复
|
|
7
|
+
周期 R[n]/PT..(如 R3/PT10S = 每 10 秒一次共 3 次)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
# 引擎统一时间格式(与 clock.now() 一致;定长可字典序比较)
|
|
17
|
+
ISO_FORMAT = "%Y-%m-%dT%H:%M:%S"
|
|
18
|
+
|
|
19
|
+
# ISO-8601 时长:P[nW] | P[nD][T[nH][nM][n[.f]S]]
|
|
20
|
+
_ISO_DURATION_RE = re.compile(
|
|
21
|
+
r"^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$"
|
|
22
|
+
)
|
|
23
|
+
# ISO-8601 重复周期:R[n]/<时长>(带起点形式 R/<date>/<dur> 暂不支持)
|
|
24
|
+
_ISO_REPEAT_RE = re.compile(r"^R(\d*)/(.+)$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_iso(dt: datetime) -> str:
|
|
28
|
+
"""datetime -> 本地定长 ISO 字符串。"""
|
|
29
|
+
return dt.strftime(ISO_FORMAT)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_iso(text: str) -> datetime:
|
|
33
|
+
"""定长 ISO 字符串 -> datetime。"""
|
|
34
|
+
return datetime.strptime(text, ISO_FORMAT)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_trigger_date(text: str) -> str:
|
|
38
|
+
"""timeDate 文本 -> 本地定长 ISO 字符串(对齐引擎时钟时区)。"""
|
|
39
|
+
raw = text.strip()
|
|
40
|
+
if raw.endswith("Z"):
|
|
41
|
+
raw = raw[:-1] + "+00:00"
|
|
42
|
+
try:
|
|
43
|
+
dt = datetime.fromisoformat(raw)
|
|
44
|
+
except ValueError:
|
|
45
|
+
# 退化为引擎定长格式直解(无时区本地时间)
|
|
46
|
+
return format_iso(datetime.strptime(raw, ISO_FORMAT))
|
|
47
|
+
if dt.tzinfo is not None:
|
|
48
|
+
dt = dt.astimezone() # 转本地时区
|
|
49
|
+
return format_iso(dt)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_iso_duration(text: str) -> float:
|
|
53
|
+
"""ISO-8601 时长文本 -> 秒(浮点,支持小数秒)。
|
|
54
|
+
|
|
55
|
+
例:PT30S -> 30;PT1H30M -> 5400;P1D -> 86400;P1W -> 604800。
|
|
56
|
+
非法输入抛 ValueError(部署期即暴露配置错误)。
|
|
57
|
+
"""
|
|
58
|
+
t = text.strip()
|
|
59
|
+
sign = -1.0 if t.startswith("-") else 1.0
|
|
60
|
+
t = t.lstrip("+-")
|
|
61
|
+
m = _ISO_DURATION_RE.fullmatch(t)
|
|
62
|
+
if not m or not any(m.groups()):
|
|
63
|
+
raise ValueError(f"非法 ISO-8601 时长: {text!r}")
|
|
64
|
+
weeks, days, hours, minutes, seconds = m.groups()
|
|
65
|
+
total = (
|
|
66
|
+
(int(weeks) if weeks else 0) * 604800
|
|
67
|
+
+ (int(days) if days else 0) * 86400
|
|
68
|
+
+ (int(hours) if hours else 0) * 3600
|
|
69
|
+
+ (int(minutes) if minutes else 0) * 60
|
|
70
|
+
+ (float(seconds) if seconds else 0.0)
|
|
71
|
+
)
|
|
72
|
+
return sign * total
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_iso_repeat(text: str) -> Optional[Dict[str, Any]]:
|
|
76
|
+
"""ISO-8601 重复周期 R[n]/PT.. -> repeat 字典;非该格式返回 None。
|
|
77
|
+
|
|
78
|
+
repeat = {"kind": "interval", "seconds": float, "count": int | None}
|
|
79
|
+
count=None 表示无限重复。
|
|
80
|
+
"""
|
|
81
|
+
m = _ISO_REPEAT_RE.fullmatch(text.strip())
|
|
82
|
+
if not m:
|
|
83
|
+
return None
|
|
84
|
+
count = int(m.group(1)) if m.group(1) else None
|
|
85
|
+
seconds = parse_iso_duration(m.group(2))
|
|
86
|
+
return {"kind": "interval", "seconds": seconds, "count": count}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def next_trigger(repeat: Dict[str, Any], after: datetime) -> datetime:
|
|
90
|
+
"""计算 timerCycle 在 after 之后的下一触发时刻。
|
|
91
|
+
|
|
92
|
+
repeat:
|
|
93
|
+
- {"kind": "interval", "seconds": s, "count": n|None} 间隔重复
|
|
94
|
+
- {"kind": "cron", "expr": "..."} quartz/cron 表达式
|
|
95
|
+
"""
|
|
96
|
+
if repeat["kind"] == "interval":
|
|
97
|
+
return after + timedelta(seconds=repeat["seconds"])
|
|
98
|
+
if repeat["kind"] == "cron":
|
|
99
|
+
try:
|
|
100
|
+
from croniter import croniter
|
|
101
|
+
except ImportError as e: # 依赖缺失时给出可操作提示
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
"timerCycle 使用 cron 表达式需要安装依赖 croniter"
|
|
104
|
+
) from e
|
|
105
|
+
return croniter(repeat["expr"], after).get_next(datetime)
|
|
106
|
+
raise ValueError(f"不支持的周期类型: {repeat!r}")
|