autoflow-sdk 1.0.0__tar.gz
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.
- autoflow_sdk-1.0.0/PKG-INFO +146 -0
- autoflow_sdk-1.0.0/README.md +131 -0
- autoflow_sdk-1.0.0/autoflow_sdk/__init__.py +14 -0
- autoflow_sdk-1.0.0/autoflow_sdk/callback.py +179 -0
- autoflow_sdk-1.0.0/autoflow_sdk/context.py +153 -0
- autoflow_sdk-1.0.0/autoflow_sdk/http.py +85 -0
- autoflow_sdk-1.0.0/autoflow_sdk/logger.py +19 -0
- autoflow_sdk-1.0.0/autoflow_sdk/models.py +65 -0
- autoflow_sdk-1.0.0/autoflow_sdk/result.py +31 -0
- autoflow_sdk-1.0.0/autoflow_sdk.egg-info/PKG-INFO +146 -0
- autoflow_sdk-1.0.0/autoflow_sdk.egg-info/SOURCES.txt +21 -0
- autoflow_sdk-1.0.0/autoflow_sdk.egg-info/dependency_links.txt +1 -0
- autoflow_sdk-1.0.0/autoflow_sdk.egg-info/requires.txt +8 -0
- autoflow_sdk-1.0.0/autoflow_sdk.egg-info/top_level.txt +1 -0
- autoflow_sdk-1.0.0/pyproject.toml +25 -0
- autoflow_sdk-1.0.0/setup.cfg +4 -0
- autoflow_sdk-1.0.0/setup.py +8 -0
- autoflow_sdk-1.0.0/tests/test_callback.py +155 -0
- autoflow_sdk-1.0.0/tests/test_context.py +251 -0
- autoflow_sdk-1.0.0/tests/test_http.py +91 -0
- autoflow_sdk-1.0.0/tests/test_logger.py +38 -0
- autoflow_sdk-1.0.0/tests/test_models.py +76 -0
- autoflow_sdk-1.0.0/tests/test_result.py +65 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: autoflow-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AutoFlow Python SDK — base utilities for task execution
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.24.0
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
14
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# autoflow-sdk — AutoCodeFlow Python SDK
|
|
17
|
+
|
|
18
|
+
AutoCodeFlow 任务执行器(executor-python)侧的 Python SDK:提供任务上下文
|
|
19
|
+
(`TaskContext`)、日志(`ctx.log`)、Admin API 回调客户端
|
|
20
|
+
(`CallbackClient` + `ctx.report_success()` / `ctx.report_failure()`)与
|
|
21
|
+
通用 HTTP 客户端(`HttpClient` / `AsyncHttpClient`,基于 httpx)。回调契约
|
|
22
|
+
与 Node.js SDK [`@autocodeflow/sdk`](../autocodeflow-node-sdk/README.md) 完全
|
|
23
|
+
对齐(第九轮),双端共用 `CallbackItemDto` 字段。平台侧完整说明见
|
|
24
|
+
[docs/sdk-guide.md](../../docs/sdk-guide.md)。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install autoflow-sdk # 公共 PyPI(发布后)
|
|
30
|
+
# 内网私有 registry(apps/registry-pypi):
|
|
31
|
+
pip install --index-url http://<registry-pypi-host>/simple autoflow-sdk
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
要求 Python ≥ 3.9;运行时依赖 `httpx`、`pyyaml`、`pydantic>=2`
|
|
35
|
+
(`autoflow_sdk.models` 的协议模型);开发/测试依赖另需
|
|
36
|
+
`pytest`、`pytest-asyncio`、`respx`(见 `[project.optional-dependencies].dev`)。
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
任务脚本由执行器以子进程运行,环境变量注入见下表。
|
|
41
|
+
`TaskContext.from_env()` 读取 `EXECUTION_ID` / `TASK_ID` / `TASK_NAME` 与
|
|
42
|
+
全部 `AUTOFLOW_*` 触发参数(回调凭证三件套除外——它们暴露为专用字段,
|
|
43
|
+
绝不混入 `ctx.params`):
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
# tasks/fetch_data.py
|
|
47
|
+
from autoflow_sdk import TaskContext
|
|
48
|
+
|
|
49
|
+
ctx = TaskContext.from_env()
|
|
50
|
+
ctx.log.info(f"task {ctx.task_id} started, execution={ctx.execution_id}")
|
|
51
|
+
|
|
52
|
+
source_url = ctx.get_param("source_url") # AUTOFLOW_SOURCE_URL
|
|
53
|
+
rows = do_work(source_url)
|
|
54
|
+
|
|
55
|
+
# 主动回调 Admin API(N23 per-execution token)。旧版执行器不注入凭证,
|
|
56
|
+
# 此时 ctx.callback.enabled 为 False,结果仍由执行器统一上报。
|
|
57
|
+
if ctx.callback.enabled:
|
|
58
|
+
ctx.report_success(summary=f"{rows} rows written", duration_ms=1200)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
失败上报(`error` 映射为 `errorMessage` 并截断至 4 KB;`failure_reason`
|
|
62
|
+
取 admin-api 的 `ExecutionFailureReason` 枚举,默认 `script_error`,非法值
|
|
63
|
+
抛 `ValueError`):
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
try:
|
|
67
|
+
rows = do_work(source_url)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
if ctx.callback.enabled:
|
|
70
|
+
ctx.report_failure(e, failure_reason="script_error")
|
|
71
|
+
raise
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 底层回调客户端
|
|
75
|
+
|
|
76
|
+
批量/自定义字段时直接使用 `ctx.callback.report()`,
|
|
77
|
+
`executionId` / `executorAddress` 自动补齐(N27),显式书写的值不会被覆盖:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
ctx.callback.report([
|
|
81
|
+
{"status": "success", "durationMs": 800},
|
|
82
|
+
{"status": "success", "durationMs": 900, "logs": "checkpoint 2"},
|
|
83
|
+
])
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
凭证缺失时任何上报调用抛 `CallbackDisabledError` 并指明缺失变量
|
|
87
|
+
(fail-closed,与 Node `HttpClient.disabledReason` 语义对齐)。
|
|
88
|
+
|
|
89
|
+
### HttpClient 直用
|
|
90
|
+
|
|
91
|
+
通用 HTTP 客户端(非回调专用)可独立构造:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from autoflow_sdk import HttpClient, AsyncHttpClient
|
|
95
|
+
|
|
96
|
+
http = HttpClient(base_url="http://admin-api:3105", timeout=30.0,
|
|
97
|
+
headers={"Authorization": f"Bearer {token}"})
|
|
98
|
+
resp = http.get("/api/tasks") # 返回 httpx.Response
|
|
99
|
+
|
|
100
|
+
async with AsyncHttpClient(base_url=...) as ahttp:
|
|
101
|
+
resp = await ahttp.post("/api/x", json={"a": 1})
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 执行器注入的环境变量
|
|
105
|
+
|
|
106
|
+
与 [docs/sdk-guide.md](../../docs/sdk-guide.md) 的注入表逐字一致:
|
|
107
|
+
|
|
108
|
+
| 变量名 | 说明 | 示例值 |
|
|
109
|
+
|--------|------|--------|
|
|
110
|
+
| `TASK_ID` | 当前任务的唯一标识 | `task_abc123` |
|
|
111
|
+
| `TASK_NAME` | 当前任务名称 | `fetch_data` |
|
|
112
|
+
| `EXECUTION_ID` | 本次执行记录的唯一标识 | `exec_xyz789` |
|
|
113
|
+
| `AUTOFLOW_<KEY>` | 触发参数,按参数名转大写后注入 | `AUTOFLOW_SOURCE_URL=https://api.example.com` |
|
|
114
|
+
| `AUTOFLOW_ADMIN_API_URL` | Admin API 基地址(非机密路由信息,N23 起注入) | `AUTOFLOW_ADMIN_API_URL=http://admin-api:3105` |
|
|
115
|
+
| `AUTOFLOW_CALLBACK_TOKEN` | 本次执行的一次性回调 token(`v1.` HMAC,绑定 executionId、随 TTL 过期,N23 起注入) | `AUTOFLOW_CALLBACK_TOKEN=v1.<uuid>.<exp>.<hmac>` |
|
|
116
|
+
| `AUTOFLOW_EXECUTOR_ADDRESS` | 当前执行器注册地址(非机密路由信息,N27 起注入;SDK 经 `ctx.executorAddress`(Node)/ `ctx.executor_address`(Python)暴露并自动填入回调请求) | `AUTOFLOW_EXECUTOR_ADDRESS=executor-node:8002` |
|
|
117
|
+
|
|
118
|
+
## 回调契约(与 Node SDK 一致)
|
|
119
|
+
|
|
120
|
+
`POST {AUTOFLOW_ADMIN_API_URL}/api/executions/callback`,请求体为
|
|
121
|
+
`CallbackItemDto[]`:`executionId` / `status: success|failed` /
|
|
122
|
+
`executorAddress` / `logs` / `errorMessage` / `failureReason` /
|
|
123
|
+
`durationMs`。per-execution token 仅授权本 `executionId` 的回调,越权或
|
|
124
|
+
过期一律 401(fail-closed);执行器共享 token 绝不进入任务子进程(SEC-01)。
|
|
125
|
+
|
|
126
|
+
## 版本与发布
|
|
127
|
+
|
|
128
|
+
- 版本策略:与 `@autocodeflow/sdk`(npm)、`autocodeflow-mcp-server` 走
|
|
129
|
+
**lockstep** 单版本线,当前 `1.0.0`。
|
|
130
|
+
- 发布管道:[.github/workflows/release.yml](../../.github/workflows/release.yml)。
|
|
131
|
+
push tag `vX.Y.Z` 触发:版本一致性守卫(tag 必须等于本包
|
|
132
|
+
`pyproject.toml` version 与 `autoflow_sdk.__version__`,不一致直接
|
|
133
|
+
fail)→ `publish-pypi` job(python 3.12,`python -m build` +
|
|
134
|
+
`pypa/gh-action-pypi-publish`)。
|
|
135
|
+
- 凭证:GitHub secret `PYPI_API_TOKEN`(pypi.org API token);如改用
|
|
136
|
+
PyPI Trusted Publishing(OIDC)则删 workflow 中 `password` 行。
|
|
137
|
+
- 元数据单一来源:`pyproject.toml`(`setup.py` 仅留 `setup()`)。
|
|
138
|
+
- 本地演练(不真发布):
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
cd packages/autoflow-sdk
|
|
142
|
+
python -m pip install build && python -m build --wheel # 产物在 dist/(已 gitignore)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- 发布流程与矩阵说明见
|
|
146
|
+
[docs/sdk-guide.md「SDK 矩阵」](../../docs/sdk-guide.md)。
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# autoflow-sdk — AutoCodeFlow Python SDK
|
|
2
|
+
|
|
3
|
+
AutoCodeFlow 任务执行器(executor-python)侧的 Python SDK:提供任务上下文
|
|
4
|
+
(`TaskContext`)、日志(`ctx.log`)、Admin API 回调客户端
|
|
5
|
+
(`CallbackClient` + `ctx.report_success()` / `ctx.report_failure()`)与
|
|
6
|
+
通用 HTTP 客户端(`HttpClient` / `AsyncHttpClient`,基于 httpx)。回调契约
|
|
7
|
+
与 Node.js SDK [`@autocodeflow/sdk`](../autocodeflow-node-sdk/README.md) 完全
|
|
8
|
+
对齐(第九轮),双端共用 `CallbackItemDto` 字段。平台侧完整说明见
|
|
9
|
+
[docs/sdk-guide.md](../../docs/sdk-guide.md)。
|
|
10
|
+
|
|
11
|
+
## 安装
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install autoflow-sdk # 公共 PyPI(发布后)
|
|
15
|
+
# 内网私有 registry(apps/registry-pypi):
|
|
16
|
+
pip install --index-url http://<registry-pypi-host>/simple autoflow-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
要求 Python ≥ 3.9;运行时依赖 `httpx`、`pyyaml`、`pydantic>=2`
|
|
20
|
+
(`autoflow_sdk.models` 的协议模型);开发/测试依赖另需
|
|
21
|
+
`pytest`、`pytest-asyncio`、`respx`(见 `[project.optional-dependencies].dev`)。
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
任务脚本由执行器以子进程运行,环境变量注入见下表。
|
|
26
|
+
`TaskContext.from_env()` 读取 `EXECUTION_ID` / `TASK_ID` / `TASK_NAME` 与
|
|
27
|
+
全部 `AUTOFLOW_*` 触发参数(回调凭证三件套除外——它们暴露为专用字段,
|
|
28
|
+
绝不混入 `ctx.params`):
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
# tasks/fetch_data.py
|
|
32
|
+
from autoflow_sdk import TaskContext
|
|
33
|
+
|
|
34
|
+
ctx = TaskContext.from_env()
|
|
35
|
+
ctx.log.info(f"task {ctx.task_id} started, execution={ctx.execution_id}")
|
|
36
|
+
|
|
37
|
+
source_url = ctx.get_param("source_url") # AUTOFLOW_SOURCE_URL
|
|
38
|
+
rows = do_work(source_url)
|
|
39
|
+
|
|
40
|
+
# 主动回调 Admin API(N23 per-execution token)。旧版执行器不注入凭证,
|
|
41
|
+
# 此时 ctx.callback.enabled 为 False,结果仍由执行器统一上报。
|
|
42
|
+
if ctx.callback.enabled:
|
|
43
|
+
ctx.report_success(summary=f"{rows} rows written", duration_ms=1200)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
失败上报(`error` 映射为 `errorMessage` 并截断至 4 KB;`failure_reason`
|
|
47
|
+
取 admin-api 的 `ExecutionFailureReason` 枚举,默认 `script_error`,非法值
|
|
48
|
+
抛 `ValueError`):
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
try:
|
|
52
|
+
rows = do_work(source_url)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
if ctx.callback.enabled:
|
|
55
|
+
ctx.report_failure(e, failure_reason="script_error")
|
|
56
|
+
raise
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 底层回调客户端
|
|
60
|
+
|
|
61
|
+
批量/自定义字段时直接使用 `ctx.callback.report()`,
|
|
62
|
+
`executionId` / `executorAddress` 自动补齐(N27),显式书写的值不会被覆盖:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
ctx.callback.report([
|
|
66
|
+
{"status": "success", "durationMs": 800},
|
|
67
|
+
{"status": "success", "durationMs": 900, "logs": "checkpoint 2"},
|
|
68
|
+
])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
凭证缺失时任何上报调用抛 `CallbackDisabledError` 并指明缺失变量
|
|
72
|
+
(fail-closed,与 Node `HttpClient.disabledReason` 语义对齐)。
|
|
73
|
+
|
|
74
|
+
### HttpClient 直用
|
|
75
|
+
|
|
76
|
+
通用 HTTP 客户端(非回调专用)可独立构造:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from autoflow_sdk import HttpClient, AsyncHttpClient
|
|
80
|
+
|
|
81
|
+
http = HttpClient(base_url="http://admin-api:3105", timeout=30.0,
|
|
82
|
+
headers={"Authorization": f"Bearer {token}"})
|
|
83
|
+
resp = http.get("/api/tasks") # 返回 httpx.Response
|
|
84
|
+
|
|
85
|
+
async with AsyncHttpClient(base_url=...) as ahttp:
|
|
86
|
+
resp = await ahttp.post("/api/x", json={"a": 1})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 执行器注入的环境变量
|
|
90
|
+
|
|
91
|
+
与 [docs/sdk-guide.md](../../docs/sdk-guide.md) 的注入表逐字一致:
|
|
92
|
+
|
|
93
|
+
| 变量名 | 说明 | 示例值 |
|
|
94
|
+
|--------|------|--------|
|
|
95
|
+
| `TASK_ID` | 当前任务的唯一标识 | `task_abc123` |
|
|
96
|
+
| `TASK_NAME` | 当前任务名称 | `fetch_data` |
|
|
97
|
+
| `EXECUTION_ID` | 本次执行记录的唯一标识 | `exec_xyz789` |
|
|
98
|
+
| `AUTOFLOW_<KEY>` | 触发参数,按参数名转大写后注入 | `AUTOFLOW_SOURCE_URL=https://api.example.com` |
|
|
99
|
+
| `AUTOFLOW_ADMIN_API_URL` | Admin API 基地址(非机密路由信息,N23 起注入) | `AUTOFLOW_ADMIN_API_URL=http://admin-api:3105` |
|
|
100
|
+
| `AUTOFLOW_CALLBACK_TOKEN` | 本次执行的一次性回调 token(`v1.` HMAC,绑定 executionId、随 TTL 过期,N23 起注入) | `AUTOFLOW_CALLBACK_TOKEN=v1.<uuid>.<exp>.<hmac>` |
|
|
101
|
+
| `AUTOFLOW_EXECUTOR_ADDRESS` | 当前执行器注册地址(非机密路由信息,N27 起注入;SDK 经 `ctx.executorAddress`(Node)/ `ctx.executor_address`(Python)暴露并自动填入回调请求) | `AUTOFLOW_EXECUTOR_ADDRESS=executor-node:8002` |
|
|
102
|
+
|
|
103
|
+
## 回调契约(与 Node SDK 一致)
|
|
104
|
+
|
|
105
|
+
`POST {AUTOFLOW_ADMIN_API_URL}/api/executions/callback`,请求体为
|
|
106
|
+
`CallbackItemDto[]`:`executionId` / `status: success|failed` /
|
|
107
|
+
`executorAddress` / `logs` / `errorMessage` / `failureReason` /
|
|
108
|
+
`durationMs`。per-execution token 仅授权本 `executionId` 的回调,越权或
|
|
109
|
+
过期一律 401(fail-closed);执行器共享 token 绝不进入任务子进程(SEC-01)。
|
|
110
|
+
|
|
111
|
+
## 版本与发布
|
|
112
|
+
|
|
113
|
+
- 版本策略:与 `@autocodeflow/sdk`(npm)、`autocodeflow-mcp-server` 走
|
|
114
|
+
**lockstep** 单版本线,当前 `1.0.0`。
|
|
115
|
+
- 发布管道:[.github/workflows/release.yml](../../.github/workflows/release.yml)。
|
|
116
|
+
push tag `vX.Y.Z` 触发:版本一致性守卫(tag 必须等于本包
|
|
117
|
+
`pyproject.toml` version 与 `autoflow_sdk.__version__`,不一致直接
|
|
118
|
+
fail)→ `publish-pypi` job(python 3.12,`python -m build` +
|
|
119
|
+
`pypa/gh-action-pypi-publish`)。
|
|
120
|
+
- 凭证:GitHub secret `PYPI_API_TOKEN`(pypi.org API token);如改用
|
|
121
|
+
PyPI Trusted Publishing(OIDC)则删 workflow 中 `password` 行。
|
|
122
|
+
- 元数据单一来源:`pyproject.toml`(`setup.py` 仅留 `setup()`)。
|
|
123
|
+
- 本地演练(不真发布):
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cd packages/autoflow-sdk
|
|
127
|
+
python -m pip install build && python -m build --wheel # 产物在 dist/(已 gitignore)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- 发布流程与矩阵说明见
|
|
131
|
+
[docs/sdk-guide.md「SDK 矩阵」](../../docs/sdk-guide.md)。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""AutoFlow SDK — Python base utilities."""
|
|
2
|
+
from .context import TaskContext
|
|
3
|
+
from .logger import get_logger
|
|
4
|
+
from .http import HttpClient, AsyncHttpClient
|
|
5
|
+
from .callback import CallbackClient, CallbackDisabledError
|
|
6
|
+
from .result import TaskResult
|
|
7
|
+
from .models import ExecuteRequest, ExecuteResult, TaskConfig
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
__all__ = [
|
|
11
|
+
"TaskContext", "get_logger", "HttpClient", "AsyncHttpClient", "TaskResult",
|
|
12
|
+
"ExecuteRequest", "ExecuteResult", "TaskConfig",
|
|
13
|
+
"CallbackClient", "CallbackDisabledError",
|
|
14
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Per-execution Admin API callback client (N23/N27 parity with node SDK).
|
|
2
|
+
|
|
3
|
+
Since round 7/8 the executors inject three variables into task subprocesses:
|
|
4
|
+
|
|
5
|
+
- ``AUTOFLOW_CALLBACK_TOKEN`` — a one-shot ``v1.<executionId>.<exp>.<hmac>``
|
|
6
|
+
token bound to THIS execution (never the executor shared token, SEC-01);
|
|
7
|
+
- ``AUTOFLOW_ADMIN_API_URL`` — Admin API base URL (non-secret routing info);
|
|
8
|
+
- ``AUTOFLOW_EXECUTOR_ADDRESS`` — the address this executor registered with
|
|
9
|
+
(N27), stamped onto callback items automatically.
|
|
10
|
+
|
|
11
|
+
Task code uses them via ``TaskContext``::
|
|
12
|
+
|
|
13
|
+
ctx = TaskContext.from_env()
|
|
14
|
+
if ctx.callback.enabled:
|
|
15
|
+
ctx.report_success(summary="3 rows written")
|
|
16
|
+
# or, on failure:
|
|
17
|
+
ctx.report_failure(ValueError("upstream 503"))
|
|
18
|
+
|
|
19
|
+
The client is ENABLED only when all three credentials are present. When
|
|
20
|
+
disabled, construction still succeeds (so ``ctx.callback.enabled`` can be
|
|
21
|
+
checked), but any report attempt raises :class:`CallbackDisabledError`
|
|
22
|
+
naming the missing variables — the same contract as the node SDK's
|
|
23
|
+
``HttpClient.disabledReason``.
|
|
24
|
+
|
|
25
|
+
Payload shape follows admin-api's ``CallbackItemDto``
|
|
26
|
+
(apps/admin-api/src/modules/task/dto/execution-callback.dto.ts):
|
|
27
|
+
``POST {admin_api_url}/api/executions/callback`` with
|
|
28
|
+
``Authorization: Bearer <token>`` and a JSON array body of
|
|
29
|
+
``{executionId, status, executorAddress, logs?, errorMessage?,
|
|
30
|
+
failureReason?, durationMs?}`` items.
|
|
31
|
+
"""
|
|
32
|
+
from typing import Any, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
# CallbackItemDto field constraints (keep in sync with the admin DTO).
|
|
37
|
+
ERROR_MESSAGE_MAX_LENGTH = 4096
|
|
38
|
+
LOGS_MAX_LENGTH = 512_000
|
|
39
|
+
|
|
40
|
+
# ExecutionFailureReason enum values accepted by the DTO validation.
|
|
41
|
+
VALID_FAILURE_REASONS = frozenset({
|
|
42
|
+
"package_fetch_failed",
|
|
43
|
+
"script_error",
|
|
44
|
+
"timeout",
|
|
45
|
+
"executor_offline",
|
|
46
|
+
"executor_restart",
|
|
47
|
+
"killed",
|
|
48
|
+
"unknown",
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CallbackDisabledError(RuntimeError):
|
|
53
|
+
"""Raised when a callback is attempted without full credentials."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CallbackClient:
|
|
57
|
+
"""Synchronous client for ``POST /api/executions/callback``."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
admin_api_url: Optional[str] = None,
|
|
62
|
+
token: Optional[str] = None,
|
|
63
|
+
executor_address: Optional[str] = None,
|
|
64
|
+
execution_id: Optional[str] = None,
|
|
65
|
+
timeout: float = 10.0,
|
|
66
|
+
) -> None:
|
|
67
|
+
self.admin_api_url = (admin_api_url or "").rstrip("/")
|
|
68
|
+
self.token = token or ""
|
|
69
|
+
self.executor_address = executor_address or ""
|
|
70
|
+
self.execution_id = execution_id or ""
|
|
71
|
+
self.timeout = timeout
|
|
72
|
+
|
|
73
|
+
missing = []
|
|
74
|
+
if not self.admin_api_url:
|
|
75
|
+
missing.append("AUTOFLOW_ADMIN_API_URL")
|
|
76
|
+
if not self.token:
|
|
77
|
+
missing.append("AUTOFLOW_CALLBACK_TOKEN")
|
|
78
|
+
if not self.executor_address:
|
|
79
|
+
missing.append("AUTOFLOW_EXECUTOR_ADDRESS")
|
|
80
|
+
#: Whether all three callback credentials are present.
|
|
81
|
+
self.enabled = not missing
|
|
82
|
+
#: Reason for being disabled (populated only when ``enabled`` is False).
|
|
83
|
+
self.disabled_reason: Optional[str] = None
|
|
84
|
+
if not self.enabled:
|
|
85
|
+
self.disabled_reason = (
|
|
86
|
+
"CallbackClient is disabled: Admin API callback credentials are "
|
|
87
|
+
"missing (" + ", ".join(missing) + " were not present in the "
|
|
88
|
+
"environment; older executors never inject them, see SEC-01/N23). "
|
|
89
|
+
"Provide them via TaskContext(callback_token=..., admin_api_url=..., "
|
|
90
|
+
"executor_address=...) if callbacks are required."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------ url
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def callback_url(self) -> str:
|
|
97
|
+
"""Full callback endpoint URL; tolerates a base that already ends in /api."""
|
|
98
|
+
if self.admin_api_url.endswith("/api"):
|
|
99
|
+
return f"{self.admin_api_url}/executions/callback"
|
|
100
|
+
return f"{self.admin_api_url}/api/executions/callback"
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ core
|
|
103
|
+
|
|
104
|
+
def report(self, items: List[Dict[str, Any]]) -> Any:
|
|
105
|
+
"""POST a batch of CallbackItemDto dicts. Returns the parsed response.
|
|
106
|
+
|
|
107
|
+
Raises CallbackDisabledError without credentials, httpx.HTTPStatusError
|
|
108
|
+
on a non-2xx answer from the Admin API.
|
|
109
|
+
"""
|
|
110
|
+
if not self.enabled:
|
|
111
|
+
raise CallbackDisabledError(self.disabled_reason)
|
|
112
|
+
payload = [self._with_defaults(item) for item in items]
|
|
113
|
+
with httpx.Client(timeout=self.timeout, trust_env=False) as client:
|
|
114
|
+
resp = client.post(
|
|
115
|
+
self.callback_url,
|
|
116
|
+
json=payload,
|
|
117
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
118
|
+
)
|
|
119
|
+
resp.raise_for_status()
|
|
120
|
+
return resp.json()
|
|
121
|
+
|
|
122
|
+
def _with_defaults(self, item: Dict[str, Any]) -> Dict[str, Any]:
|
|
123
|
+
"""Fill executionId / executorAddress on items that omit them (N27)."""
|
|
124
|
+
filled = dict(item)
|
|
125
|
+
if not filled.get("executionId"):
|
|
126
|
+
filled["executionId"] = self.execution_id
|
|
127
|
+
if not filled.get("executorAddress"):
|
|
128
|
+
filled["executorAddress"] = self.executor_address
|
|
129
|
+
return filled
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------- convenience
|
|
132
|
+
|
|
133
|
+
def report_success(
|
|
134
|
+
self,
|
|
135
|
+
summary: Optional[str] = None,
|
|
136
|
+
duration_ms: Optional[int] = None,
|
|
137
|
+
) -> Any:
|
|
138
|
+
"""Report status=success for this client's execution."""
|
|
139
|
+
item: Dict[str, Any] = {
|
|
140
|
+
"executionId": self.execution_id,
|
|
141
|
+
"status": "success",
|
|
142
|
+
"executorAddress": self.executor_address,
|
|
143
|
+
}
|
|
144
|
+
if summary:
|
|
145
|
+
item["logs"] = summary[:LOGS_MAX_LENGTH]
|
|
146
|
+
if duration_ms is not None:
|
|
147
|
+
item["durationMs"] = duration_ms
|
|
148
|
+
return self.report([item])
|
|
149
|
+
|
|
150
|
+
def report_failure(
|
|
151
|
+
self,
|
|
152
|
+
error: Any,
|
|
153
|
+
summary: Optional[str] = None,
|
|
154
|
+
duration_ms: Optional[int] = None,
|
|
155
|
+
failure_reason: str = "script_error",
|
|
156
|
+
) -> Any:
|
|
157
|
+
"""Report status=failed for this client's execution.
|
|
158
|
+
|
|
159
|
+
``error`` (any object) is stringified into ``errorMessage`` and
|
|
160
|
+
truncated to the DTO's 4 KB cap; ``failure_reason`` must be one of
|
|
161
|
+
admin-api's ExecutionFailureReason values (default ``script_error``).
|
|
162
|
+
"""
|
|
163
|
+
if failure_reason not in VALID_FAILURE_REASONS:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"invalid failure_reason {failure_reason!r}; expected one of "
|
|
166
|
+
+ ", ".join(sorted(VALID_FAILURE_REASONS))
|
|
167
|
+
)
|
|
168
|
+
item: Dict[str, Any] = {
|
|
169
|
+
"executionId": self.execution_id,
|
|
170
|
+
"status": "failed",
|
|
171
|
+
"executorAddress": self.executor_address,
|
|
172
|
+
"errorMessage": str(error)[:ERROR_MESSAGE_MAX_LENGTH],
|
|
173
|
+
"failureReason": failure_reason,
|
|
174
|
+
}
|
|
175
|
+
if summary:
|
|
176
|
+
item["logs"] = summary[:LOGS_MAX_LENGTH]
|
|
177
|
+
if duration_ms is not None:
|
|
178
|
+
item["durationMs"] = duration_ms
|
|
179
|
+
return self.report([item])
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Task execution context — passed to every task handler."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from .callback import CallbackClient
|
|
7
|
+
|
|
8
|
+
# R9 (round-9): AUTOFLOW_* keys that carry callback credentials / routing
|
|
9
|
+
# info (N23/N27 parity with executor-node). They are exposed as dedicated
|
|
10
|
+
# TaskContext fields and deliberately EXCLUDED from `params` — folding a
|
|
11
|
+
# one-shot HMAC token or a deployment address into user task parameters
|
|
12
|
+
# would pollute the parameter view and leak the credential into any code
|
|
13
|
+
# that dumps ctx.params.
|
|
14
|
+
_CALLBACK_ENV_KEYS = {
|
|
15
|
+
"AUTOFLOW_CALLBACK_TOKEN": "callback_token",
|
|
16
|
+
"AUTOFLOW_ADMIN_API_URL": "admin_api_url",
|
|
17
|
+
"AUTOFLOW_EXECUTOR_ADDRESS": "executor_address",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TaskContext:
|
|
23
|
+
"""Provides runtime metadata and helpers to a running task."""
|
|
24
|
+
|
|
25
|
+
task_id: str
|
|
26
|
+
execution_id: str
|
|
27
|
+
task_name: str
|
|
28
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
# Callback credentials injected by the executor (N23/N27). All optional:
|
|
32
|
+
# older executors never inject them, in which case `ctx.callback` (and
|
|
33
|
+
# the `ctx.report_success` / `ctx.report_failure` shorthands) stay
|
|
34
|
+
# disabled and raise a descriptive error when used.
|
|
35
|
+
# N40 (round-10): repr=False — the dataclass __repr__ is a leak surface
|
|
36
|
+
# the same as to_dict (N27): a task's `print(ctx)` / f-string debug log
|
|
37
|
+
# would otherwise carry the one-shot HMAC token into the executor's
|
|
38
|
+
# captured stdout → admin DB (execution logs), where any JWT user who
|
|
39
|
+
# can read the execution could replay it within its TTL window.
|
|
40
|
+
callback_token: Optional[str] = field(default=None, repr=False)
|
|
41
|
+
admin_api_url: Optional[str] = field(default=None, repr=False)
|
|
42
|
+
executor_address: Optional[str] = field(default=None, repr=False)
|
|
43
|
+
|
|
44
|
+
# Runtime helpers — populated lazily
|
|
45
|
+
_logger: Optional[Any] = field(default=None, repr=False)
|
|
46
|
+
_callback_client: Optional[CallbackClient] = field(default=None, repr=False)
|
|
47
|
+
|
|
48
|
+
def get_param(self, key: str, default: Any = None) -> Any:
|
|
49
|
+
"""Retrieve a task parameter by key."""
|
|
50
|
+
return self.params.get(key, default)
|
|
51
|
+
|
|
52
|
+
def get_env(self, key: str, default: str = "") -> str:
|
|
53
|
+
"""Retrieve an environment variable (task env first, then OS env)."""
|
|
54
|
+
return self.env.get(key, os.environ.get(key, default))
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def log(self):
|
|
58
|
+
if self._logger is None:
|
|
59
|
+
from .logger import get_logger
|
|
60
|
+
object.__setattr__(self, "_logger", get_logger(self.task_name))
|
|
61
|
+
return self._logger
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def callback(self) -> CallbackClient:
|
|
65
|
+
"""Per-execution Admin API callback client (N23/N27 parity).
|
|
66
|
+
|
|
67
|
+
Lazily built from the injected credentials; check
|
|
68
|
+
``ctx.callback.enabled`` before reporting (older executors do not
|
|
69
|
+
inject the credentials). See :class:`autoflow_sdk.callback.CallbackClient`.
|
|
70
|
+
"""
|
|
71
|
+
if self._callback_client is None:
|
|
72
|
+
object.__setattr__(
|
|
73
|
+
self,
|
|
74
|
+
"_callback_client",
|
|
75
|
+
CallbackClient(
|
|
76
|
+
admin_api_url=self.admin_api_url,
|
|
77
|
+
token=self.callback_token,
|
|
78
|
+
executor_address=self.executor_address,
|
|
79
|
+
execution_id=self.execution_id,
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
return self._callback_client
|
|
83
|
+
|
|
84
|
+
def report_success(self, summary: Optional[str] = None, duration_ms: Optional[int] = None) -> Any:
|
|
85
|
+
"""Report this execution's success to the Admin API.
|
|
86
|
+
|
|
87
|
+
Shorthand for ``ctx.callback.report_success(...)``; raises
|
|
88
|
+
``CallbackDisabledError`` when callback credentials are absent.
|
|
89
|
+
"""
|
|
90
|
+
return self.callback.report_success(summary=summary, duration_ms=duration_ms)
|
|
91
|
+
|
|
92
|
+
def report_failure(
|
|
93
|
+
self,
|
|
94
|
+
error: Any,
|
|
95
|
+
summary: Optional[str] = None,
|
|
96
|
+
duration_ms: Optional[int] = None,
|
|
97
|
+
failure_reason: str = "script_error",
|
|
98
|
+
) -> Any:
|
|
99
|
+
"""Report this execution's failure to the Admin API.
|
|
100
|
+
|
|
101
|
+
``error`` is mapped to the callback item's ``errorMessage`` and
|
|
102
|
+
``failure_reason`` (default ``script_error``) to its structured
|
|
103
|
+
``failureReason``. Raises ``CallbackDisabledError`` when callback
|
|
104
|
+
credentials are absent.
|
|
105
|
+
"""
|
|
106
|
+
return self.callback.report_failure(
|
|
107
|
+
error, summary=summary, duration_ms=duration_ms, failure_reason=failure_reason
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_env(cls) -> "TaskContext":
|
|
112
|
+
"""
|
|
113
|
+
Create TaskContext from environment variables injected by the executor.
|
|
114
|
+
|
|
115
|
+
Reads EXECUTION_ID, TASK_ID, TASK_NAME, and all AUTOFLOW_* vars as
|
|
116
|
+
params — except the callback credential keys
|
|
117
|
+
(``AUTOFLOW_CALLBACK_TOKEN``, ``AUTOFLOW_ADMIN_API_URL``,
|
|
118
|
+
``AUTOFLOW_EXECUTOR_ADDRESS``), which are exposed as dedicated
|
|
119
|
+
fields and never leak into ``params``.
|
|
120
|
+
|
|
121
|
+
Usage::
|
|
122
|
+
|
|
123
|
+
from autoflow_sdk import TaskContext
|
|
124
|
+
|
|
125
|
+
ctx = TaskContext.from_env()
|
|
126
|
+
ctx.log.info(f"Task {ctx.task_id} started")
|
|
127
|
+
date = ctx.get_param("date")
|
|
128
|
+
"""
|
|
129
|
+
params: dict[str, Any] = {}
|
|
130
|
+
callback_fields: dict[str, str] = {}
|
|
131
|
+
for k, v in os.environ.items():
|
|
132
|
+
if not k.startswith("AUTOFLOW_") or not v:
|
|
133
|
+
continue
|
|
134
|
+
if k in _CALLBACK_ENV_KEYS:
|
|
135
|
+
callback_fields[_CALLBACK_ENV_KEYS[k]] = v
|
|
136
|
+
continue
|
|
137
|
+
params[k[len("AUTOFLOW_"):].lower()] = v
|
|
138
|
+
|
|
139
|
+
return cls(
|
|
140
|
+
task_id=os.environ.get("TASK_ID", "unknown"),
|
|
141
|
+
execution_id=os.environ.get("EXECUTION_ID", "unknown"),
|
|
142
|
+
task_name=os.environ.get("TASK_NAME", "unknown"),
|
|
143
|
+
params=params,
|
|
144
|
+
**callback_fields,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
148
|
+
return {
|
|
149
|
+
"task_id": self.task_id,
|
|
150
|
+
"execution_id": self.execution_id,
|
|
151
|
+
"task_name": self.task_name,
|
|
152
|
+
"params": self.params,
|
|
153
|
+
}
|