octoparse-dataapp 0.0.2__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.
@@ -0,0 +1,27 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+
8
+ # uv 虚拟环境
9
+ .venv/
10
+ uv.lock
11
+
12
+ # 本地运行产物
13
+ storage/
14
+
15
+ # 工具缓存
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+ .mypy_cache/
19
+
20
+ # 本地环境变量(模板见 .env.example)
21
+ .env
22
+
23
+ # Claude Code 本地配置
24
+ .claude/settings.local.json
25
+
26
+ # OS
27
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Octoparse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.5
2
+ Name: octoparse-dataapp
3
+ Version: 0.0.2
4
+ Summary: Developer SDK for building Octoparse DataHub Data Apps: read input, push records, handle aborts
5
+ Project-URL: Homepage, https://www.octoparse.com
6
+ Author: Octoparse DataHub Team
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: data-app,datahub,octoparse,sdk
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: jsonschema>=4.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # octoparse-dataapp
26
+
27
+ The developer SDK for building Octoparse DataHub Data Apps (`octoparse-dataapp`, imported as
28
+ `octoparse_dataapp`). Your `app.py` is the program; the SDK is a library you call inside
29
+ `async with DataApp:` to read the run input, push records and react to aborts.
30
+
31
+ To discover, run and consume Data Apps as a caller, use `octoparse-client` instead.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ uv add octoparse-dataapp
37
+ ```
38
+
39
+ Start from the project template (`GET /v1/templates/python.zip`): it ships the `.dataapp/`
40
+ contract, `app.py`, `Dockerfile` and `AGENTS.md`.
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ import asyncio
46
+
47
+ from octoparse_dataapp import DataApp, Event
48
+
49
+
50
+ async def main() -> None:
51
+ async with DataApp: # enter: fetch the run input
52
+ inp = await DataApp.get_input()
53
+
54
+ async def on_abort(event) -> None: # cancel / timeout / record cap
55
+ await DataApp.exit()
56
+ DataApp.on(Event.ABORTING, on_abort)
57
+
58
+ api_key = DataApp.get_env("example_api_key") # alias from .dataapp/runtime.json
59
+ for i, keyword in enumerate(inp["keywords"], 1):
60
+ DataApp.log.info("processing %s", keyword)
61
+ await DataApp.push_data({"id": keyword, "title": f"Result for {keyword}"})
62
+ await DataApp.set_progress(i, len(inp["keywords"]))
63
+ # leave: report the final state
64
+
65
+
66
+ if __name__ == "__main__":
67
+ asyncio.run(main())
68
+ ```
69
+
70
+ Run it with `uv run python app.py`, both locally and on the platform.
71
+
72
+ ## API
73
+
74
+ | API | Description |
75
+ |---|---|
76
+ | `async with DataApp:` | Starts the run. Leaving normally reports `succeeded`; an uncaught exception reports `failed` (or `partially_succeeded` if the platform has accepted records). |
77
+ | `await DataApp.get_input()` | The run input, with top-level `default`s from `input_schema.json` applied. |
78
+ | `await DataApp.push_data(record_or_list)` | Pushes records. Each record must be JSON-serializable (convert `datetime`, `Decimal`, `bytes`, NaN first) and match `output_schema.json`. The SDK batches writes and retries transient failures in the background; if the platform keeps refusing writes, `push_data` waits instead of buffering without limit. A batch the platform rejects outright fails the run with `push-rejected`. |
79
+ | `DataApp.get_env(alias, default=None)` | Reads an env entry declared in `runtime.json` `env`. Treat values as secrets. |
80
+ | `await DataApp.set_progress(done, total, status_text=None, *, upstream_ref=None)` | Progress and a human-readable status. Throttled; only the latest value is sent. `upstream_ref` is the upstream system's own task id, shown on the run for tracing. It is kept until a progress report succeeds, even if later progress values replace the rest. `status_text` longer than 500 characters is truncated. |
81
+ | `DataApp.on(Event.ABORTING, handler)` | Called once on cancel, timeout, record cap or SIGTERM/SIGINT, with `{"reason": ...}`. Without a handler the SDK calls `exit()` for you. On timeout, if the run is still going 15 seconds after your handler returns, the SDK ends it (final state `run-timeout`); cancel stays cooperative. |
82
+ | `await DataApp.exit()` | Flushes, reports the final state and ends the `async with` block. |
83
+ | `await DataApp.fail(message, code=..., retryable=False)` | Ends the run with a structured error. |
84
+ | `DataApp.log` | A `logging.Logger` writing to stderr; env values are masked. |
85
+ | `DataApp.is_at_home` | `True` on the platform, `False` locally. |
86
+
87
+ Custom billing quantities are derived by the platform from pushed records
88
+ (`pricing.json` `events[].qty_from`); there is no manual charge call.
89
+
90
+ ## Local mode
91
+
92
+ Without `DATAHUB_SERVER_URL` and `DATAHUB_WORKER_TOKEN` the SDK runs locally:
93
+
94
+ - input is read from `storage/input.json` and validated against `.dataapp/input_schema.json`;
95
+ - records are written to `storage/dataset/records.jsonl` (cleared on each run) and checked
96
+ against `.dataapp/output_schema.json` (warnings only);
97
+ - `get_env` reads the process environment, then `.env` in the project root;
98
+ - progress and the final state are logged; Ctrl-C triggers `ABORTING`, a second Ctrl-C
99
+ stops immediately; `fail()` exits with status 1.
100
+
101
+ ## Platform mode
102
+
103
+ The platform runs your image as is (its own `ENTRYPOINT` / `CMD`, e.g. `python app.py`) and
104
+ injects `DATAHUB_SERVER_URL` and `DATAHUB_WORKER_TOKEN` (a credential scoped to this one run).
105
+ On entering `async with DataApp:` the SDK claims the run, then fetches the env entries
106
+ declared in `runtime.json` if there are any. The run time limit comes from
107
+ `execution.timeout_seconds`. Every request carries the runtime identity header
108
+ `X-DataHub-Runtime: lang=python; sdk=<version>; protocol=1`.
109
+
110
+ If the claim returns nothing to run (the run was cancelled before it started, or this SDK version is no
111
+ longer supported and the platform has already failed the run), the process exits with status 0
112
+ and the `async with` body never runs.
113
+
114
+ Exit codes only matter if the process exits before it claims its run. The platform reads them as follows:
115
+
116
+ | Code | Meaning |
117
+ |---|---|
118
+ | `0` | Nothing to run, or the claimed run already reached a final state |
119
+ | `2` | Only one of `DATAHUB_SERVER_URL` / `DATAHUB_WORKER_TOKEN` is set (a wrapper dropped the environment) |
120
+ | `75` | The platform could not be reached or rejected the credential; the platform retries the run |
121
+
122
+ Do not exit with `75` from your own code: a pre-claim exit with that code is treated as a platform
123
+ failure and retried.
124
+
125
+ ## Logs
126
+
127
+ On the platform, everything your app writes to stdout and stderr (`DataApp.log`, `print`
128
+ and uncaught errors) becomes the run's log. The person who started the run can read it, for
129
+ test runs and production runs alike; the app owner cannot read the logs of runs started by
130
+ others. The output of your image build is the build log, visible to you as the owner.
131
+
132
+ Before a log is stored, the platform masks the env values handed to the run, inputs marked
133
+ `sensitive`, platform credentials and common credential patterns. Anything else you print is
134
+ shown as is, so do not print personal data or secrets you obtained in other ways. Run logs
135
+ are kept for 30 days. A run keeps at most 2 MiB of log (the beginning and the end of each
136
+ attempt), and lines longer than 16 KiB are cut.
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,116 @@
1
+ # octoparse-dataapp
2
+
3
+ The developer SDK for building Octoparse DataHub Data Apps (`octoparse-dataapp`, imported as
4
+ `octoparse_dataapp`). Your `app.py` is the program; the SDK is a library you call inside
5
+ `async with DataApp:` to read the run input, push records and react to aborts.
6
+
7
+ To discover, run and consume Data Apps as a caller, use `octoparse-client` instead.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ uv add octoparse-dataapp
13
+ ```
14
+
15
+ Start from the project template (`GET /v1/templates/python.zip`): it ships the `.dataapp/`
16
+ contract, `app.py`, `Dockerfile` and `AGENTS.md`.
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ import asyncio
22
+
23
+ from octoparse_dataapp import DataApp, Event
24
+
25
+
26
+ async def main() -> None:
27
+ async with DataApp: # enter: fetch the run input
28
+ inp = await DataApp.get_input()
29
+
30
+ async def on_abort(event) -> None: # cancel / timeout / record cap
31
+ await DataApp.exit()
32
+ DataApp.on(Event.ABORTING, on_abort)
33
+
34
+ api_key = DataApp.get_env("example_api_key") # alias from .dataapp/runtime.json
35
+ for i, keyword in enumerate(inp["keywords"], 1):
36
+ DataApp.log.info("processing %s", keyword)
37
+ await DataApp.push_data({"id": keyword, "title": f"Result for {keyword}"})
38
+ await DataApp.set_progress(i, len(inp["keywords"]))
39
+ # leave: report the final state
40
+
41
+
42
+ if __name__ == "__main__":
43
+ asyncio.run(main())
44
+ ```
45
+
46
+ Run it with `uv run python app.py`, both locally and on the platform.
47
+
48
+ ## API
49
+
50
+ | API | Description |
51
+ |---|---|
52
+ | `async with DataApp:` | Starts the run. Leaving normally reports `succeeded`; an uncaught exception reports `failed` (or `partially_succeeded` if the platform has accepted records). |
53
+ | `await DataApp.get_input()` | The run input, with top-level `default`s from `input_schema.json` applied. |
54
+ | `await DataApp.push_data(record_or_list)` | Pushes records. Each record must be JSON-serializable (convert `datetime`, `Decimal`, `bytes`, NaN first) and match `output_schema.json`. The SDK batches writes and retries transient failures in the background; if the platform keeps refusing writes, `push_data` waits instead of buffering without limit. A batch the platform rejects outright fails the run with `push-rejected`. |
55
+ | `DataApp.get_env(alias, default=None)` | Reads an env entry declared in `runtime.json` `env`. Treat values as secrets. |
56
+ | `await DataApp.set_progress(done, total, status_text=None, *, upstream_ref=None)` | Progress and a human-readable status. Throttled; only the latest value is sent. `upstream_ref` is the upstream system's own task id, shown on the run for tracing. It is kept until a progress report succeeds, even if later progress values replace the rest. `status_text` longer than 500 characters is truncated. |
57
+ | `DataApp.on(Event.ABORTING, handler)` | Called once on cancel, timeout, record cap or SIGTERM/SIGINT, with `{"reason": ...}`. Without a handler the SDK calls `exit()` for you. On timeout, if the run is still going 15 seconds after your handler returns, the SDK ends it (final state `run-timeout`); cancel stays cooperative. |
58
+ | `await DataApp.exit()` | Flushes, reports the final state and ends the `async with` block. |
59
+ | `await DataApp.fail(message, code=..., retryable=False)` | Ends the run with a structured error. |
60
+ | `DataApp.log` | A `logging.Logger` writing to stderr; env values are masked. |
61
+ | `DataApp.is_at_home` | `True` on the platform, `False` locally. |
62
+
63
+ Custom billing quantities are derived by the platform from pushed records
64
+ (`pricing.json` `events[].qty_from`); there is no manual charge call.
65
+
66
+ ## Local mode
67
+
68
+ Without `DATAHUB_SERVER_URL` and `DATAHUB_WORKER_TOKEN` the SDK runs locally:
69
+
70
+ - input is read from `storage/input.json` and validated against `.dataapp/input_schema.json`;
71
+ - records are written to `storage/dataset/records.jsonl` (cleared on each run) and checked
72
+ against `.dataapp/output_schema.json` (warnings only);
73
+ - `get_env` reads the process environment, then `.env` in the project root;
74
+ - progress and the final state are logged; Ctrl-C triggers `ABORTING`, a second Ctrl-C
75
+ stops immediately; `fail()` exits with status 1.
76
+
77
+ ## Platform mode
78
+
79
+ The platform runs your image as is (its own `ENTRYPOINT` / `CMD`, e.g. `python app.py`) and
80
+ injects `DATAHUB_SERVER_URL` and `DATAHUB_WORKER_TOKEN` (a credential scoped to this one run).
81
+ On entering `async with DataApp:` the SDK claims the run, then fetches the env entries
82
+ declared in `runtime.json` if there are any. The run time limit comes from
83
+ `execution.timeout_seconds`. Every request carries the runtime identity header
84
+ `X-DataHub-Runtime: lang=python; sdk=<version>; protocol=1`.
85
+
86
+ If the claim returns nothing to run (the run was cancelled before it started, or this SDK version is no
87
+ longer supported and the platform has already failed the run), the process exits with status 0
88
+ and the `async with` body never runs.
89
+
90
+ Exit codes only matter if the process exits before it claims its run. The platform reads them as follows:
91
+
92
+ | Code | Meaning |
93
+ |---|---|
94
+ | `0` | Nothing to run, or the claimed run already reached a final state |
95
+ | `2` | Only one of `DATAHUB_SERVER_URL` / `DATAHUB_WORKER_TOKEN` is set (a wrapper dropped the environment) |
96
+ | `75` | The platform could not be reached or rejected the credential; the platform retries the run |
97
+
98
+ Do not exit with `75` from your own code: a pre-claim exit with that code is treated as a platform
99
+ failure and retried.
100
+
101
+ ## Logs
102
+
103
+ On the platform, everything your app writes to stdout and stderr (`DataApp.log`, `print`
104
+ and uncaught errors) becomes the run's log. The person who started the run can read it, for
105
+ test runs and production runs alike; the app owner cannot read the logs of runs started by
106
+ others. The output of your image build is the build log, visible to you as the owner.
107
+
108
+ Before a log is stored, the platform masks the env values handed to the run, inputs marked
109
+ `sensitive`, platform credentials and common credential patterns. Anything else you print is
110
+ shown as is, so do not print personal data or secrets you obtained in other ways. Run logs
111
+ are kept for 30 days. A run keeps at most 2 MiB of log (the beginning and the end of each
112
+ attempt), and lines longer than 16 KiB are cut.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "octoparse-dataapp"
3
+ version = "0.0.2"
4
+ description = "Developer SDK for building Octoparse DataHub Data Apps: read input, push records, handle aborts"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [{ name = "Octoparse DataHub Team" }]
8
+ keywords = ["octoparse", "datahub", "sdk", "data-app"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Framework :: AsyncIO",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+ requires-python = ">=3.10"
22
+ # 依赖克制、区间宽松:SDK 与作者依赖共存于同一 venv,收紧区间只会徒增
23
+ # 与作者锁文件的冲突面。jsonschema 只在本地模式校验输入/输出时用到
24
+ dependencies = ["httpx>=0.27", "jsonschema>=4.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://www.octoparse.com"
28
+
29
+ # 离线单元测试(tests/,本地模式走真实文件、平台模式走 MockTransport):`uv run pytest -q`
30
+ [dependency-groups]
31
+ # pyyaml 只给 scripts/sync_contract.py 读引擎 openapi.yaml 用,不进运行时依赖
32
+ dev = ["pytest>=8.0", "ruff>=0.6", "pyyaml>=6.0"]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/octoparse_dataapp"]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py310"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "W", "I", "UP", "B", "A"]
47
+
48
+ # sdist 只带公开内容:内部文档(AGENTS.md 等)不进公网制品
49
+ [tool.hatch.build.targets.sdist]
50
+ only-include = ["src", "README.md", "LICENSE"]
@@ -0,0 +1,24 @@
1
+ """octoparse-dataapp:开发 Octoparse DataHub Data App 的 SDK。
2
+
3
+ 进程归作者、SDK 是库:作者的 app.py 是入口,在 `async with DataApp:` 内
4
+ 读取输入、写回记录、响应取消。同一份代码本地与平台都用 `uv run python app.py`
5
+ 运行;存在 DATAHUB_SERVER_URL 与 DATAHUB_WORKER_TOKEN 即平台模式(按平台 worker 通道
6
+ 协议认领 run),否则本地模式。
7
+
8
+ 调用其他 Data App(发现、运行、取结果)属于用户侧能力,见 octoparse-client。
9
+ """
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+
13
+ from ._brand import BRAND
14
+
15
+ try:
16
+ __version__ = version(f"{BRAND}-dataapp")
17
+ except PackageNotFoundError: # 源码树直接导入(未安装)
18
+ __version__ = "0.0.0"
19
+
20
+ from ._app import AbortReason, DataApp, Event # noqa: E402
21
+ from ._errors import DataAppError, RunTerminatedError # noqa: E402
22
+
23
+ __all__ = ["DataApp", "Event", "AbortReason", "DataAppError", "RunTerminatedError",
24
+ "__version__"]
@@ -0,0 +1,119 @@
1
+ """ABORTING:取消 / 超时 / 条数熔断 / SIGTERM·SIGINT 统一走这里,只触发一次。
2
+
3
+ 有作者 handler 就交给作者收尾;没有就由 SDK 调 exit()。
4
+ 第二次信号不再等作者,直接取消主任务。
5
+ 超时(TIMEOUT)另有宽限:作者 handler 跑完后 TIMEOUT_GRACE_SECONDS 内 run 还没结束,
6
+ SDK 直接结束主任务——否则不配合收尾的作者代码会一直跑到执行环境生命期耗尽才被平台收尸。
7
+ 取消 / 熔断仍是协作式的,与引擎自己的看门狗同口径(只有超时强停)。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import inspect
14
+ import signal
15
+ from collections.abc import Awaitable, Callable
16
+ from enum import Enum
17
+ from typing import Any
18
+
19
+ from ._log import sdk_logger as logger
20
+ from ._outcome import AbortReason
21
+ from ._state import RunState
22
+
23
+ # 超时后给作者收尾的宽限(秒):到点由 SDK 结束主任务;SDK 自己的攒批写出与终态上报在其后照常进行
24
+ TIMEOUT_GRACE_SECONDS = 15.0
25
+
26
+
27
+ class Event(str, Enum):
28
+ ABORTING = "aborting"
29
+
30
+
31
+ Handler = Callable[[dict[str, Any]], None | Awaitable[None]]
32
+
33
+
34
+ class AbortController:
35
+ def __init__(self, state: RunState, default_action: Callable[[], Awaitable[None]],
36
+ force_stop: Callable[[], Awaitable[None]] | None = None) -> None:
37
+ self._state = state
38
+ self._default_action = default_action # 没有 ABORTING handler 时的收尾(= exit())
39
+ self._force_stop = force_stop or default_action # 超时宽限到点的强停(= 结束主任务)
40
+ self._handlers: dict[Event, list[Handler]] = {}
41
+ self._task: asyncio.Task[None] | None = None
42
+ self._main_task: asyncio.Task[Any] | None = None
43
+ self._signals: list[int] = []
44
+ self.reason: AbortReason | None = None
45
+ self.cancel_requested = False
46
+
47
+ def on(self, event: Event, handler: Handler) -> None:
48
+ self._handlers.setdefault(Event(event), []).append(handler)
49
+
50
+ def trigger(self, reason: AbortReason) -> None:
51
+ if self.reason is not None or self._state.finished:
52
+ return
53
+ self.reason = reason
54
+ logger.info("aborting (%s)", reason.value)
55
+ self._task = asyncio.create_task(self._run_handlers(reason), name="dataapp-aborting")
56
+
57
+ def request_cancel(self) -> None:
58
+ """平台下发了取消信号(进度响应捎带或轮询得到)。"""
59
+ self.cancel_requested = True
60
+ self.trigger(AbortReason.CANCEL)
61
+
62
+ def ensure_reason(self, reason: AbortReason) -> None:
63
+ """只记原因不跑 handler:主任务已在收尾(外部取消、Ctrl-C)时用。"""
64
+ if self.reason is None:
65
+ self.reason = reason
66
+
67
+ async def _run_handlers(self, reason: AbortReason) -> None:
68
+ handlers = list(self._handlers.get(Event.ABORTING, ()))
69
+ if not handlers:
70
+ await self._default_action()
71
+ return
72
+ for handler in handlers:
73
+ try:
74
+ result = handler({"reason": reason.value})
75
+ if inspect.isawaitable(result):
76
+ await result
77
+ except asyncio.CancelledError:
78
+ raise
79
+ except Exception: # noqa: BLE001 — 作者代码
80
+ logger.exception("ABORTING handler failed")
81
+ if reason is AbortReason.TIMEOUT and not self._state.finished:
82
+ # 作者 handler 跑完却没结束 run:宽限后由 SDK 结束主任务,终态照常是 run-timeout
83
+ await asyncio.sleep(TIMEOUT_GRACE_SECONDS)
84
+ if not self._state.finished:
85
+ logger.warning("run did not stop within %.0fs after the timeout; stopping it",
86
+ TIMEOUT_GRACE_SECONDS)
87
+ await self._force_stop()
88
+
89
+ # ── 信号 ─────────────────────────────────────────────────
90
+ def install_signals(self, main_task: asyncio.Task[Any] | None) -> None:
91
+ self._main_task = main_task
92
+ loop = asyncio.get_running_loop()
93
+ for sig in (signal.SIGTERM, signal.SIGINT):
94
+ try:
95
+ loop.add_signal_handler(sig, self._on_signal)
96
+ except (NotImplementedError, RuntimeError, ValueError):
97
+ continue # Windows / 非主线程:没有信号处理也能跑,只是收不到 ABORTING
98
+ self._signals.append(sig)
99
+
100
+ def _on_signal(self) -> None:
101
+ if self.reason is not None and self._main_task is not None:
102
+ # 第二次信号:不再等作者收尾,直接取消主任务
103
+ self._main_task.cancel()
104
+ return
105
+ self.trigger(AbortReason.SIGNAL)
106
+
107
+ async def close(self) -> None:
108
+ loop = asyncio.get_running_loop()
109
+ for sig in self._signals:
110
+ loop.remove_signal_handler(sig)
111
+ self._signals.clear()
112
+ task = self._task
113
+ # 在 ABORTING 任务里调 exit() 走到这里时不能取消自己
114
+ if task is not None and task is not asyncio.current_task() and not task.done():
115
+ task.cancel()
116
+ try:
117
+ await task
118
+ except (asyncio.CancelledError, Exception):
119
+ pass