crawler4j-sdk 0.2.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.
- crawler4j_sdk/__init__.py +58 -0
- crawler4j_sdk/_version.py +48 -0
- crawler4j_sdk/assembler.py +216 -0
- crawler4j_sdk/base.py +90 -0
- crawler4j_sdk/cli/__init__.py +13 -0
- crawler4j_sdk/cli/commands.py +2084 -0
- crawler4j_sdk/cli/templates.py +426 -0
- crawler4j_sdk/context.py +39 -0
- crawler4j_sdk/env_selector.py +56 -0
- crawler4j_sdk/py.typed +0 -0
- crawler4j_sdk/result.py +5 -0
- crawler4j_sdk/signal.py +5 -0
- crawler4j_sdk/workflow.py +102 -0
- crawler4j_sdk-0.2.0.dist-info/METADATA +297 -0
- crawler4j_sdk-0.2.0.dist-info/RECORD +18 -0
- crawler4j_sdk-0.2.0.dist-info/WHEEL +5 -0
- crawler4j_sdk-0.2.0.dist-info/entry_points.txt +2 -0
- crawler4j_sdk-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Crawler4j SDK - 任务脚本开发工具包。
|
|
2
|
+
|
|
3
|
+
本包聚合导出模块开发所需的稳定契约与基类,供标准模块直接依赖。
|
|
4
|
+
稳定契约类型由 `crawler4j-contracts` 提供,SDK 负责补充 TaskScript、
|
|
5
|
+
TaskFlow 与 ModuleAssembler 等开发入口。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from crawler4j_sdk._version import get_version
|
|
9
|
+
from crawler4j_sdk.base import TaskScript
|
|
10
|
+
from crawler4j_sdk.workflow import TaskFlow
|
|
11
|
+
from crawler4j_sdk.assembler import ModuleAssembler
|
|
12
|
+
from crawler4j_sdk.env_selector import EnvSelectorInfo, env_selector
|
|
13
|
+
from crawler4j_contracts import (
|
|
14
|
+
BBox,
|
|
15
|
+
ClickCaptchaDebugInfo,
|
|
16
|
+
ClickCaptchaMatchResult,
|
|
17
|
+
ClickCaptchaOrderedTarget,
|
|
18
|
+
EnvCandidate,
|
|
19
|
+
EnvAction,
|
|
20
|
+
ImageInput,
|
|
21
|
+
Point,
|
|
22
|
+
SliderCaptchaDebugInfo,
|
|
23
|
+
SliderCaptchaMatchResult,
|
|
24
|
+
TaskContext,
|
|
25
|
+
TaskResult,
|
|
26
|
+
TaskSignal,
|
|
27
|
+
TaskSignalAction,
|
|
28
|
+
ToolSpec,
|
|
29
|
+
ToolsCapability,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__version__ = get_version()
|
|
33
|
+
|
|
34
|
+
# 稳定导出列表(同 MAJOR 版本内冻结)
|
|
35
|
+
__all__ = [
|
|
36
|
+
# 核心契约类型
|
|
37
|
+
"TaskScript",
|
|
38
|
+
"TaskFlow",
|
|
39
|
+
"ModuleAssembler",
|
|
40
|
+
"env_selector",
|
|
41
|
+
"EnvSelectorInfo",
|
|
42
|
+
"TaskContext",
|
|
43
|
+
"TaskResult",
|
|
44
|
+
"TaskSignal",
|
|
45
|
+
"TaskSignalAction",
|
|
46
|
+
"EnvAction",
|
|
47
|
+
"ImageInput",
|
|
48
|
+
"BBox",
|
|
49
|
+
"Point",
|
|
50
|
+
"ToolSpec",
|
|
51
|
+
"ToolsCapability",
|
|
52
|
+
"SliderCaptchaDebugInfo",
|
|
53
|
+
"SliderCaptchaMatchResult",
|
|
54
|
+
"ClickCaptchaDebugInfo",
|
|
55
|
+
"ClickCaptchaOrderedTarget",
|
|
56
|
+
"ClickCaptchaMatchResult",
|
|
57
|
+
"EnvCandidate",
|
|
58
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Version helpers backed by package metadata and pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import tomllib
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PACKAGE_NAME = "crawler4j-sdk"
|
|
12
|
+
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
|
|
13
|
+
PYPROJECT_PATH = PACKAGE_ROOT / "pyproject.toml"
|
|
14
|
+
BASE_VERSION_RE = re.compile(r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_version_from_metadata() -> str | None:
|
|
18
|
+
try:
|
|
19
|
+
return metadata.version(PACKAGE_NAME)
|
|
20
|
+
except metadata.PackageNotFoundError:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_version_from_pyproject() -> str:
|
|
25
|
+
with PYPROJECT_PATH.open("rb") as f:
|
|
26
|
+
pyproject = tomllib.load(f)
|
|
27
|
+
return str(pyproject["project"]["version"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_version() -> str:
|
|
31
|
+
"""Resolve the package version from build metadata or the source pyproject."""
|
|
32
|
+
return _load_version_from_metadata() or _load_version_from_pyproject()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_base_version(version: str | None = None) -> str:
|
|
36
|
+
"""Extract major.minor.patch from the declared version string."""
|
|
37
|
+
resolved = version or get_version()
|
|
38
|
+
match = BASE_VERSION_RE.match(resolved.strip())
|
|
39
|
+
if not match:
|
|
40
|
+
raise ValueError(f"Unsupported version format: {resolved}")
|
|
41
|
+
return f"{match.group('major')}.{match.group('minor')}.{match.group('patch')}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_compatible_dependency_spec() -> str:
|
|
45
|
+
"""Build the default scaffold dependency range for the current SDK version."""
|
|
46
|
+
base_version = get_base_version()
|
|
47
|
+
major = int(base_version.split(".", 1)[0])
|
|
48
|
+
return f"{PACKAGE_NAME}>={base_version},<{major + 1}.0.0"
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Module Assembler for Crawler4j modules.
|
|
2
|
+
|
|
3
|
+
This module provides the ModuleAssembler class which handles automatic discovery
|
|
4
|
+
of tasks and workflows within a module, and provides a standardized entry point.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
8
|
+
import inspect
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from pkgutil import iter_modules
|
|
12
|
+
from typing import Any, Callable, Dict, Optional, Type
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from crawler4j_sdk.base import TaskScript
|
|
17
|
+
from crawler4j_sdk.context import TaskContext
|
|
18
|
+
from crawler4j_sdk.env_selector import EnvSelectorInfo, get_env_selector_info, invoke_env_selector
|
|
19
|
+
from crawler4j_sdk.result import TaskResult
|
|
20
|
+
from crawler4j_sdk.workflow import TaskFlow
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModuleAssembler:
|
|
26
|
+
"""Handles discovery and execution of module components."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, package_root: Path, module_name: str, default_workflow: str = ""):
|
|
29
|
+
self.package_root = package_root
|
|
30
|
+
self.module_name = module_name
|
|
31
|
+
self.default_workflow = default_workflow
|
|
32
|
+
self.task_scripts: Dict[str, Type[TaskScript]] = {}
|
|
33
|
+
self.workflows: Dict[str, Type[TaskFlow]] = {}
|
|
34
|
+
self.discovery_errors: Dict[str, str] = {}
|
|
35
|
+
|
|
36
|
+
# Hooks that can be overridden by module_runtime.py
|
|
37
|
+
self.hooks: Dict[str, Callable] = {}
|
|
38
|
+
self.env_selectors: Dict[str, Callable] = {}
|
|
39
|
+
self.env_selector_infos: Dict[str, EnvSelectorInfo] = {}
|
|
40
|
+
|
|
41
|
+
self._load_manifest()
|
|
42
|
+
self._discover()
|
|
43
|
+
self._load_runtime_extensions()
|
|
44
|
+
|
|
45
|
+
def _load_manifest(self):
|
|
46
|
+
"""Load default_workflow from module.yaml if not set."""
|
|
47
|
+
manifest_path = self.package_root / "module.yaml"
|
|
48
|
+
if manifest_path.exists():
|
|
49
|
+
try:
|
|
50
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
51
|
+
manifest = yaml.safe_load(f)
|
|
52
|
+
if manifest and not self.default_workflow:
|
|
53
|
+
workflows = manifest.get("workflows", [])
|
|
54
|
+
if workflows and isinstance(workflows, list):
|
|
55
|
+
self.default_workflow = workflows[0].get("name", "")
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.warning(f"Failed to load manifest at {manifest_path}: {e}")
|
|
58
|
+
|
|
59
|
+
def _load_registry(self, subpackage: str, base_cls: type) -> Dict[str, type]:
|
|
60
|
+
registry: Dict[str, type] = {}
|
|
61
|
+
package_dir = self.package_root / subpackage
|
|
62
|
+
if not package_dir.exists():
|
|
63
|
+
return registry
|
|
64
|
+
|
|
65
|
+
full_package_name = f"{self.module_name}.{subpackage}"
|
|
66
|
+
for module_info in iter_modules([str(package_dir)]):
|
|
67
|
+
if module_info.name.startswith("_"):
|
|
68
|
+
continue
|
|
69
|
+
import_target = f"{full_package_name}.{module_info.name}"
|
|
70
|
+
try:
|
|
71
|
+
module = importlib.import_module(import_target)
|
|
72
|
+
for attr_name in dir(module):
|
|
73
|
+
candidate = getattr(module, attr_name)
|
|
74
|
+
if (
|
|
75
|
+
inspect.isclass(candidate)
|
|
76
|
+
and issubclass(candidate, base_cls)
|
|
77
|
+
and candidate is not base_cls
|
|
78
|
+
):
|
|
79
|
+
key = getattr(candidate, "name", "") or module_info.name
|
|
80
|
+
registry[key] = candidate
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
error_message = (
|
|
83
|
+
f"Failed to import {import_target} during {subpackage} discovery: "
|
|
84
|
+
f"{exc.__class__.__name__}: {exc}"
|
|
85
|
+
)
|
|
86
|
+
self.discovery_errors[module_info.name] = error_message
|
|
87
|
+
logger.exception(error_message)
|
|
88
|
+
continue
|
|
89
|
+
return registry
|
|
90
|
+
|
|
91
|
+
def _discover(self):
|
|
92
|
+
"""Automatically discover tasks and workflows."""
|
|
93
|
+
self.task_scripts = self._load_registry("tasks", TaskScript)
|
|
94
|
+
self.workflows = self._load_registry("workflows", TaskFlow)
|
|
95
|
+
|
|
96
|
+
def _load_runtime_extensions(self):
|
|
97
|
+
"""Load optional module_runtime.py extensions."""
|
|
98
|
+
runtime_path = self.package_root / "module_runtime.py"
|
|
99
|
+
if runtime_path.exists():
|
|
100
|
+
try:
|
|
101
|
+
runtime_module = importlib.import_module(f"{self.module_name}.module_runtime")
|
|
102
|
+
|
|
103
|
+
# Load hooks if present
|
|
104
|
+
hook_names = [
|
|
105
|
+
"prepare_env",
|
|
106
|
+
"init_env",
|
|
107
|
+
"before_run",
|
|
108
|
+
"on_success",
|
|
109
|
+
"on_failure",
|
|
110
|
+
"on_timeout",
|
|
111
|
+
"on_cleanup",
|
|
112
|
+
]
|
|
113
|
+
for name in hook_names:
|
|
114
|
+
if hasattr(runtime_module, name):
|
|
115
|
+
self.hooks[name] = getattr(runtime_module, name)
|
|
116
|
+
|
|
117
|
+
# Allow overriding default_workflow
|
|
118
|
+
if hasattr(runtime_module, "DEFAULT_WORKFLOW"):
|
|
119
|
+
self.default_workflow = getattr(runtime_module, "DEFAULT_WORKFLOW")
|
|
120
|
+
|
|
121
|
+
# Allow manual registration/override of tasks/workflows
|
|
122
|
+
if hasattr(runtime_module, "TASK_SCRIPTS"):
|
|
123
|
+
self.task_scripts.update(getattr(runtime_module, "TASK_SCRIPTS"))
|
|
124
|
+
if hasattr(runtime_module, "WORKFLOWS"):
|
|
125
|
+
self.workflows.update(getattr(runtime_module, "WORKFLOWS"))
|
|
126
|
+
|
|
127
|
+
for attr_name in dir(runtime_module):
|
|
128
|
+
candidate = getattr(runtime_module, attr_name)
|
|
129
|
+
info = get_env_selector_info(candidate)
|
|
130
|
+
if not info:
|
|
131
|
+
continue
|
|
132
|
+
self.env_selectors[info.name] = candidate
|
|
133
|
+
self.env_selector_infos[info.name] = info
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.exception(f"Failed to load runtime extensions: {e}")
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _normalize_result_payload(result: object) -> TaskResult:
|
|
140
|
+
if isinstance(result, TaskResult):
|
|
141
|
+
return result
|
|
142
|
+
if result is None:
|
|
143
|
+
return TaskResult.ok()
|
|
144
|
+
if isinstance(result, dict):
|
|
145
|
+
return TaskResult.ok(data=result)
|
|
146
|
+
return TaskResult.ok(data={"value": result})
|
|
147
|
+
|
|
148
|
+
async def _run_task_script(self, script_cls: Type[TaskScript], ctx: TaskContext) -> TaskResult:
|
|
149
|
+
script = script_cls()
|
|
150
|
+
result = await script.execute(ctx)
|
|
151
|
+
return self._normalize_result_payload(result)
|
|
152
|
+
|
|
153
|
+
async def _run_task_flow(self, flow_cls: Type[TaskFlow], ctx: TaskContext) -> TaskResult:
|
|
154
|
+
flow = flow_cls()
|
|
155
|
+
result = await flow.run(ctx)
|
|
156
|
+
if result is None:
|
|
157
|
+
return TaskResult.ok(data=dict(ctx.state))
|
|
158
|
+
return self._normalize_result_payload(result)
|
|
159
|
+
|
|
160
|
+
async def _subtask_executor(self, task_name: str, ctx: TaskContext) -> TaskResult:
|
|
161
|
+
if task_name not in self.task_scripts:
|
|
162
|
+
raise ValueError(f"Unknown subtask: {task_name}")
|
|
163
|
+
return await self._run_task_script(self.task_scripts[task_name], ctx)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _resolve_workflow_name(context: TaskContext, default_workflow: str) -> str:
|
|
167
|
+
"""Resolve workflow selection from runtime only."""
|
|
168
|
+
runtime = getattr(context, "runtime", None)
|
|
169
|
+
if isinstance(runtime, dict):
|
|
170
|
+
workflow_name = runtime.get("workflow")
|
|
171
|
+
if isinstance(workflow_name, str) and workflow_name.strip():
|
|
172
|
+
return workflow_name
|
|
173
|
+
return default_workflow
|
|
174
|
+
|
|
175
|
+
async def run(self, context: TaskContext) -> TaskResult:
|
|
176
|
+
"""Module execution entry point."""
|
|
177
|
+
workflow_name = self._resolve_workflow_name(context, self.default_workflow)
|
|
178
|
+
|
|
179
|
+
if workflow_name in self.workflows:
|
|
180
|
+
context._subtask_executor = self._subtask_executor
|
|
181
|
+
return await self._run_task_flow(self.workflows[workflow_name], context)
|
|
182
|
+
|
|
183
|
+
if workflow_name in self.task_scripts:
|
|
184
|
+
return await self._run_task_script(self.task_scripts[workflow_name], context)
|
|
185
|
+
|
|
186
|
+
discovery_error = self.discovery_errors.get(workflow_name)
|
|
187
|
+
if discovery_error:
|
|
188
|
+
raise ValueError(f"Workflow or task not found: {workflow_name}. {discovery_error}")
|
|
189
|
+
|
|
190
|
+
raise ValueError(f"Workflow or task not found: {workflow_name}")
|
|
191
|
+
|
|
192
|
+
def get_hook(self, name: str) -> Optional[Callable]:
|
|
193
|
+
"""Get a registered hook by name."""
|
|
194
|
+
return self.hooks.get(name)
|
|
195
|
+
|
|
196
|
+
def get_env_selector(self, name: str) -> Optional[Callable]:
|
|
197
|
+
"""Get a registered env selector callback by name."""
|
|
198
|
+
return self.env_selectors.get(name)
|
|
199
|
+
|
|
200
|
+
def list_env_selectors(self) -> list[EnvSelectorInfo]:
|
|
201
|
+
"""List registered env selectors in stable name order."""
|
|
202
|
+
return [
|
|
203
|
+
self.env_selector_infos[name]
|
|
204
|
+
for name in sorted(self.env_selector_infos)
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
async def run_env_selector(
|
|
208
|
+
self,
|
|
209
|
+
name: str,
|
|
210
|
+
context: TaskContext,
|
|
211
|
+
candidates: list[Any],
|
|
212
|
+
) -> Any:
|
|
213
|
+
selector = self.get_env_selector(name)
|
|
214
|
+
if not selector:
|
|
215
|
+
raise ValueError(f"Env selector not found: {name}")
|
|
216
|
+
return await invoke_env_selector(selector, context, candidates)
|
crawler4j_sdk/base.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""TaskScript 基类定义。
|
|
2
|
+
|
|
3
|
+
本模块定义了 Crawler4j SDK 的核心契约之一:TaskScript(原子任务基类)。
|
|
4
|
+
所有任务脚本必须继承此类并实现 execute 方法。
|
|
5
|
+
|
|
6
|
+
稳定契约 (Stable API - 同 MAJOR 版本内冻结):
|
|
7
|
+
- 类属性: name, display_name, description, default_config
|
|
8
|
+
- 方法: execute
|
|
9
|
+
|
|
10
|
+
参考规格: docs/02-requirements/reference-srs/06-sdk/06-1-taskscript.md
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from crawler4j_sdk.context import TaskContext
|
|
18
|
+
from crawler4j_sdk.result import TaskResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaskScript(ABC):
|
|
22
|
+
"""原子任务基类。
|
|
23
|
+
|
|
24
|
+
TaskScript 是最小可执行单元:给定上下文与配置,执行一次业务动作并返回结构化结果。
|
|
25
|
+
|
|
26
|
+
类属性 (Stable):
|
|
27
|
+
name: 任务唯一标识符 (MUST),用于调度、引用和追溯。
|
|
28
|
+
display_name: 面向 UI/日志的可读名称 (SHOULD)。
|
|
29
|
+
description: 简要说明 (SHOULD)。
|
|
30
|
+
default_config: 默认配置字典 (MAY),运行时会与外部配置深合并。
|
|
31
|
+
|
|
32
|
+
并发语义:
|
|
33
|
+
- 单个 TaskScript 实例不要求线程安全
|
|
34
|
+
- 框架不得并发调用同一实例的 execute
|
|
35
|
+
- 每次执行应使用新实例,避免隐式共享状态
|
|
36
|
+
|
|
37
|
+
示例:
|
|
38
|
+
>>> from crawler4j_sdk import TaskScript, TaskContext, TaskResult
|
|
39
|
+
>>>
|
|
40
|
+
>>> class LoginTask(TaskScript):
|
|
41
|
+
... name = "login"
|
|
42
|
+
... display_name = "用户登录"
|
|
43
|
+
... description = "执行网站登录流程"
|
|
44
|
+
... default_config = {"timeout": 30}
|
|
45
|
+
...
|
|
46
|
+
... async def execute(self, ctx: TaskContext) -> TaskResult:
|
|
47
|
+
... await ctx.page.goto("https://example.com/login")
|
|
48
|
+
... # ... 登录逻辑
|
|
49
|
+
... return TaskResult.ok(message="登录成功")
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
# === 类属性 (Stable) ===
|
|
53
|
+
|
|
54
|
+
name: str = ""
|
|
55
|
+
"""任务唯一标识符 (MUST)。用于调度、引用和追溯,应保持稳定。"""
|
|
56
|
+
|
|
57
|
+
display_name: str = ""
|
|
58
|
+
"""面向 UI/日志的可读名称 (SHOULD)。"""
|
|
59
|
+
|
|
60
|
+
description: str = ""
|
|
61
|
+
"""任务简要说明 (SHOULD)。"""
|
|
62
|
+
|
|
63
|
+
default_config: dict[str, Any] = {}
|
|
64
|
+
"""默认配置字典 (MAY)。运行时会与外部配置深合并,外部配置优先。"""
|
|
65
|
+
|
|
66
|
+
# === 主执行方法 (Stable) ===
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def execute(self, ctx: "TaskContext") -> "TaskResult":
|
|
70
|
+
"""执行任务的主方法 (MUST 实现)。
|
|
71
|
+
|
|
72
|
+
这是 TaskScript 的核心方法,子类必须实现。
|
|
73
|
+
方法应完成一次完整的业务动作并返回结构化结果。
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
ctx: 任务执行上下文,提供 page、config、runtime、logger、tools 等能力。
|
|
77
|
+
脚本应只通过 ctx 获取框架能力,不得直接耦合 Core 内部对象。
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
TaskResult: 执行结果。成功使用 TaskResult.ok(),失败使用 TaskResult.fail()。
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
Exception: 允许抛出异常,由宿主 ATM 统一处理并映射到失败生命周期。
|
|
84
|
+
|
|
85
|
+
Note:
|
|
86
|
+
- 业务失败(可预期)建议返回 TaskResult.fail()
|
|
87
|
+
- 不可预期异常可直接抛出,由运行时处理
|
|
88
|
+
- 应避免长时间阻塞调用,使用 async I/O
|
|
89
|
+
"""
|
|
90
|
+
raise NotImplementedError
|