bazhuayu-client 0.2.6__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,14 @@
1
+ # bazhuayu-client 环境变量示例。复制为 .env 并按需填写(.env 已被 .gitignore 忽略)。
2
+ # SDK 本身不加载 .env 文件——由调用方(应用/脚本/测试)自行加载,如 python-dotenv。
3
+ # Client() 构造参数缺省时回退这两个环境变量。
4
+
5
+ # 平台 API 地址(缺省生产端点 https://api-datahub.bazhuayu.com;
6
+ # 本地开发/预发环境按需覆盖)
7
+ BAZHUAYU_BASE_URL=
8
+
9
+ # API Key(控制台生成;留空则匿名访问)
10
+ BAZHUAYU_API_KEY=
11
+
12
+ # OAuth /token 端点(PasswordTokenProvider 的 token_url 缺省回退;
13
+ # 缺省生产端点 https://openapi.bazhuayu.com/token,预发环境按需覆盖)
14
+ BAZHUAYU_TOKEN_URL=
@@ -0,0 +1,24 @@
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
+ .pytest_cache/
14
+ .ruff_cache/
15
+ .mypy_cache/
16
+
17
+ # 本地环境变量(模板见 .env.example)
18
+ .env
19
+
20
+ # Claude Code 本地配置
21
+ .claude/settings.local.json
22
+
23
+ # OS
24
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bazhuayu (八爪鱼)
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,102 @@
1
+ Metadata-Version: 2.5
2
+ Name: bazhuayu-client
3
+ Version: 0.2.6
4
+ Summary: 八爪鱼 DataHub 官方 Python SDK:发现、调用、消费 Data App
5
+ Project-URL: Homepage, https://www.bazhuayu.com
6
+ Author: Bazhuayu DataHub Team
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: bazhuayu,data-app,datahub,sdk
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx>=0.27
21
+ Description-Content-Type: text/markdown
22
+
23
+ # bazhuayu-client-python — DataHub 官方 Python SDK
24
+
25
+ 八爪鱼 DataHub 的官方 Python 客户端(`bazhuayu-client`):发现、调用、消费 Data App。
26
+ 自包含小项目,零平台侧依赖(运行时仅 `httpx`),只讲平台公开的 `/v1` REST API。
27
+ 仓库按 `<品牌>-client-<语言>` 命名,其他语言的客户端与之平级(如 bazhuayu-client-js)。
28
+
29
+ ## 安装
30
+
31
+ ```bash
32
+ pip install bazhuayu-client
33
+ ```
34
+
35
+ ## 用法
36
+
37
+ ```python
38
+ from bazhuayu_client import Client
39
+
40
+ # base_url 缺省即生产端点 https://api-datahub.bazhuayu.com,本地/预发环境按需传入
41
+ with Client(api_key="demo-key") as client:
42
+ # 发现
43
+ result = client.search("评论", limit=10)
44
+ detail = client.get_app("demo/reviews-store-query") # 或卡片里的 app_id
45
+
46
+ # 调用(等到终态)并消费
47
+ run = client.call("demo/partner-reviews-api", {"product": "p-9001"})
48
+ for record in client.iterate_records(run["run_id"]):
49
+ print(record["content"], record["rating"])
50
+
51
+ # 盘点与对账
52
+ for r in client.iterate_runs(run_kind="production", created_from="2026-08-01T00:00:00Z"):
53
+ print(r["run_id"], r["state"], r["billing"])
54
+ print(client.billing(group_by="data_app", tz_offset=480)) # 按本地日/按 App 看花在哪
55
+
56
+ # 结果数据默认保留 90 天,要长期留存打保留标记
57
+ client.set_dataset_retention(run["dataset_id"], retained=True)
58
+ ```
59
+
60
+ `call()` 发起运行并轮询到终态;需要非阻塞语义时用 `run()` 拿 `run_id` 后自行
61
+ `get_run()` / `cancel()`。被点对点授权给自己的 App 不在市场结果里,用
62
+ `search(shared_with="me")` 查。全部方法与错误映射见 `src/bazhuayu_client/client.py`。
63
+
64
+ App 引用有两种形态,`get_app()` / `run()` / `call()` 及运行列表的 `data_app`
65
+ 过滤处等价可用:`<用户名>/<应用名>` 两段式引用(取自卡片的 `namespace` 与
66
+ `app_name`,适合人读,发布者改名后旧引用失效);不变标识 `app_id`
67
+ (`app_<hex>`,取自卡片/详情的 `app_id` 字段,改名免疫)。写进配置、定时任务
68
+ 等长期集成建议钉 `app_id`。
69
+
70
+ ## 配置
71
+
72
+ `Client()` 的 `base_url` / `api_key` 缺省时回退环境变量 `BAZHUAYU_BASE_URL` /
73
+ `BAZHUAYU_API_KEY`(模板见 `.env.example`;SDK 不自动加载 `.env`,由调用方
74
+ 自行加载),再回退生产端点 `https://api-datahub.bazhuayu.com` / 匿名。
75
+
76
+ ## OAuth 登录(账号密码,替代 api_key)
77
+
78
+ 除 api_key 外,也可用八爪鱼账号密码经 openapi `/token` 端点换取 identity JWT
79
+ 调用平台(两通道互斥,归属与计费同一账户):
80
+
81
+ ```python
82
+ from bazhuayu_client import Client, PasswordTokenProvider
83
+
84
+ provider = PasswordTokenProvider("user@example.com", "password")
85
+ with Client("https://datahub.example.com", token_provider=provider) as client:
86
+ client.call("demo/partner-reviews-api", {"product": "p-9001"})
87
+ ```
88
+
89
+ token 的缓存、快过期自动续期、refresh_token 一次性轮换与失效后密码重登都收在
90
+ provider 内,线程安全;提交 run 前默认要求 token 剩余有效期 ≥ 12h(不足则先
91
+ 强制刷新,可用 `Client(run_token_min_ttl=)` 调整)——平台入队时快照凭证、
92
+ 终态重放扣费,提交时 token 越新鲜,长运行结束时扣费凭证过期的敞口越小。
93
+ 注意 refresh_token 一次性使用:**多进程不要共享同一账号的凭据序列**,每个
94
+ 进程各建 provider(各自密码登录)即可。`token_url` 缺省生产端点,可用参数或
95
+ 环境变量 `BAZHUAYU_TOKEN_URL` 覆盖。`token_provider` 也接受任意返回当前有效
96
+ token 的无参 callable(自管续期时用)。
97
+
98
+ ## 测试
99
+
100
+ ```bash
101
+ uv run pytest -q # 离线单元测试(MockTransport,不依赖服务端)
102
+ ```
@@ -0,0 +1,80 @@
1
+ # bazhuayu-client-python — DataHub 官方 Python SDK
2
+
3
+ 八爪鱼 DataHub 的官方 Python 客户端(`bazhuayu-client`):发现、调用、消费 Data App。
4
+ 自包含小项目,零平台侧依赖(运行时仅 `httpx`),只讲平台公开的 `/v1` REST API。
5
+ 仓库按 `<品牌>-client-<语言>` 命名,其他语言的客户端与之平级(如 bazhuayu-client-js)。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ pip install bazhuayu-client
11
+ ```
12
+
13
+ ## 用法
14
+
15
+ ```python
16
+ from bazhuayu_client import Client
17
+
18
+ # base_url 缺省即生产端点 https://api-datahub.bazhuayu.com,本地/预发环境按需传入
19
+ with Client(api_key="demo-key") as client:
20
+ # 发现
21
+ result = client.search("评论", limit=10)
22
+ detail = client.get_app("demo/reviews-store-query") # 或卡片里的 app_id
23
+
24
+ # 调用(等到终态)并消费
25
+ run = client.call("demo/partner-reviews-api", {"product": "p-9001"})
26
+ for record in client.iterate_records(run["run_id"]):
27
+ print(record["content"], record["rating"])
28
+
29
+ # 盘点与对账
30
+ for r in client.iterate_runs(run_kind="production", created_from="2026-08-01T00:00:00Z"):
31
+ print(r["run_id"], r["state"], r["billing"])
32
+ print(client.billing(group_by="data_app", tz_offset=480)) # 按本地日/按 App 看花在哪
33
+
34
+ # 结果数据默认保留 90 天,要长期留存打保留标记
35
+ client.set_dataset_retention(run["dataset_id"], retained=True)
36
+ ```
37
+
38
+ `call()` 发起运行并轮询到终态;需要非阻塞语义时用 `run()` 拿 `run_id` 后自行
39
+ `get_run()` / `cancel()`。被点对点授权给自己的 App 不在市场结果里,用
40
+ `search(shared_with="me")` 查。全部方法与错误映射见 `src/bazhuayu_client/client.py`。
41
+
42
+ App 引用有两种形态,`get_app()` / `run()` / `call()` 及运行列表的 `data_app`
43
+ 过滤处等价可用:`<用户名>/<应用名>` 两段式引用(取自卡片的 `namespace` 与
44
+ `app_name`,适合人读,发布者改名后旧引用失效);不变标识 `app_id`
45
+ (`app_<hex>`,取自卡片/详情的 `app_id` 字段,改名免疫)。写进配置、定时任务
46
+ 等长期集成建议钉 `app_id`。
47
+
48
+ ## 配置
49
+
50
+ `Client()` 的 `base_url` / `api_key` 缺省时回退环境变量 `BAZHUAYU_BASE_URL` /
51
+ `BAZHUAYU_API_KEY`(模板见 `.env.example`;SDK 不自动加载 `.env`,由调用方
52
+ 自行加载),再回退生产端点 `https://api-datahub.bazhuayu.com` / 匿名。
53
+
54
+ ## OAuth 登录(账号密码,替代 api_key)
55
+
56
+ 除 api_key 外,也可用八爪鱼账号密码经 openapi `/token` 端点换取 identity JWT
57
+ 调用平台(两通道互斥,归属与计费同一账户):
58
+
59
+ ```python
60
+ from bazhuayu_client import Client, PasswordTokenProvider
61
+
62
+ provider = PasswordTokenProvider("user@example.com", "password")
63
+ with Client("https://datahub.example.com", token_provider=provider) as client:
64
+ client.call("demo/partner-reviews-api", {"product": "p-9001"})
65
+ ```
66
+
67
+ token 的缓存、快过期自动续期、refresh_token 一次性轮换与失效后密码重登都收在
68
+ provider 内,线程安全;提交 run 前默认要求 token 剩余有效期 ≥ 12h(不足则先
69
+ 强制刷新,可用 `Client(run_token_min_ttl=)` 调整)——平台入队时快照凭证、
70
+ 终态重放扣费,提交时 token 越新鲜,长运行结束时扣费凭证过期的敞口越小。
71
+ 注意 refresh_token 一次性使用:**多进程不要共享同一账号的凭据序列**,每个
72
+ 进程各建 provider(各自密码登录)即可。`token_url` 缺省生产端点,可用参数或
73
+ 环境变量 `BAZHUAYU_TOKEN_URL` 覆盖。`token_provider` 也接受任意返回当前有效
74
+ token 的无参 callable(自管续期时用)。
75
+
76
+ ## 测试
77
+
78
+ ```bash
79
+ uv run pytest -q # 离线单元测试(MockTransport,不依赖服务端)
80
+ ```
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "bazhuayu-client"
3
+ version = "0.2.6"
4
+ description = "八爪鱼 DataHub 官方 Python SDK:发现、调用、消费 Data App"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [{ name = "Bazhuayu DataHub Team" }]
8
+ keywords = ["bazhuayu", "datahub", "sdk", "data-app"]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Intended Audience :: Developers",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Typing :: Typed",
19
+ ]
20
+ requires-python = ">=3.10"
21
+ dependencies = ["httpx>=0.27"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://www.bazhuayu.com"
25
+
26
+ # 离线单元测试(tests/,MockTransport):`uv run pytest -q`;联调测试在平台仓库。
27
+ [dependency-groups]
28
+ dev = ["pytest>=8.0", "ruff>=0.6"]
29
+
30
+ [tool.ruff]
31
+ line-length = 100
32
+ target-version = "py310"
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E", "F", "W", "I", "UP", "B", "A"]
36
+
37
+ [tool.ruff.lint.per-file-ignores]
38
+ # input/type/format 等参数名有意贴平台 API 命名,豁免内置名遮蔽告警
39
+ "src/bazhuayu_client/client.py" = ["A002"]
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/bazhuayu_client"]
47
+
48
+ # sdist 是对外发行物:只装用户可见面,内部协作文档(AGENTS/CLAUDE/TESTING)与
49
+ # 测试不进包。发布渠道与命令见 AGENTS.md(不入 sdist)。
50
+ [tool.hatch.build.targets.sdist]
51
+ only-include = ["src", "README.md", "LICENSE", ".env.example"]
@@ -0,0 +1,12 @@
1
+ from bazhuayu_client.client import (
2
+ TERMINAL_STATES,
3
+ ApiError,
4
+ Client,
5
+ PasswordTokenProvider,
6
+ RunFailed,
7
+ TokenProvider,
8
+ __version__,
9
+ )
10
+
11
+ __all__ = ["Client", "ApiError", "RunFailed", "PasswordTokenProvider",
12
+ "TokenProvider", "TERMINAL_STATES", "__version__"]
@@ -0,0 +1,621 @@
1
+ """八爪鱼 DataHub Python SDK。
2
+
3
+ - 薄封装平台 REST API(/v1),不感知平台内部实现;
4
+ - `call()` = 循环续 wait=60 长轮询直到终态(缺省无总时限,可选 timeout);
5
+ - `iterate_records()` / `iterate_dataset_records()` / `iterate_runs()` 自动翻页,
6
+ 屏蔽分页细节;
7
+ - 服务端错误统一抛 ApiError(携带平台 Error 对象的 code/category/retryable);
8
+ 网络层异常(连接失败、超时)不做包装,直接透出 httpx 异常族。
9
+
10
+ 本包自包含(不依赖平台侧任何包),支持整目录 copy 改名发行(见 pyproject 注释)。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import json as _json
17
+ import os
18
+ import threading
19
+ import time
20
+ from collections.abc import Callable, Iterator
21
+ from importlib.metadata import PackageNotFoundError, version
22
+ from typing import Any, Protocol, runtime_checkable
23
+ from urllib.parse import quote
24
+
25
+ import httpx
26
+
27
+ try:
28
+ __version__ = version("bazhuayu-client")
29
+ except PackageNotFoundError: # 未安装、直接以源码路径运行时
30
+ __version__ = "0.0.0"
31
+
32
+ DEFAULT_BASE_URL = "https://api-datahub.bazhuayu.com"
33
+ ENV_BASE_URL = "BAZHUAYU_BASE_URL"
34
+ ENV_API_KEY = "BAZHUAYU_API_KEY"
35
+ ENV_TOKEN_URL = "BAZHUAYU_TOKEN_URL"
36
+ DEFAULT_TOKEN_URL = "https://openapi.bazhuayu.com/token"
37
+ API_PREFIX = "/v1"
38
+ # OAuth token 常规续期阈值:剩余有效期低于此值即后台刷新(提前量吸收时钟偏差
39
+ # 与在途请求耗时,避免拿着临期 token 撞 401)
40
+ TOKEN_REFRESH_LEEWAY = 300.0
41
+ # 提交 run(POST …/runs)前的强制新鲜度:平台入队时快照凭证、终态重放扣费,
42
+ # token 越新鲜,长 run 结束时扣费凭证过期的敞口越小
43
+ RUN_TOKEN_MIN_TTL = 12 * 3600.0
44
+ MAX_WAIT_PER_POLL = 60.0 # 服务端 wait 长轮询硬上限
45
+ MAX_ERROR_TEXT = 500 # ApiError.message 保留响应原文的截断上限
46
+ EXPORT_LIMIT = 10000 # export_records 单次导出上限;超量请改用 iterate_records
47
+ USER_AGENT = f"bazhuayu-client/{__version__}"
48
+
49
+ # 与平台统一状态机对齐的终态集合(自包含内联,勿 import 平台包)
50
+ TERMINAL_STATES = frozenset(
51
+ {"SUCCEEDED", "PARTIALLY_SUCCEEDED", "FAILED", "CANCELLED", "EXPIRED"})
52
+
53
+ # 有产出的终态:call(raise_on_failure=True) 不视为失败
54
+ _SUCCESS_STATES = frozenset({"SUCCEEDED", "PARTIALLY_SUCCEEDED"})
55
+
56
+
57
+ class ApiError(Exception):
58
+ """服务端错误的统一异常:映射平台 Error 信封的 code/category/retryable;
59
+ 非平台信封(网关 502、2xx 畸形响应)归一为 http-error / invalid-response。"""
60
+
61
+ def __init__(self, status_code: int, error: dict[str, Any]):
62
+ self.status_code = status_code
63
+ self.code = error.get("code", "error")
64
+ self.category = error.get("category", "internal")
65
+ self.retryable = bool(error.get("retryable"))
66
+ self.message = error.get("message", "")
67
+ # 字段级问题 [{path, message}]:message 压成一句话后无法按字段定位,
68
+ # 平台带了就原样透出(非 list 形态一律视为没带)
69
+ details = error.get("details")
70
+ self.details: list[dict[str, str]] | None = details if isinstance(details, list) else None
71
+ super().__init__(f"[{self.code}] {self.message}")
72
+
73
+
74
+ class RunFailed(Exception):
75
+ """call(raise_on_failure=True) 时运行进入无产出终态(FAILED/CANCELLED/
76
+ EXPIRED)抛出;run 属性携带完整运行对象供排查。"""
77
+
78
+ def __init__(self, run: dict[str, Any]):
79
+ self.run = run
80
+ super().__init__(f"运行终态 {run.get('state')}(run_id={run.get('run_id')})")
81
+
82
+
83
+ def _invalid_response(status_code: int, text: str) -> ApiError:
84
+ """2xx 但响应体不符平台成功信封(非 JSON、缺 data、畸形运行对象…)时
85
+ 统一抛这个,别把 JSONDecodeError / KeyError 裸漏给调用方。"""
86
+ return ApiError(status_code, {
87
+ "code": "invalid-response", "category": "internal",
88
+ "message": (text or f"HTTP {status_code}")[:MAX_ERROR_TEXT]})
89
+
90
+
91
+ def _quote_id(value: str) -> str:
92
+ """单段路径参数(run_id / dataset_id 等)本不该含 / 与保留字符:
93
+ 全量转义,防止畸形 id 改写请求路径。"""
94
+ return quote(value, safe="")
95
+
96
+
97
+ def _quote_app_id(app_id: str) -> str:
98
+ """App 引用两种形态都要过这里:两段式 `<用户名>/<应用名>` 需保留 /,
99
+ 逐段转义;单段 app_id(`app_<hex>`)天然单段同样适用。
100
+ 空段与 . / .. 段直接拒绝,防路径穿越。"""
101
+ segments = app_id.split("/")
102
+ if any(seg in ("", ".", "..") for seg in segments):
103
+ raise ValueError(f"非法 app_id: {app_id!r}")
104
+ return "/".join(quote(seg, safe="") for seg in segments)
105
+
106
+
107
+ def _jwt_exp(token: str) -> float | None:
108
+ """尽力解析 JWT payload 的 exp(epoch 秒);非 JWT / 无 exp 返回 None。
109
+ 只做本地读取,不验签——有效性由服务端判定。"""
110
+ try:
111
+ payload_b64 = token.split(".")[1]
112
+ payload_b64 += "=" * (-len(payload_b64) % 4)
113
+ payload = _json.loads(base64.urlsafe_b64decode(payload_b64))
114
+ exp = payload.get("exp")
115
+ return float(exp) if isinstance(exp, (int, float)) else None
116
+ except Exception:
117
+ return None
118
+
119
+
120
+ @runtime_checkable
121
+ class TokenProvider(Protocol):
122
+ """Client(token_provider=) 接受的对象形态:返回当前有效的 Bearer token。
123
+
124
+ min_ttl 是调用方要求的最低剩余有效期(秒):实现应在剩余不足时先续期再
125
+ 返回;无法续到更长也应返回现有 token(尽力语义),不要在此抛过期错误。
126
+ 也可以直接传 `Callable[[], str]`(无 min_ttl 语义,提交 run 不强制刷新)。
127
+ """
128
+
129
+ def token(self, min_ttl: float = 0.0) -> str: ...
130
+
131
+
132
+ class PasswordTokenProvider:
133
+ """OAuth 密码模式 token 提供者:向 openapi /token 端点用用户名密码换取
134
+ identity JWT,并在快过期时自动续期,供 Client(token_provider=) 使用。
135
+
136
+ - access_token 持内存缓存,剩余有效期低于 max(refresh_leeway, min_ttl)
137
+ 时刷新;有效期优先取响应 expires_in,缺失时回退解析 JWT exp;
138
+ - refresh_token **一次性使用**(用一次即作废并换发新的):全程持锁单飞,
139
+ 并发调用只有一次真实刷新;多进程勿共享同一 provider 的凭据序列;
140
+ - refresh_token 失效(绝对 7 天到期、被其他进程顶掉)时回落用户名密码
141
+ 重新登录,对调用方透明;
142
+ - 凭证红线:username/password/refresh_token 不进日志、异常与 repr。
143
+
144
+ token_url 缺省回退环境变量 BAZHUAYU_TOKEN_URL,再回退生产端点
145
+ DEFAULT_TOKEN_URL;transport 仅供离线测试注入。
146
+ """
147
+
148
+ def __init__(self, username: str, password: str, *, token_url: str | None = None,
149
+ refresh_leeway: float = TOKEN_REFRESH_LEEWAY, timeout: float = 30.0,
150
+ transport: httpx.BaseTransport | None = None):
151
+ self._username = username
152
+ self._password = password
153
+ self._token_url = token_url or os.environ.get(ENV_TOKEN_URL) or DEFAULT_TOKEN_URL
154
+ self._leeway = refresh_leeway
155
+ self._lock = threading.Lock()
156
+ self._access_token: str | None = None
157
+ self._refresh_token: str | None = None
158
+ self._expires_at = 0.0 # epoch 秒;与 JWT exp 同一时间轴
159
+ self._http = httpx.Client(timeout=timeout, transport=transport,
160
+ headers={"User-Agent": USER_AGENT})
161
+
162
+ def __repr__(self) -> str: # 凭证红线:不回显 username/password
163
+ return f"PasswordTokenProvider(token_url={self._token_url!r})"
164
+
165
+ def token(self, min_ttl: float = 0.0) -> str:
166
+ """返回当前有效 token;剩余有效期不足 max(refresh_leeway, min_ttl) 时
167
+ 先刷新。刷新后即便仍不满足 min_ttl(服务端签发的有效期本就更短)也
168
+ 返回新 token——已是能拿到的最新鲜凭证。"""
169
+ threshold = max(self._leeway, min_ttl)
170
+ with self._lock:
171
+ # 锁内复查:并发到期时第一个线程刷新,其余醒来直接命中新 token
172
+ if self._access_token and self._expires_at - time.time() >= threshold:
173
+ return self._access_token
174
+ self._refresh_locked()
175
+ assert self._access_token is not None
176
+ return self._access_token
177
+
178
+ def close(self) -> None:
179
+ self._http.close()
180
+
181
+ def __enter__(self) -> PasswordTokenProvider:
182
+ return self
183
+
184
+ def __exit__(self, *exc: object) -> None:
185
+ self.close()
186
+
187
+ def _refresh_locked(self) -> None:
188
+ """持锁前提下续期:优先 refresh_token,服务端拒绝(过期/被顶掉)时
189
+ 回落密码重登。网络层异常原样透出,不清 refresh_token(请求可能根本
190
+ 没到服务端,凭据未必已作废)。"""
191
+ if self._refresh_token:
192
+ try:
193
+ self._grant({"grant_type": "refresh_token",
194
+ "refresh_token": self._refresh_token})
195
+ return
196
+ except ApiError:
197
+ self._refresh_token = None
198
+ self._grant({"grant_type": "password",
199
+ "username": self._username, "password": self._password})
200
+
201
+ def _grant(self, payload: dict[str, Any]) -> None:
202
+ resp = self._http.post(self._token_url, json=payload)
203
+ if resp.status_code >= 400:
204
+ # 只带服务端响应原文(不含我方请求体);格式非平台信封,统一归为 auth
205
+ raise ApiError(resp.status_code, {
206
+ "code": "auth-error", "category": "auth",
207
+ "message": (resp.text or f"HTTP {resp.status_code}")[:MAX_ERROR_TEXT]})
208
+ try:
209
+ data = resp.json()
210
+ except ValueError:
211
+ data = None
212
+ access = data.get("access_token") if isinstance(data, dict) else None
213
+ if not isinstance(access, str) or not access:
214
+ # 固定消息,不带响应原文——2xx 畸形体仍可能含凭证材料
215
+ raise ApiError(resp.status_code, {
216
+ "code": "invalid-response", "category": "internal",
217
+ "message": "token 响应缺少 access_token"})
218
+ expires_in = data.get("expires_in")
219
+ if isinstance(expires_in, (int, float)) and not isinstance(expires_in, bool):
220
+ expires_at = time.time() + float(expires_in)
221
+ else:
222
+ exp = _jwt_exp(access)
223
+ if exp is None:
224
+ raise ApiError(resp.status_code, {
225
+ "code": "invalid-response", "category": "internal",
226
+ "message": "token 响应缺少 expires_in 且 JWT 无 exp,无法调度续期"})
227
+ expires_at = exp
228
+ # 轮换:响应带新 refresh_token 则就地替换;没带则置空(旧的已一次性
229
+ # 用掉,留着必失败),下次续期直接走密码重登
230
+ new_refresh = data.get("refresh_token")
231
+ self._refresh_token = new_refresh if isinstance(new_refresh, str) and new_refresh else None
232
+ self._access_token = access
233
+ self._expires_at = expires_at
234
+
235
+
236
+ class Client:
237
+ def __init__(self, base_url: str | None = None, api_key: str | None = None,
238
+ timeout: float = 90.0, transport: httpx.BaseTransport | None = None,
239
+ *, token_provider: TokenProvider | Callable[[], str] | None = None,
240
+ run_token_min_ttl: float = RUN_TOKEN_MIN_TTL):
241
+ """base_url / api_key 缺省时依次回退环境变量 BAZHUAYU_BASE_URL /
242
+ BAZHUAYU_API_KEY(见 .env.example),再回退 DEFAULT_BASE_URL / 匿名。
243
+
244
+ token_provider 与 api_key 互斥:传入后 Authorization 改为每请求向
245
+ provider 取值(见 PasswordTokenProvider),且不再回退 BAZHUAYU_API_KEY;
246
+ provider 生命周期由调用方管理(close() 不关它)。
247
+ run_token_min_ttl 是提交 run(POST …/runs)前要求的 token 最低剩余
248
+ 有效期(秒,缺省 12h,仅对带 min_ttl 语义的 provider 生效):入队时
249
+ 平台快照凭证、终态重放扣费,提交前强制刷新可收窄长 run 扣费失败敞口。
250
+ """
251
+ if api_key and token_provider is not None:
252
+ raise ValueError("api_key 与 token_provider 互斥,只能传其一")
253
+ base_url = base_url or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL
254
+ # provider 在场即为显式选择 OAuth 通道,env 里的 api_key 不再参与
255
+ api_key = api_key or (None if token_provider is not None
256
+ else os.environ.get(ENV_API_KEY))
257
+ self._token_provider = token_provider
258
+ self._run_token_min_ttl = run_token_min_ttl
259
+ headers = {"User-Agent": USER_AGENT}
260
+ if api_key:
261
+ headers["Authorization"] = f"Bearer {api_key}"
262
+ self._http = httpx.Client(base_url=base_url, headers=headers,
263
+ timeout=timeout, transport=transport)
264
+
265
+ def close(self) -> None:
266
+ self._http.close()
267
+
268
+ def __enter__(self) -> Client:
269
+ return self
270
+
271
+ def __exit__(self, *exc: object) -> None:
272
+ self.close()
273
+
274
+ # ── 底层请求 ──────────────────────────────────────────────
275
+ def _poll_timeout(self, wait: float) -> httpx.Timeout | None:
276
+ """wait 长轮询请求单独放宽 read 超时(wait+10s 缓冲),避免与较小的
277
+ Client(timeout=) 配置互杀;客户端 read 本就更宽或未设限则不覆盖。"""
278
+ t = self._http.timeout
279
+ if t.read is None or t.read >= wait + 10.0:
280
+ return None
281
+ return httpx.Timeout(connect=t.connect, read=wait + 10.0,
282
+ write=t.write, pool=t.pool)
283
+
284
+ def _auth_headers(self, method: str, path: str) -> dict[str, str] | None:
285
+ """token_provider 通道的每请求取值;api_key 通道仍冻结在构造头,返回 None。
286
+
287
+ 提交 run(POST …/runs)按 run_token_min_ttl 要求强制新鲜度;其余请求
288
+ (轮询/读结果)走 provider 自身的常规续期阈值。纯 callable 形态无
289
+ min_ttl 语义,直接取值。
290
+ """
291
+ tp = self._token_provider
292
+ if tp is None:
293
+ return None
294
+ if isinstance(tp, TokenProvider):
295
+ min_ttl = (self._run_token_min_ttl
296
+ if method == "POST" and path.endswith("/runs") else 0.0)
297
+ return {"Authorization": f"Bearer {tp.token(min_ttl=min_ttl)}"}
298
+ return {"Authorization": f"Bearer {tp()}"}
299
+
300
+ def _request(self, method: str, path: str, *, params: dict[str, Any] | None = None,
301
+ json: dict[str, Any] | None = None,
302
+ timeout: httpx.Timeout | None = None) -> httpx.Response:
303
+ params = {k: v for k, v in (params or {}).items() if v is not None}
304
+ # timeout=None 对 httpx 意为"禁用超时",缺省须回落客户端配置的哨兵值
305
+ resp = self._http.request(
306
+ method, API_PREFIX + path, params=params, json=json,
307
+ headers=self._auth_headers(method, path),
308
+ timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT)
309
+ if resp.status_code >= 400:
310
+ try:
311
+ payload = resp.json()
312
+ except ValueError:
313
+ payload = None
314
+ error = payload.get("error") if isinstance(payload, dict) else None
315
+ if not isinstance(error, dict):
316
+ # error 字段非 dict(裸字符串、FastAPI 校验数组…)按非信封处理
317
+ error = {}
318
+ if not error.get("message"):
319
+ # 非平台信封的错误(网关 502、框架直出的 422…):别退化成一句空
320
+ # 消息的 internal error,把原文带上,调用方才有的可查。
321
+ error = {"code": error.get("code") or "http-error",
322
+ "category": error.get("category") or "internal",
323
+ "message": (resp.text or f"HTTP {resp.status_code}")[:MAX_ERROR_TEXT],
324
+ "retryable": bool(error.get("retryable"))}
325
+ raise ApiError(resp.status_code, error)
326
+ return resp
327
+
328
+ def _data(self, method: str, path: str, **kw: Any) -> Any:
329
+ """请求并解平台成功信封 {"data": ...};204/空体返回 None(cancel 场景)。"""
330
+ resp = self._request(method, path, **kw)
331
+ if resp.status_code == 204 or not resp.content:
332
+ return None
333
+ try:
334
+ payload = resp.json()
335
+ except ValueError:
336
+ payload = None
337
+ if not isinstance(payload, dict) or "data" not in payload:
338
+ raise _invalid_response(resp.status_code, resp.text)
339
+ return payload["data"]
340
+
341
+ # ── 发现 ──────────────────────────────────────────────────
342
+ def meta(self) -> dict[str, Any]:
343
+ """平台元信息(匿名可读的部署级常量),目前只有记账币种 currency
344
+ (ISO 4217 码):金额字段(unit_price / amount / total_spent)一律
345
+ 不带单位,单位以这里为准。"""
346
+ return self._data("GET", "/meta")
347
+
348
+ def search(self, query: str = "", *, type: str | None = None,
349
+ capability: str | None = None, owner: str | None = None,
350
+ shared_with: str | None = None, scope: str | None = None,
351
+ status: str | None = None,
352
+ offset: int = 0, limit: int = 5) -> dict[str, Any]:
353
+ """检索数据应用(市场视角)。
354
+
355
+ 卡片带两种 App 引用:namespace + app_name 组成 `<用户名>/<应用名>`
356
+ 两段式引用(改名后失效),以及改名免疫的不变标识 app_id
357
+ (`app_<hex>`,长期集成建议钉它);两种形态在 get_app/run/call
358
+ 处等价可用。
359
+ capability 按能力分类过滤:data-acquisition(数据获取)/
360
+ data-processing(数据处理),服务端只认这两个字面量。没有按平台/
361
+ 站点的过滤维度:要找特定平台的 App,把平台名并入 query 检索词
362
+ (卡片 keywords 参与相关度打分)。
363
+ owner="me"(需 api_key)切换为归属视角:只列自己名下的 App(含未上架
364
+ 形态),卡片额外带 status/scope;服务端只接受字面量 "me"。
365
+ shared_with="me"(需 api_key)列出被发布者点对点授权给我的 shared App
366
+ ——这类 App 不在市场结果里,不查这个视角就看不见。两个视角互斥
367
+ (同时传服务端 400),分开查询。
368
+ scope 无视角时是一等属性维度,直接决定候选集:缺省/"public"=市场;
369
+ "private"(需 api_key)=我的私有 App;"shared"(需 api_key)=可见的
370
+ 全部 shared App(我分享出去的+分享给我的,双向);"all"=可见全集
371
+ (匿名时恰为市场,自然退化)。属性模式 status 缺省 "published"
372
+ (显式可查 deprecated;draft 恒空,草稿盘点走 owner="me"),卡片
373
+ 附 App 自身 scope 字段。配合 owner="me" 时 scope/status 退为视角内
374
+ 再过滤(盘点收窄,缺省全出)。词表外取值服务端 422 拒绝。
375
+ """
376
+ return self._data("GET", "/data-apps", params={
377
+ "q": query, "type": type, "capability": capability, "owner": owner,
378
+ "shared_with": shared_with, "scope": scope, "status": status,
379
+ "offset": offset, "limit": limit})
380
+
381
+ def get_app(self, app_id: str) -> dict[str, Any]:
382
+ """app_id 接受两种引用形态(run()/call() 与 list_runs* 的 data_app
383
+ 过滤同此口径):
384
+
385
+ - `<用户名>/<应用名>` 两段式引用(取自卡片的 namespace 与 app_name),
386
+ 适合人读与临时调用;发布者改名后旧引用失效,重新检索取新引用;
387
+ - 平台不变标识 app_id(`app_<hex>`,取自卡片/detail 的 app_id 字段),
388
+ 改名免疫,长期集成建议钉它。
389
+
390
+ 非公开 App(draft/private)仅归属用户带 key 可见,其他人 404。"""
391
+ return self._data("GET", f"/data-apps/{_quote_app_id(app_id)}")
392
+
393
+ # ── 调用 ──────────────────────────────────────────────────
394
+ def run(self, app_id: str, input: dict[str, Any], *, wait: float = 0,
395
+ max_records: int | None = None, version: str | None = None,
396
+ triggered_by: str = "sdk") -> dict[str, Any]:
397
+ """发起运行。sync App 直接返回终态(含 sample_records);async App 立即返回 Run。
398
+
399
+ version 钉住 App 的历史版本运行(输入契约与价格都按该版本);缺省最新版。
400
+ 钉住已 yank 版本的新 run 被拒(422 version-yanked,在途 run 不受影响)。
401
+ triggered_by 标注调用来源(sdk/mcp/cli),供上层封装(如 MCP server)透传;
402
+ 服务端只收 `^[a-z][a-z0-9_-]{0,31}$`。
403
+ 不可见的 App(draft/private、未被授权的 shared)是 404;可见但维护中
404
+ (accepting_runs=false)是 403 app-not-accepting-runs;钱包欠费未解封
405
+ 是 402 balance-negative(充值后自动解封)。
406
+ """
407
+ # wait=0 表示不长轮询:置 None 让 _request 连同其他未传参数一并过滤掉,
408
+ # 不在 query string 里出现(下同 get_run)
409
+ return self._data("POST", f"/data-apps/{_quote_app_id(app_id)}/runs", json=input, params={
410
+ "wait": wait or None,
411
+ "max_records": max_records,
412
+ "version": version,
413
+ "triggered_by": triggered_by,
414
+ }, timeout=self._poll_timeout(wait) if wait else None)
415
+
416
+ def call(self, app_id: str, input: dict[str, Any], *,
417
+ max_records: int | None = None, version: str | None = None,
418
+ poll_interval: float = 0.2,
419
+ timeout: float | None = None, raise_on_failure: bool = False) -> dict[str, Any]:
420
+ """发起并等到终态(循环续 wait 长轮询)。version 语义同 run()。
421
+
422
+ timeout 为总时限(秒),超过抛 TimeoutError(消息含 run_id);超时不会
423
+ 取消服务端运行,需要时由调用方拿 run_id 自行 cancel() 或续查。
424
+ 缺省 None 维持无总时限。
425
+ 终态含 FAILED / CANCELLED / EXPIRED:缺省一律正常返回,是否成功由
426
+ 调用方检查 state;raise_on_failure=True 时无产出终态抛 RunFailed
427
+ (PARTIALLY_SUCCEEDED 视为有产出,正常返回)。未知的非终态 state
428
+ (未来新增的中间态)会继续轮询。
429
+ """
430
+ deadline = time.monotonic() + timeout if timeout is not None else None
431
+
432
+ def next_wait() -> float:
433
+ # 剩余时限不足一轮时收缩长轮询,别让服务端 hold 超出总时限
434
+ if deadline is None:
435
+ return MAX_WAIT_PER_POLL
436
+ return min(MAX_WAIT_PER_POLL, max(deadline - time.monotonic(), 0.001))
437
+
438
+ run = self.run(app_id, input, wait=next_wait(), max_records=max_records,
439
+ version=version)
440
+ while True:
441
+ state = run.get("state") if isinstance(run, dict) else None
442
+ if state in TERMINAL_STATES:
443
+ if raise_on_failure and state not in _SUCCESS_STATES:
444
+ raise RunFailed(run)
445
+ return run
446
+ if not state or not run.get("run_id"):
447
+ # 2xx 但运行对象缺 state/run_id:明确报错,别 KeyError 或永久轮询
448
+ raise _invalid_response(200, f"运行响应缺少 state/run_id: {run!r}")
449
+ if deadline is not None and time.monotonic() >= deadline:
450
+ # 带上 run_id:超时不取消服务端运行,调用方需要句柄去 cancel/续查
451
+ raise TimeoutError(
452
+ f"call() 超过总时限 {timeout}s"
453
+ f"(run_id={run['run_id']},state={state},运行仍在服务端继续)")
454
+ time.sleep(poll_interval)
455
+ run = self.get_run(run["run_id"], wait=next_wait())
456
+
457
+ # ── 运行管理 ──────────────────────────────────────────────
458
+ def get_run(self, run_id: str, *, wait: float = 0) -> dict[str, Any]:
459
+ """查询单个运行。wait>0 时长轮询:服务端 hold 到状态变化或超时。
460
+
461
+ 只读得到自己发起的 run(他人的与不存在的 run_id 一律 404);输入回显中
462
+ App 契约标 sensitive 的字段是掩码——明文只进不出,本人回读也是掩码。
463
+ progress.status_text 是 App 自报的人读状态(有则透出,可能为 null)。
464
+ warnings 是结构化运行告警(如计费取量缺失 billing-qty-missing),
465
+ 仅详情透出,list_runs 系列不带。
466
+ """
467
+ return self._data("GET", f"/runs/{_quote_id(run_id)}",
468
+ params={"wait": wait or None},
469
+ timeout=self._poll_timeout(wait) if wait else None)
470
+
471
+ def list_runs_page(self, *, status: str | None = None, data_app: str | None = None,
472
+ triggered_by: str | None = None, run_kind: str | None = None,
473
+ credential: str | None = None, created_from: str | None = None,
474
+ created_to: str | None = None, offset: int = 0,
475
+ limit: int = 50) -> dict[str, Any]:
476
+ """按页列出自己的运行(需 api_key),返回含 items 与 pagination 的信封。
477
+
478
+ 归属按**用户**:同一个人换通道(SDK 的 key、门户)发起的 run 都在这一个
479
+ 列表里;pagination.total 是筛选后的总数。筛选项可组合:
480
+
481
+ - status:运行状态;
482
+ - data_app:App 引用,`<用户名>/<应用名>` 两段式或不变标识 app_id
483
+ 均可(口径见 get_app;解析不到得空结果集,不报错——报错会泄露
484
+ 私有 App 的存在性);
485
+ - triggered_by:来源渠道(sdk / mcp / api / cli);
486
+ - run_kind:运行口径,production 生产 / test 作者调试(调试流量不冲稀
487
+ 生产视角的分页);
488
+ - credential:发起凭证的非密稳定标识(排查是哪把 key 在消耗额度),
489
+ 取值与 billing(group_by="credential") 的分组键同源;
490
+ - created_from / created_to:发起时间范围,ISO-8601 绝对时刻、前闭后开。
491
+ """
492
+ return self._data("GET", "/runs", params={
493
+ "status": status, "data_app": data_app, "triggered_by": triggered_by,
494
+ "run_kind": run_kind, "credential": credential,
495
+ "created_from": created_from, "created_to": created_to,
496
+ "offset": offset, "limit": limit})
497
+
498
+ def list_runs(self, *, status: str | None = None, data_app: str | None = None,
499
+ triggered_by: str | None = None, run_kind: str | None = None,
500
+ credential: str | None = None, created_from: str | None = None,
501
+ created_to: str | None = None, offset: int = 0,
502
+ limit: int = 50) -> list[dict[str, Any]]:
503
+ """列出自己的运行(需 api_key),只要 items;筛选项同 list_runs_page。
504
+
505
+ 需要筛选后的总数/翻页信息用 list_runs_page(),全量遍历用 iterate_runs()。
506
+ """
507
+ return self.list_runs_page(
508
+ status=status, data_app=data_app, triggered_by=triggered_by,
509
+ run_kind=run_kind, credential=credential, created_from=created_from,
510
+ created_to=created_to, offset=offset, limit=limit)["items"]
511
+
512
+ def iterate_runs(self, *, status: str | None = None, data_app: str | None = None,
513
+ triggered_by: str | None = None, run_kind: str | None = None,
514
+ credential: str | None = None, created_from: str | None = None,
515
+ created_to: str | None = None,
516
+ batch: int = 100) -> Iterator[dict[str, Any]]:
517
+ """自动翻页迭代自己的运行(屏蔽分页);筛选项同 list_runs_page。"""
518
+ return self._paginate(
519
+ lambda offset: self.list_runs_page(
520
+ status=status, data_app=data_app, triggered_by=triggered_by,
521
+ run_kind=run_kind, credential=credential, created_from=created_from,
522
+ created_to=created_to, offset=offset, limit=batch),
523
+ key="items")
524
+
525
+ def cancel(self, run_id: str) -> dict[str, Any] | None:
526
+ """取消运行。返回更新后的运行对象;服务端空体响应时返回 None。"""
527
+ return self._data("POST", f"/runs/{_quote_id(run_id)}/cancel")
528
+
529
+ # ── 结果获取 ──────────────────────────────────────────────
530
+ def get_records(self, run_id: str, *, offset: int = 0, limit: int = 100,
531
+ fields: str | None = None) -> dict[str, Any]:
532
+ """按页取 run 结果。fields 为逗号分隔的字段投影;返回含 records 与
533
+ pagination 的信封,大批量请改用 iterate_records()。"""
534
+ return self._data("GET", f"/runs/{_quote_id(run_id)}/records",
535
+ params={"offset": offset, "limit": limit, "fields": fields})
536
+
537
+ def _paginate(self, fetch: Callable[[int], dict[str, Any]],
538
+ key: str = "records") -> Iterator[dict[str, Any]]:
539
+ """fetch(offset) → {key: [...], "pagination": {count, has_more}};
540
+ key 随端点而异(记录页是 records,运行列表是 items)。"""
541
+ offset = 0
542
+ while True:
543
+ page = fetch(offset)
544
+ records = page[key]
545
+ yield from records
546
+ if not page["pagination"]["has_more"]:
547
+ return
548
+ if not records:
549
+ # has_more 为真但本页 0 条:offset 无法前进,继续请求必死循环,
550
+ # 按服务端契约违例明确报错,不静默截断。
551
+ raise _invalid_response(200, "分页响应 has_more=true 但本页 0 条记录")
552
+ # 按实际取回的条数推进,不信 pagination.count——两者契约上相等,
553
+ # 服务端不一致时以实际条数为准,避免静默跳过/重复记录
554
+ offset += len(records)
555
+
556
+ def iterate_records(self, run_id: str, *, batch: int = 500,
557
+ fields: str | None = None) -> Iterator[dict[str, Any]]:
558
+ """自动翻页迭代 run 结果(大批量出口,屏蔽分页)。fields 同 get_records。"""
559
+ return self._paginate(
560
+ lambda offset: self.get_records(run_id, offset=offset, limit=batch, fields=fields))
561
+
562
+ def export_records(self, run_id: str, format: str = "jsonl") -> str:
563
+ """jsonl / csv 文本导出(写文件由调用方决定)。
564
+
565
+ 单次导出上限 EXPORT_LIMIT(10000)条,超量部分不包含在返回文本里;
566
+ 需要全量请改用 iterate_records()。
567
+ """
568
+ resp = self._request("GET", f"/runs/{_quote_id(run_id)}/records",
569
+ params={"format": format, "limit": EXPORT_LIMIT})
570
+ return resp.text
571
+
572
+ # ── 数据集 / 账户 ─────────────────────────────────────────
573
+ def list_datasets(self, *, offset: int = 0, limit: int = 50) -> list[dict[str, Any]]:
574
+ """列出自己的数据集(需 api_key),外加平台显式公开的种子/样例集。
575
+
576
+ run 产出的结果数据保留 90 天(按创建时间计);要长期留存的数据集用
577
+ set_dataset_retention() 打保留标记(卡片的 retained 字段即该标记)。
578
+ """
579
+ return self._data("GET", "/datasets", params={"offset": offset, "limit": limit})["items"]
580
+
581
+ def set_dataset_retention(self, dataset_id: str,
582
+ retained: bool = True) -> dict[str, Any]:
583
+ """置/撤数据集保留标记(幂等):打了标记的数据集不参与 90 天到期清理。
584
+
585
+ 标记是显式声明,读取不续期;只能标记自己 run 产出的数据集(他人的、
586
+ 平台公开种子集、不存在的一律 404)。
587
+ """
588
+ return self._data("PUT", f"/datasets/{_quote_id(dataset_id)}/retention",
589
+ json={"retained": retained})
590
+
591
+ def get_dataset_records(self, dataset_id: str, *, offset: int = 0,
592
+ limit: int = 100) -> dict[str, Any]:
593
+ """按页取数据集记录,返回含 records 与 pagination 的信封;
594
+ 大批量请改用 iterate_dataset_records()。"""
595
+ return self._data("GET", f"/datasets/{_quote_id(dataset_id)}/records",
596
+ params={"offset": offset, "limit": limit})
597
+
598
+ def iterate_dataset_records(self, dataset_id: str, *,
599
+ batch: int = 500) -> Iterator[dict[str, Any]]:
600
+ """自动翻页迭代数据集记录(大批量出口,屏蔽分页)。"""
601
+ return self._paginate(
602
+ lambda offset: self.get_dataset_records(dataset_id, offset=offset, limit=batch))
603
+
604
+ def account(self) -> dict[str, Any]:
605
+ """当前 api_key 的账户信息(配额、用量等,以平台返回为准)。"""
606
+ return self._data("GET", "/account")
607
+
608
+ def billing(self, *, group_by: str = "day", created_from: str | None = None,
609
+ created_to: str | None = None, tz_offset: int = 0) -> dict[str, Any]:
610
+ """账单聚合(需 api_key):区间合计 + 分组明细,只含数据费。
611
+
612
+ group_by:day 按日(升序)/ data_app 按 App / credential 按发起凭证
613
+ (后两者按金额降序)。created_from / created_to 与 list_runs 同词汇
614
+ (ISO-8601 绝对时刻、前闭后开、按 run 发起时间归属),带同一组参数查
615
+ list_runs 就是这批数字对应的 run。
616
+ tz_offset 是 day 桶的时区偏移(相对 UTC 的分钟数,UTC+8 传 480),
617
+ 缺省 0 即 UTC 日;只影响日归属,不改金额与区间语义。
618
+ """
619
+ return self._data("GET", "/billing", params={
620
+ "group_by": group_by, "created_from": created_from,
621
+ "created_to": created_to, "tz_offset": tz_offset or None})
File without changes