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.
- octoparse_dataapp-0.0.2/.gitignore +27 -0
- octoparse_dataapp-0.0.2/LICENSE +21 -0
- octoparse_dataapp-0.0.2/PKG-INFO +140 -0
- octoparse_dataapp-0.0.2/README.md +116 -0
- octoparse_dataapp-0.0.2/pyproject.toml +50 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/__init__.py +24 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_abort.py +119 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_app.py +382 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_backend.py +53 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_brand.py +4 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_errors.py +29 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_local.py +208 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_log.py +68 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_outcome.py +68 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_platform.py +271 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_progress.py +66 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_state.py +16 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/_writer.py +188 -0
- octoparse_dataapp-0.0.2/src/octoparse_dataapp/py.typed +0 -0
|
@@ -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
|