omnidoc 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omnidoc-0.1.0/PKG-INFO +104 -0
- omnidoc-0.1.0/README.md +89 -0
- omnidoc-0.1.0/pyproject.toml +32 -0
- omnidoc-0.1.0/setup.cfg +4 -0
- omnidoc-0.1.0/src/omnidoc/__init__.py +16 -0
- omnidoc-0.1.0/src/omnidoc/client.py +302 -0
- omnidoc-0.1.0/src/omnidoc/errors.py +72 -0
- omnidoc-0.1.0/src/omnidoc/webhooks.py +44 -0
- omnidoc-0.1.0/src/omnidoc.egg-info/PKG-INFO +104 -0
- omnidoc-0.1.0/src/omnidoc.egg-info/SOURCES.txt +12 -0
- omnidoc-0.1.0/src/omnidoc.egg-info/dependency_links.txt +1 -0
- omnidoc-0.1.0/src/omnidoc.egg-info/requires.txt +4 -0
- omnidoc-0.1.0/src/omnidoc.egg-info/top_level.txt +1 -0
- omnidoc-0.1.0/tests/test_client.py +274 -0
omnidoc-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: omnidoc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OmniDoc 开放 API 的 Python 客户端:上传、翻译、下载、积分查询与 Webhook 验签
|
|
5
|
+
Project-URL: Homepage, https://omnidoc.top/
|
|
6
|
+
Project-URL: Documentation, https://docs.omnidoc.top/
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests>=2.31
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
15
|
+
|
|
16
|
+
# omnidoc(Python)
|
|
17
|
+
|
|
18
|
+
OmniDoc 开放 API 的 Python 客户端:上传文件、创建翻译任务、等待结果、下载产物,并提供积分查询和 Webhook 验签。接口说明见 <https://docs.omnidoc.top/>。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
发布到 PyPI 之前,可以从源码安装:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ./libs/omnidoc-python
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
发布后使用 `pip install omnidoc`。
|
|
29
|
+
|
|
30
|
+
依赖只有 `requests`,需要 Python 3.9 或更新版本。
|
|
31
|
+
|
|
32
|
+
## 快速开始
|
|
33
|
+
|
|
34
|
+
在环境变量里设置 API Key(以 `sk_` 开头,在 API 中心创建):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export OMNIDOC_API_KEY="sk_your_key"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from omnidoc import OmniDocClient
|
|
42
|
+
|
|
43
|
+
client = OmniDocClient() # 读取 OMNIDOC_API_KEY
|
|
44
|
+
files = client.translate_file("report.docx", "en", output_dir="out")
|
|
45
|
+
print(files)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`translate_file` 会依次完成:上传(presign → 直传 → finalize)、创建翻译任务、轮询直到结束、下载全部产物。任务失败时抛出 `JobFailedError`,`quota_exceeded` 为 True 表示余额不足,未扣费。
|
|
49
|
+
|
|
50
|
+
## 分步调用
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
doc_id = client.upload("report.docx")
|
|
54
|
+
job = client.create_job(doc_id, "en", output_mode="bilingual", engine="xr-397b")
|
|
55
|
+
job = client.wait_job(job["id"], poll_interval=3, timeout=1800)
|
|
56
|
+
for artifact in client.list_artifacts(job["id"]):
|
|
57
|
+
client.download_artifact(artifact["id"], "out", fallback_name=f"{artifact['id']}.{artifact.get('format', 'bin')}")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`create_job` 每次调用会生成新的 `Idempotency-Key`;内部重试沿用同一个键,不会重复创建或扣费。需要自己重试同一次提交时,传入相同的 `idempotency_key`。
|
|
61
|
+
|
|
62
|
+
## 查询与其他接口
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
client.get_job(job_id) # 任务状态(不扣积分)
|
|
66
|
+
client.cancel_job(job_id) # 取消任务
|
|
67
|
+
client.credit_balance() # 积分余额(不扣积分)
|
|
68
|
+
client.credit_operations() # 积分流水
|
|
69
|
+
client.usage_summary() # 用量汇总
|
|
70
|
+
|
|
71
|
+
# 尚未封装的接口,按 docs.omnidoc.top 上的路径直接调用:
|
|
72
|
+
client.get_json("<路径>", params={...})
|
|
73
|
+
client.post_json("<路径>", json={...})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 错误与重试
|
|
77
|
+
|
|
78
|
+
- API 错误统一抛出 `OmniDocError`,带有 `status_code`、`code`、`message` 与 `request_id`(对应响应头 `X-Request-Id`)。
|
|
79
|
+
- 429 与 5xx 会按 `Retry-After` 或指数退避重试,最多 `max_retries` 次(默认 4)。
|
|
80
|
+
- `AUTH_API_KEY_QUOTA_EXCEEDED` 不重试:该 Key 的预算已用尽,需要先调高预算。
|
|
81
|
+
|
|
82
|
+
## Webhook 验签
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from omnidoc import parse_webhook
|
|
86
|
+
|
|
87
|
+
event = parse_webhook(raw_body, request.headers["X-Webhook-Signature"], secret)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
必须使用收到的原始请求体字节验签。签名或时间戳不符时抛出 `ValueError`。接收端还应按 `data.job_id` 与 `data.status` 去重。
|
|
91
|
+
|
|
92
|
+
## 安全
|
|
93
|
+
|
|
94
|
+
- Key 只在服务端或受控环境保存,不要写进源码、日志或公开仓库。
|
|
95
|
+
- Key 只发送给 API 地址,不会发送给直传的 `upload_url`。
|
|
96
|
+
|
|
97
|
+
## 开发
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install -e ".[test]"
|
|
101
|
+
pytest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
测试使用假的 HTTP 会话,不需要 API Key,也不会访问网络。
|
omnidoc-0.1.0/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# omnidoc(Python)
|
|
2
|
+
|
|
3
|
+
OmniDoc 开放 API 的 Python 客户端:上传文件、创建翻译任务、等待结果、下载产物,并提供积分查询和 Webhook 验签。接口说明见 <https://docs.omnidoc.top/>。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
发布到 PyPI 之前,可以从源码安装:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install ./libs/omnidoc-python
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
发布后使用 `pip install omnidoc`。
|
|
14
|
+
|
|
15
|
+
依赖只有 `requests`,需要 Python 3.9 或更新版本。
|
|
16
|
+
|
|
17
|
+
## 快速开始
|
|
18
|
+
|
|
19
|
+
在环境变量里设置 API Key(以 `sk_` 开头,在 API 中心创建):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export OMNIDOC_API_KEY="sk_your_key"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from omnidoc import OmniDocClient
|
|
27
|
+
|
|
28
|
+
client = OmniDocClient() # 读取 OMNIDOC_API_KEY
|
|
29
|
+
files = client.translate_file("report.docx", "en", output_dir="out")
|
|
30
|
+
print(files)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`translate_file` 会依次完成:上传(presign → 直传 → finalize)、创建翻译任务、轮询直到结束、下载全部产物。任务失败时抛出 `JobFailedError`,`quota_exceeded` 为 True 表示余额不足,未扣费。
|
|
34
|
+
|
|
35
|
+
## 分步调用
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
doc_id = client.upload("report.docx")
|
|
39
|
+
job = client.create_job(doc_id, "en", output_mode="bilingual", engine="xr-397b")
|
|
40
|
+
job = client.wait_job(job["id"], poll_interval=3, timeout=1800)
|
|
41
|
+
for artifact in client.list_artifacts(job["id"]):
|
|
42
|
+
client.download_artifact(artifact["id"], "out", fallback_name=f"{artifact['id']}.{artifact.get('format', 'bin')}")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`create_job` 每次调用会生成新的 `Idempotency-Key`;内部重试沿用同一个键,不会重复创建或扣费。需要自己重试同一次提交时,传入相同的 `idempotency_key`。
|
|
46
|
+
|
|
47
|
+
## 查询与其他接口
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
client.get_job(job_id) # 任务状态(不扣积分)
|
|
51
|
+
client.cancel_job(job_id) # 取消任务
|
|
52
|
+
client.credit_balance() # 积分余额(不扣积分)
|
|
53
|
+
client.credit_operations() # 积分流水
|
|
54
|
+
client.usage_summary() # 用量汇总
|
|
55
|
+
|
|
56
|
+
# 尚未封装的接口,按 docs.omnidoc.top 上的路径直接调用:
|
|
57
|
+
client.get_json("<路径>", params={...})
|
|
58
|
+
client.post_json("<路径>", json={...})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 错误与重试
|
|
62
|
+
|
|
63
|
+
- API 错误统一抛出 `OmniDocError`,带有 `status_code`、`code`、`message` 与 `request_id`(对应响应头 `X-Request-Id`)。
|
|
64
|
+
- 429 与 5xx 会按 `Retry-After` 或指数退避重试,最多 `max_retries` 次(默认 4)。
|
|
65
|
+
- `AUTH_API_KEY_QUOTA_EXCEEDED` 不重试:该 Key 的预算已用尽,需要先调高预算。
|
|
66
|
+
|
|
67
|
+
## Webhook 验签
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from omnidoc import parse_webhook
|
|
71
|
+
|
|
72
|
+
event = parse_webhook(raw_body, request.headers["X-Webhook-Signature"], secret)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
必须使用收到的原始请求体字节验签。签名或时间戳不符时抛出 `ValueError`。接收端还应按 `data.job_id` 与 `data.status` 去重。
|
|
76
|
+
|
|
77
|
+
## 安全
|
|
78
|
+
|
|
79
|
+
- Key 只在服务端或受控环境保存,不要写进源码、日志或公开仓库。
|
|
80
|
+
- Key 只发送给 API 地址,不会发送给直传的 `upload_url`。
|
|
81
|
+
|
|
82
|
+
## 开发
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install -e ".[test]"
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
测试使用假的 HTTP 会话,不需要 API Key,也不会访问网络。
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "omnidoc"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OmniDoc 开放 API 的 Python 客户端:上传、翻译、下载、积分查询与 Webhook 验签"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"requests>=2.31",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
test = ["pytest>=8"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://omnidoc.top/"
|
|
25
|
+
Documentation = "https://docs.omnidoc.top/"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
pythonpath = ["src"]
|
|
32
|
+
testpaths = ["tests"]
|
omnidoc-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""OmniDoc 开放 API 的 Python 客户端。"""
|
|
2
|
+
|
|
3
|
+
from .client import DEFAULT_BASE_URL, OmniDocClient
|
|
4
|
+
from .errors import JobFailedError, OmniDocError
|
|
5
|
+
from .webhooks import parse_webhook, verify_webhook
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DEFAULT_BASE_URL",
|
|
9
|
+
"JobFailedError",
|
|
10
|
+
"OmniDocClient",
|
|
11
|
+
"OmniDocError",
|
|
12
|
+
"parse_webhook",
|
|
13
|
+
"verify_webhook",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""OmniDoc 开放 API 的同步客户端。
|
|
2
|
+
|
|
3
|
+
接口说明见 https://docs.omnidoc.top/ 。典型流程:
|
|
4
|
+
|
|
5
|
+
client = OmniDocClient() # 读取环境变量 OMNIDOC_API_KEY
|
|
6
|
+
paths = client.translate_file("report.docx", "en", output_dir="out")
|
|
7
|
+
|
|
8
|
+
Key 只发送给 API 地址;直传的 upload_url 不带 Key。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
20
|
+
from urllib.parse import unquote
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from .errors import JobFailedError, OmniDocError, error_from_response
|
|
25
|
+
|
|
26
|
+
DEFAULT_BASE_URL = "https://omnidoc.top/api/v1"
|
|
27
|
+
TERMINAL_JOB_STATUSES = frozenset({"completed", "failed"})
|
|
28
|
+
RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
29
|
+
QUOTA_EXCEEDED_CODE = "AUTH_API_KEY_QUOTA_EXCEEDED"
|
|
30
|
+
|
|
31
|
+
PathLike = Union[str, os.PathLike]
|
|
32
|
+
|
|
33
|
+
_UTF8_FILENAME = re.compile(r"filename\*=UTF-8''([^;]+)", re.IGNORECASE)
|
|
34
|
+
_PLAIN_FILENAME = re.compile(r'filename="([^"]+)"', re.IGNORECASE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _filename_from_disposition(disposition: str, fallback: str) -> str:
|
|
38
|
+
"""从 Content-Disposition 取文件名,只保留最后一段,防止路径穿越。"""
|
|
39
|
+
match = _UTF8_FILENAME.search(disposition)
|
|
40
|
+
if match:
|
|
41
|
+
name = unquote(match.group(1))
|
|
42
|
+
else:
|
|
43
|
+
match = _PLAIN_FILENAME.search(disposition)
|
|
44
|
+
name = match.group(1) if match else fallback
|
|
45
|
+
return Path(name).name or fallback
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class OmniDocClient:
|
|
49
|
+
"""OmniDoc API 客户端。
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
api_key: 以 ``sk_`` 开头的 API Key;缺省时读取环境变量 OMNIDOC_API_KEY。
|
|
53
|
+
base_url: API 根地址,默认 https://omnidoc.top/api/v1。
|
|
54
|
+
timeout: (连接超时, 读取超时) 秒数。
|
|
55
|
+
max_retries: 遇到 429 与 5xx 时的最大重试次数(不含首次请求)。
|
|
56
|
+
session: 可选的 requests.Session,便于测试或复用连接。
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
api_key: Optional[str] = None,
|
|
62
|
+
*,
|
|
63
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
64
|
+
timeout: Tuple[float, float] = (10.0, 60.0),
|
|
65
|
+
max_retries: int = 4,
|
|
66
|
+
session: Optional[requests.Session] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
key = api_key or os.environ.get("OMNIDOC_API_KEY")
|
|
69
|
+
if not key:
|
|
70
|
+
raise ValueError("需要 API Key:传入 api_key,或设置环境变量 OMNIDOC_API_KEY")
|
|
71
|
+
self._api_key = key
|
|
72
|
+
self.base_url = base_url.rstrip("/")
|
|
73
|
+
self.timeout = timeout
|
|
74
|
+
self.max_retries = max_retries
|
|
75
|
+
self._session = session or requests.Session()
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ 底层请求
|
|
78
|
+
|
|
79
|
+
def request(
|
|
80
|
+
self,
|
|
81
|
+
method: str,
|
|
82
|
+
path: str,
|
|
83
|
+
*,
|
|
84
|
+
json: Any = None,
|
|
85
|
+
params: Optional[Dict[str, Any]] = None,
|
|
86
|
+
headers: Optional[Dict[str, str]] = None,
|
|
87
|
+
stream: bool = False,
|
|
88
|
+
) -> requests.Response:
|
|
89
|
+
"""发送请求,成功返回 Response,失败抛出 OmniDocError。
|
|
90
|
+
|
|
91
|
+
429 与 5xx 按 Retry-After 或指数退避重试;AUTH_API_KEY_QUOTA_EXCEEDED 不重试。
|
|
92
|
+
创建类请求重试时会沿用调用方传入的 Idempotency-Key,因此不会重复扣费。
|
|
93
|
+
"""
|
|
94
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
95
|
+
attempt = 0
|
|
96
|
+
while True:
|
|
97
|
+
request_headers = {"X-API-Key": self._api_key, **(headers or {})}
|
|
98
|
+
response = self._session.request(
|
|
99
|
+
method,
|
|
100
|
+
url,
|
|
101
|
+
json=json,
|
|
102
|
+
params=params,
|
|
103
|
+
headers=request_headers,
|
|
104
|
+
timeout=self.timeout,
|
|
105
|
+
stream=stream,
|
|
106
|
+
)
|
|
107
|
+
if response.status_code < 400:
|
|
108
|
+
return response
|
|
109
|
+
|
|
110
|
+
error = error_from_response(response)
|
|
111
|
+
retryable = response.status_code in RETRYABLE_STATUSES and error.code != QUOTA_EXCEEDED_CODE
|
|
112
|
+
if not retryable or attempt >= self.max_retries:
|
|
113
|
+
raise error
|
|
114
|
+
|
|
115
|
+
time.sleep(_retry_delay(response, attempt))
|
|
116
|
+
attempt += 1
|
|
117
|
+
|
|
118
|
+
def get_json(self, path: str, **kwargs: Any) -> Any:
|
|
119
|
+
"""GET 并返回解析后的 JSON;可调用文档中任何尚未封装的接口。"""
|
|
120
|
+
return self.request("GET", path, **kwargs).json()
|
|
121
|
+
|
|
122
|
+
def post_json(self, path: str, json: Any = None, **kwargs: Any) -> Any:
|
|
123
|
+
"""POST 并返回解析后的 JSON。"""
|
|
124
|
+
return self.request("POST", path, json=json, **kwargs).json()
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ 上传
|
|
127
|
+
|
|
128
|
+
def upload(self, path: PathLike, *, filename: Optional[str] = None) -> str:
|
|
129
|
+
"""上传本地文件并确认,返回文档 ID。
|
|
130
|
+
|
|
131
|
+
流程:申请上传地址(presign)→ 直传到 upload_url → 确认(finalize)。
|
|
132
|
+
"""
|
|
133
|
+
file_path = Path(path)
|
|
134
|
+
size = file_path.stat().st_size
|
|
135
|
+
ticket = self.post_json(
|
|
136
|
+
"upload/presign",
|
|
137
|
+
json={"filename": filename or file_path.name, "file_size": size},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
put_headers = {**ticket["upload_headers"], "Content-Length": str(size)}
|
|
141
|
+
with file_path.open("rb") as stream:
|
|
142
|
+
# 直传地址不带 API Key,使用模块级 requests.put。
|
|
143
|
+
uploaded = requests.put(
|
|
144
|
+
ticket["upload_url"], headers=put_headers, data=stream, timeout=300
|
|
145
|
+
)
|
|
146
|
+
uploaded.raise_for_status()
|
|
147
|
+
|
|
148
|
+
finalized = self.post_json(
|
|
149
|
+
"upload/finalize", json={"finalize_token": ticket["finalize_token"]}
|
|
150
|
+
)
|
|
151
|
+
return finalized["document"]["id"]
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------ 翻译任务
|
|
154
|
+
|
|
155
|
+
def create_job(
|
|
156
|
+
self,
|
|
157
|
+
document_id: str,
|
|
158
|
+
target_lang: str,
|
|
159
|
+
*,
|
|
160
|
+
source_lang: Optional[str] = None,
|
|
161
|
+
engine: Optional[str] = None,
|
|
162
|
+
output_mode: Optional[str] = None,
|
|
163
|
+
glossary_id: Optional[str] = None,
|
|
164
|
+
include_images: Optional[bool] = None,
|
|
165
|
+
context_translation: Optional[bool] = None,
|
|
166
|
+
style_preset: Optional[str] = None,
|
|
167
|
+
idempotency_key: Optional[str] = None,
|
|
168
|
+
) -> Dict[str, Any]:
|
|
169
|
+
"""创建文件翻译任务。
|
|
170
|
+
|
|
171
|
+
output_mode 可选 ``single`` 或 ``bilingual``。重试同一次提交时请传入相同的
|
|
172
|
+
idempotency_key;每次新的提交使用新的 UUID(缺省时自动生成)。
|
|
173
|
+
"""
|
|
174
|
+
optional = {
|
|
175
|
+
"source_lang": source_lang,
|
|
176
|
+
"engine": engine,
|
|
177
|
+
"output_mode": output_mode,
|
|
178
|
+
"glossary_id": glossary_id,
|
|
179
|
+
"include_images": include_images,
|
|
180
|
+
"context_translation": context_translation,
|
|
181
|
+
"style_preset": style_preset,
|
|
182
|
+
}
|
|
183
|
+
body: Dict[str, Any] = {"document_id": document_id, "target_lang": target_lang}
|
|
184
|
+
body.update({k: v for k, v in optional.items() if v is not None})
|
|
185
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
186
|
+
return self.request(
|
|
187
|
+
"POST", "jobs", json=body, headers={"Idempotency-Key": key}
|
|
188
|
+
).json()
|
|
189
|
+
|
|
190
|
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
191
|
+
"""查询任务状态(不扣积分)。"""
|
|
192
|
+
return self.request("GET", f"jobs/{job_id}").json()
|
|
193
|
+
|
|
194
|
+
def cancel_job(self, job_id: str) -> Dict[str, Any]:
|
|
195
|
+
"""取消任务。取消的任务不会发送 Webhook。"""
|
|
196
|
+
return self.request("POST", f"jobs/{job_id}/cancel").json()
|
|
197
|
+
|
|
198
|
+
def wait_job(
|
|
199
|
+
self,
|
|
200
|
+
job_id: str,
|
|
201
|
+
*,
|
|
202
|
+
poll_interval: float = 3.0,
|
|
203
|
+
timeout: float = 1800.0,
|
|
204
|
+
) -> Dict[str, Any]:
|
|
205
|
+
"""轮询直到任务 completed 或 failed,返回最终任务信息。
|
|
206
|
+
|
|
207
|
+
生产环境建议改用 Webhook 或 SSE,不要长时间轮询。
|
|
208
|
+
"""
|
|
209
|
+
deadline = time.monotonic() + timeout
|
|
210
|
+
while True:
|
|
211
|
+
job = self.get_job(job_id)
|
|
212
|
+
if job.get("status") in TERMINAL_JOB_STATUSES:
|
|
213
|
+
return job
|
|
214
|
+
if time.monotonic() >= deadline:
|
|
215
|
+
raise TimeoutError(f"任务 {job_id} 在 {timeout:.0f} 秒内没有结束")
|
|
216
|
+
time.sleep(poll_interval)
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------ 产物
|
|
219
|
+
|
|
220
|
+
def list_artifacts(self, job_id: str) -> List[Dict[str, Any]]:
|
|
221
|
+
"""列出任务的产物(不扣积分)。"""
|
|
222
|
+
return self.request("GET", "artifacts", params={"job_id": job_id}).json()
|
|
223
|
+
|
|
224
|
+
def download_artifact(
|
|
225
|
+
self,
|
|
226
|
+
artifact_id: str,
|
|
227
|
+
output_dir: PathLike = ".",
|
|
228
|
+
*,
|
|
229
|
+
fallback_name: Optional[str] = None,
|
|
230
|
+
) -> Path:
|
|
231
|
+
"""下载产物到 output_dir,文件名取自响应头,取不到时用 fallback_name。"""
|
|
232
|
+
response = self.request(
|
|
233
|
+
"GET", f"artifacts/{artifact_id}/download", stream=True
|
|
234
|
+
)
|
|
235
|
+
fallback = fallback_name or artifact_id
|
|
236
|
+
name = _filename_from_disposition(
|
|
237
|
+
response.headers.get("Content-Disposition", ""), fallback
|
|
238
|
+
)
|
|
239
|
+
target = Path(output_dir) / name
|
|
240
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
with target.open("wb") as handle:
|
|
242
|
+
for chunk in response.iter_content(chunk_size=64 * 1024):
|
|
243
|
+
handle.write(chunk)
|
|
244
|
+
return target
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------ 组合流程
|
|
247
|
+
|
|
248
|
+
def translate_file(
|
|
249
|
+
self,
|
|
250
|
+
path: PathLike,
|
|
251
|
+
target_lang: str,
|
|
252
|
+
*,
|
|
253
|
+
output_dir: PathLike = ".",
|
|
254
|
+
poll_interval: float = 3.0,
|
|
255
|
+
timeout: float = 1800.0,
|
|
256
|
+
**options: Any,
|
|
257
|
+
) -> List[Path]:
|
|
258
|
+
"""上传、翻译、等待并下载全部产物,返回下载后的文件路径列表。
|
|
259
|
+
|
|
260
|
+
options 会原样传给 create_job(如 output_mode、engine、glossary_id)。
|
|
261
|
+
任务失败时抛出 JobFailedError。
|
|
262
|
+
"""
|
|
263
|
+
document_id = self.upload(path)
|
|
264
|
+
job = self.create_job(document_id, target_lang, **options)
|
|
265
|
+
job = self.wait_job(job["id"], poll_interval=poll_interval, timeout=timeout)
|
|
266
|
+
if job.get("status") == "failed":
|
|
267
|
+
raise JobFailedError(job)
|
|
268
|
+
|
|
269
|
+
files: List[Path] = []
|
|
270
|
+
for artifact in self.list_artifacts(job["id"]):
|
|
271
|
+
fallback = f"{artifact['id']}.{artifact.get('format') or 'bin'}"
|
|
272
|
+
files.append(
|
|
273
|
+
self.download_artifact(
|
|
274
|
+
artifact["id"], output_dir, fallback_name=fallback
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
return files
|
|
278
|
+
|
|
279
|
+
# ------------------------------------------------------------------ 账户与用量
|
|
280
|
+
|
|
281
|
+
def credit_balance(self) -> Dict[str, Any]:
|
|
282
|
+
"""积分余额(不扣积分)。"""
|
|
283
|
+
return self.get_json("billing/credits/balance")
|
|
284
|
+
|
|
285
|
+
def credit_operations(self, **params: Any) -> Any:
|
|
286
|
+
"""积分流水,可按文档支持的查询参数过滤(不扣积分)。"""
|
|
287
|
+
return self.get_json("billing/credits/operations", params=params or None)
|
|
288
|
+
|
|
289
|
+
def usage_summary(self, **params: Any) -> Any:
|
|
290
|
+
"""用量汇总(不扣积分)。"""
|
|
291
|
+
return self.get_json("usage/summary", params=params or None)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _retry_delay(response: requests.Response, attempt: int) -> float:
|
|
295
|
+
"""优先使用 Retry-After;没有时指数退避并加入随机抖动。"""
|
|
296
|
+
retry_after = response.headers.get("Retry-After")
|
|
297
|
+
if retry_after:
|
|
298
|
+
try:
|
|
299
|
+
return max(float(retry_after), 0.0)
|
|
300
|
+
except ValueError:
|
|
301
|
+
pass
|
|
302
|
+
return min(2 ** attempt, 16) + random.random()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""OmniDoc 客户端的异常类型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OmniDocError(Exception):
|
|
11
|
+
"""API 返回的错误(HTTP 状态码 >= 400)。
|
|
12
|
+
|
|
13
|
+
code 来自响应体的 code 字段;只有部分错误带 code,缺失时为 UNKNOWN_ERROR。
|
|
14
|
+
request_id 对应响应头 X-Request-Id,排查问题时请一并提供。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
status_code: int,
|
|
20
|
+
code: str,
|
|
21
|
+
message: str,
|
|
22
|
+
*,
|
|
23
|
+
detail: Any = None,
|
|
24
|
+
request_id: Optional[str] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(f"HTTP {status_code} {code}: {message}")
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
self.code = code
|
|
29
|
+
self.message = message
|
|
30
|
+
self.detail = detail
|
|
31
|
+
self.request_id = request_id
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JobFailedError(RuntimeError):
|
|
35
|
+
"""翻译任务以 failed 结束。
|
|
36
|
+
|
|
37
|
+
quota_exceeded 为 True 时表示余额不足以开始该文件,任务未扣费。
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, job: dict) -> None:
|
|
41
|
+
self.job = job
|
|
42
|
+
self.quota_exceeded = bool(job.get("quota_exceeded"))
|
|
43
|
+
super().__init__(job.get("error_msg") or "translation failed")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def error_from_response(response: requests.Response) -> OmniDocError:
|
|
47
|
+
"""把错误响应转换成 OmniDocError,兼容无 JSON 或 422 字段列表的情况。"""
|
|
48
|
+
try:
|
|
49
|
+
body = response.json()
|
|
50
|
+
except ValueError:
|
|
51
|
+
body = {}
|
|
52
|
+
if not isinstance(body, dict):
|
|
53
|
+
body = {}
|
|
54
|
+
|
|
55
|
+
code = body.get("code") or "UNKNOWN_ERROR"
|
|
56
|
+
detail = body.get("detail")
|
|
57
|
+
message = body.get("message")
|
|
58
|
+
if not message:
|
|
59
|
+
if isinstance(detail, str):
|
|
60
|
+
message = detail
|
|
61
|
+
elif isinstance(detail, list):
|
|
62
|
+
message = "参数校验失败"
|
|
63
|
+
else:
|
|
64
|
+
message = response.reason or "request failed"
|
|
65
|
+
|
|
66
|
+
return OmniDocError(
|
|
67
|
+
response.status_code,
|
|
68
|
+
code,
|
|
69
|
+
message,
|
|
70
|
+
detail=detail,
|
|
71
|
+
request_id=response.headers.get("X-Request-Id"),
|
|
72
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Webhook 回调验签。
|
|
2
|
+
|
|
3
|
+
签名是对原始请求体做 HMAC-SHA256,头部格式为 ``X-Webhook-Signature: sha256=<hex>``。
|
|
4
|
+
必须用收到的原始字节验签,不要先解析再重新序列化。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
DEFAULT_TOLERANCE_SECONDS = 12 * 3600
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
|
|
19
|
+
"""检查签名是否与原始请求体匹配。"""
|
|
20
|
+
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
21
|
+
return hmac.compare_digest(f"sha256={expected}", signature)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_webhook(
|
|
25
|
+
payload: bytes,
|
|
26
|
+
signature: str,
|
|
27
|
+
secret: str,
|
|
28
|
+
*,
|
|
29
|
+
tolerance: int = DEFAULT_TOLERANCE_SECONDS,
|
|
30
|
+
now: Optional[float] = None,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
"""验签并解析回调;签名不符或 timestamp 超出容差时抛出 ValueError。
|
|
33
|
+
|
|
34
|
+
失败重试会原样重发,因此时效窗口不要短于最后一次重试(约 8.6 小时)。
|
|
35
|
+
接收端还应按 data.job_id 与 data.status 去重。
|
|
36
|
+
"""
|
|
37
|
+
if not verify_webhook(payload, signature, secret):
|
|
38
|
+
raise ValueError("invalid signature")
|
|
39
|
+
|
|
40
|
+
event = json.loads(payload)
|
|
41
|
+
current = time.time() if now is None else now
|
|
42
|
+
if abs(current - event["timestamp"]) > tolerance:
|
|
43
|
+
raise ValueError("stale event")
|
|
44
|
+
return event
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: omnidoc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OmniDoc 开放 API 的 Python 客户端:上传、翻译、下载、积分查询与 Webhook 验签
|
|
5
|
+
Project-URL: Homepage, https://omnidoc.top/
|
|
6
|
+
Project-URL: Documentation, https://docs.omnidoc.top/
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests>=2.31
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
15
|
+
|
|
16
|
+
# omnidoc(Python)
|
|
17
|
+
|
|
18
|
+
OmniDoc 开放 API 的 Python 客户端:上传文件、创建翻译任务、等待结果、下载产物,并提供积分查询和 Webhook 验签。接口说明见 <https://docs.omnidoc.top/>。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
发布到 PyPI 之前,可以从源码安装:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install ./libs/omnidoc-python
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
发布后使用 `pip install omnidoc`。
|
|
29
|
+
|
|
30
|
+
依赖只有 `requests`,需要 Python 3.9 或更新版本。
|
|
31
|
+
|
|
32
|
+
## 快速开始
|
|
33
|
+
|
|
34
|
+
在环境变量里设置 API Key(以 `sk_` 开头,在 API 中心创建):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
export OMNIDOC_API_KEY="sk_your_key"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from omnidoc import OmniDocClient
|
|
42
|
+
|
|
43
|
+
client = OmniDocClient() # 读取 OMNIDOC_API_KEY
|
|
44
|
+
files = client.translate_file("report.docx", "en", output_dir="out")
|
|
45
|
+
print(files)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`translate_file` 会依次完成:上传(presign → 直传 → finalize)、创建翻译任务、轮询直到结束、下载全部产物。任务失败时抛出 `JobFailedError`,`quota_exceeded` 为 True 表示余额不足,未扣费。
|
|
49
|
+
|
|
50
|
+
## 分步调用
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
doc_id = client.upload("report.docx")
|
|
54
|
+
job = client.create_job(doc_id, "en", output_mode="bilingual", engine="xr-397b")
|
|
55
|
+
job = client.wait_job(job["id"], poll_interval=3, timeout=1800)
|
|
56
|
+
for artifact in client.list_artifacts(job["id"]):
|
|
57
|
+
client.download_artifact(artifact["id"], "out", fallback_name=f"{artifact['id']}.{artifact.get('format', 'bin')}")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`create_job` 每次调用会生成新的 `Idempotency-Key`;内部重试沿用同一个键,不会重复创建或扣费。需要自己重试同一次提交时,传入相同的 `idempotency_key`。
|
|
61
|
+
|
|
62
|
+
## 查询与其他接口
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
client.get_job(job_id) # 任务状态(不扣积分)
|
|
66
|
+
client.cancel_job(job_id) # 取消任务
|
|
67
|
+
client.credit_balance() # 积分余额(不扣积分)
|
|
68
|
+
client.credit_operations() # 积分流水
|
|
69
|
+
client.usage_summary() # 用量汇总
|
|
70
|
+
|
|
71
|
+
# 尚未封装的接口,按 docs.omnidoc.top 上的路径直接调用:
|
|
72
|
+
client.get_json("<路径>", params={...})
|
|
73
|
+
client.post_json("<路径>", json={...})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 错误与重试
|
|
77
|
+
|
|
78
|
+
- API 错误统一抛出 `OmniDocError`,带有 `status_code`、`code`、`message` 与 `request_id`(对应响应头 `X-Request-Id`)。
|
|
79
|
+
- 429 与 5xx 会按 `Retry-After` 或指数退避重试,最多 `max_retries` 次(默认 4)。
|
|
80
|
+
- `AUTH_API_KEY_QUOTA_EXCEEDED` 不重试:该 Key 的预算已用尽,需要先调高预算。
|
|
81
|
+
|
|
82
|
+
## Webhook 验签
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from omnidoc import parse_webhook
|
|
86
|
+
|
|
87
|
+
event = parse_webhook(raw_body, request.headers["X-Webhook-Signature"], secret)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
必须使用收到的原始请求体字节验签。签名或时间戳不符时抛出 `ValueError`。接收端还应按 `data.job_id` 与 `data.status` 去重。
|
|
91
|
+
|
|
92
|
+
## 安全
|
|
93
|
+
|
|
94
|
+
- Key 只在服务端或受控环境保存,不要写进源码、日志或公开仓库。
|
|
95
|
+
- Key 只发送给 API 地址,不会发送给直传的 `upload_url`。
|
|
96
|
+
|
|
97
|
+
## 开发
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install -e ".[test]"
|
|
101
|
+
pytest
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
测试使用假的 HTTP 会话,不需要 API Key,也不会访问网络。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/omnidoc/__init__.py
|
|
4
|
+
src/omnidoc/client.py
|
|
5
|
+
src/omnidoc/errors.py
|
|
6
|
+
src/omnidoc/webhooks.py
|
|
7
|
+
src/omnidoc.egg-info/PKG-INFO
|
|
8
|
+
src/omnidoc.egg-info/SOURCES.txt
|
|
9
|
+
src/omnidoc.egg-info/dependency_links.txt
|
|
10
|
+
src/omnidoc.egg-info/requires.txt
|
|
11
|
+
src/omnidoc.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
omnidoc
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
import omnidoc.client as client_module
|
|
9
|
+
from omnidoc import JobFailedError, OmniDocClient, OmniDocError, parse_webhook, verify_webhook
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeResponse:
|
|
13
|
+
def __init__(self, status_code=200, body=None, headers=None, content=b""):
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self._body = body
|
|
16
|
+
self.headers = headers or {}
|
|
17
|
+
self.content = content
|
|
18
|
+
self.reason = "OK" if status_code < 400 else "Error"
|
|
19
|
+
|
|
20
|
+
def json(self):
|
|
21
|
+
if self._body is None:
|
|
22
|
+
raise ValueError("no json body")
|
|
23
|
+
return self._body
|
|
24
|
+
|
|
25
|
+
def iter_content(self, chunk_size=1):
|
|
26
|
+
yield self.content
|
|
27
|
+
|
|
28
|
+
def raise_for_status(self):
|
|
29
|
+
if self.status_code >= 400:
|
|
30
|
+
raise requests.HTTPError(f"HTTP {self.status_code}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FakeSession:
|
|
34
|
+
def __init__(self, responses):
|
|
35
|
+
self.responses = list(responses)
|
|
36
|
+
self.calls = []
|
|
37
|
+
|
|
38
|
+
def request(self, method, url, **kwargs):
|
|
39
|
+
self.calls.append((method, url, kwargs))
|
|
40
|
+
return self.responses.pop(0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PutOk:
|
|
44
|
+
status_code = 200
|
|
45
|
+
|
|
46
|
+
def raise_for_status(self):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def sleeps(monkeypatch):
|
|
52
|
+
recorded = []
|
|
53
|
+
monkeypatch.setattr(client_module.time, "sleep", lambda seconds: recorded.append(seconds))
|
|
54
|
+
return recorded
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def make_client(responses, **kwargs):
|
|
58
|
+
session = FakeSession(responses)
|
|
59
|
+
return OmniDocClient("sk_test", session=session, **kwargs), session
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_api_key_is_read_from_environment(monkeypatch):
|
|
63
|
+
monkeypatch.setenv("OMNIDOC_API_KEY", "sk_env")
|
|
64
|
+
client = OmniDocClient(session=FakeSession([]))
|
|
65
|
+
assert client._api_key == "sk_env"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_missing_api_key_raises(monkeypatch):
|
|
69
|
+
monkeypatch.delenv("OMNIDOC_API_KEY", raising=False)
|
|
70
|
+
with pytest.raises(ValueError):
|
|
71
|
+
OmniDocClient(session=FakeSession([]))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_request_sends_key_header_to_api_base():
|
|
75
|
+
client, session = make_client([FakeResponse(200, {"balance": 10})])
|
|
76
|
+
assert client.credit_balance() == {"balance": 10}
|
|
77
|
+
method, url, kwargs = session.calls[0]
|
|
78
|
+
assert method == "GET"
|
|
79
|
+
assert url == "https://omnidoc.top/api/v1/billing/credits/balance"
|
|
80
|
+
assert kwargs["headers"]["X-API-Key"] == "sk_test"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_retries_429_using_retry_after(sleeps):
|
|
84
|
+
client, session = make_client(
|
|
85
|
+
[
|
|
86
|
+
FakeResponse(429, {"code": "RATE_LIMITED", "message": "slow down"}, {"Retry-After": "7"}),
|
|
87
|
+
FakeResponse(200, {"ok": 1}),
|
|
88
|
+
]
|
|
89
|
+
)
|
|
90
|
+
assert client.usage_summary() == {"ok": 1}
|
|
91
|
+
assert sleeps == [7.0]
|
|
92
|
+
assert len(session.calls) == 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_quota_exceeded_is_not_retried(sleeps):
|
|
96
|
+
client, session = make_client(
|
|
97
|
+
[FakeResponse(429, {"code": "AUTH_API_KEY_QUOTA_EXCEEDED", "message": "quota used up"})]
|
|
98
|
+
)
|
|
99
|
+
with pytest.raises(OmniDocError) as info:
|
|
100
|
+
client.usage_summary()
|
|
101
|
+
assert info.value.code == "AUTH_API_KEY_QUOTA_EXCEEDED"
|
|
102
|
+
assert sleeps == []
|
|
103
|
+
assert len(session.calls) == 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_gives_up_after_max_retries(sleeps):
|
|
107
|
+
client, session = make_client(
|
|
108
|
+
[FakeResponse(503, {"message": "busy"}) for _ in range(3)], max_retries=2
|
|
109
|
+
)
|
|
110
|
+
with pytest.raises(OmniDocError) as info:
|
|
111
|
+
client.usage_summary()
|
|
112
|
+
assert info.value.status_code == 503
|
|
113
|
+
assert len(session.calls) == 3
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_error_carries_request_id():
|
|
117
|
+
client, _ = make_client(
|
|
118
|
+
[FakeResponse(401, {"code": "UNAUTHORIZED", "message": "bad key"}, {"X-Request-Id": "req-1"})]
|
|
119
|
+
)
|
|
120
|
+
with pytest.raises(OmniDocError) as info:
|
|
121
|
+
client.usage_summary()
|
|
122
|
+
assert info.value.status_code == 401
|
|
123
|
+
assert info.value.request_id == "req-1"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_422_field_list_becomes_readable_message():
|
|
127
|
+
client, _ = make_client(
|
|
128
|
+
[FakeResponse(422, {"detail": [{"loc": ["body", "target_lang"], "msg": "Field required"}]})]
|
|
129
|
+
)
|
|
130
|
+
with pytest.raises(OmniDocError) as info:
|
|
131
|
+
client.post_json("jobs", json={})
|
|
132
|
+
assert info.value.message == "参数校验失败"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_create_job_sends_idempotency_key_and_only_set_options():
|
|
136
|
+
client, session = make_client([FakeResponse(201, {"id": "job1", "status": "pending"})])
|
|
137
|
+
job = client.create_job(
|
|
138
|
+
"doc1", "en", output_mode="bilingual", engine="xr-397b", idempotency_key="key-1"
|
|
139
|
+
)
|
|
140
|
+
assert job["id"] == "job1"
|
|
141
|
+
method, url, kwargs = session.calls[0]
|
|
142
|
+
assert method == "POST" and url.endswith("/jobs")
|
|
143
|
+
assert kwargs["headers"]["Idempotency-Key"] == "key-1"
|
|
144
|
+
assert kwargs["json"] == {
|
|
145
|
+
"document_id": "doc1",
|
|
146
|
+
"target_lang": "en",
|
|
147
|
+
"output_mode": "bilingual",
|
|
148
|
+
"engine": "xr-397b",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_create_job_retry_reuses_idempotency_key(sleeps):
|
|
153
|
+
client, session = make_client(
|
|
154
|
+
[FakeResponse(503, {"message": "busy"}), FakeResponse(201, {"id": "job2"})]
|
|
155
|
+
)
|
|
156
|
+
client.create_job("doc1", "en", idempotency_key="same-key")
|
|
157
|
+
keys = [call[2]["headers"]["Idempotency-Key"] for call in session.calls]
|
|
158
|
+
assert keys == ["same-key", "same-key"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_upload_runs_presign_put_finalize(monkeypatch, tmp_path):
|
|
162
|
+
source = tmp_path / "report.docx"
|
|
163
|
+
source.write_bytes(b"hello")
|
|
164
|
+
put_calls = []
|
|
165
|
+
|
|
166
|
+
def fake_put(url, headers=None, data=None, timeout=None):
|
|
167
|
+
put_calls.append((url, headers, data.read()))
|
|
168
|
+
return PutOk()
|
|
169
|
+
|
|
170
|
+
monkeypatch.setattr(client_module.requests, "put", fake_put)
|
|
171
|
+
client, session = make_client(
|
|
172
|
+
[
|
|
173
|
+
FakeResponse(
|
|
174
|
+
200,
|
|
175
|
+
{
|
|
176
|
+
"upload_url": "https://upload.example/x",
|
|
177
|
+
"upload_headers": {"Content-Type": "application/octet-stream"},
|
|
178
|
+
"finalize_token": "tok",
|
|
179
|
+
},
|
|
180
|
+
),
|
|
181
|
+
FakeResponse(200, {"document": {"id": "doc9"}}),
|
|
182
|
+
]
|
|
183
|
+
)
|
|
184
|
+
assert client.upload(source) == "doc9"
|
|
185
|
+
|
|
186
|
+
url, headers, body = put_calls[0]
|
|
187
|
+
assert url == "https://upload.example/x"
|
|
188
|
+
assert headers["Content-Length"] == "5"
|
|
189
|
+
assert body == b"hello"
|
|
190
|
+
# 直传地址不能带 API Key
|
|
191
|
+
assert "X-API-Key" not in headers
|
|
192
|
+
assert session.calls[0][1].endswith("/upload/presign")
|
|
193
|
+
assert session.calls[1][1].endswith("/upload/finalize")
|
|
194
|
+
assert session.calls[1][2]["json"] == {"finalize_token": "tok"}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_translate_file_downloads_with_server_filename(monkeypatch, tmp_path):
|
|
198
|
+
source = tmp_path / "in.docx"
|
|
199
|
+
source.write_bytes(b"x")
|
|
200
|
+
monkeypatch.setattr(client_module.requests, "put", lambda *a, **k: PutOk())
|
|
201
|
+
client, _ = make_client(
|
|
202
|
+
[
|
|
203
|
+
FakeResponse(200, {"upload_url": "https://u", "upload_headers": {}, "finalize_token": "t"}),
|
|
204
|
+
FakeResponse(200, {"document": {"id": "d1"}}),
|
|
205
|
+
FakeResponse(201, {"id": "j1", "status": "pending"}),
|
|
206
|
+
FakeResponse(200, {"id": "j1", "status": "completed"}),
|
|
207
|
+
FakeResponse(200, [{"id": "a1", "format": "docx"}]),
|
|
208
|
+
FakeResponse(
|
|
209
|
+
200,
|
|
210
|
+
content=b"DATA",
|
|
211
|
+
headers={"Content-Disposition": "attachment; filename*=UTF-8''%E6%96%87%E6%A1%A3.docx"},
|
|
212
|
+
),
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
files = client.translate_file(source, "en", output_dir=tmp_path / "out", poll_interval=0)
|
|
216
|
+
assert [f.name for f in files] == ["文档.docx"]
|
|
217
|
+
assert files[0].read_bytes() == b"DATA"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_download_strips_directory_parts_from_server_filename(tmp_path):
|
|
221
|
+
client, _ = make_client(
|
|
222
|
+
[FakeResponse(200, content=b"Z", headers={"Content-Disposition": 'attachment; filename="../../evil.txt"'})]
|
|
223
|
+
)
|
|
224
|
+
saved = client.download_artifact("a1", tmp_path, fallback_name="a1.txt")
|
|
225
|
+
assert saved.parent == tmp_path
|
|
226
|
+
assert saved.name == "evil.txt"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_translate_file_raises_when_job_failed(monkeypatch, tmp_path):
|
|
230
|
+
source = tmp_path / "in.pdf"
|
|
231
|
+
source.write_bytes(b"x")
|
|
232
|
+
monkeypatch.setattr(client_module.requests, "put", lambda *a, **k: PutOk())
|
|
233
|
+
client, _ = make_client(
|
|
234
|
+
[
|
|
235
|
+
FakeResponse(200, {"upload_url": "https://u", "upload_headers": {}, "finalize_token": "t"}),
|
|
236
|
+
FakeResponse(200, {"document": {"id": "d2"}}),
|
|
237
|
+
FakeResponse(201, {"id": "j2", "status": "pending"}),
|
|
238
|
+
FakeResponse(
|
|
239
|
+
200,
|
|
240
|
+
{"id": "j2", "status": "failed", "quota_exceeded": True, "error_msg": "余额不足"},
|
|
241
|
+
),
|
|
242
|
+
]
|
|
243
|
+
)
|
|
244
|
+
with pytest.raises(JobFailedError) as info:
|
|
245
|
+
client.translate_file(source, "en", output_dir=tmp_path, poll_interval=0)
|
|
246
|
+
assert info.value.quota_exceeded is True
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _sign(payload: bytes, secret: str) -> str:
|
|
250
|
+
return "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_verify_webhook_accepts_matching_signature():
|
|
254
|
+
body = b'{"event":"job.completed","timestamp":1000,"data":{}}'
|
|
255
|
+
assert verify_webhook(body, _sign(body, "s3cret"), "s3cret")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_verify_webhook_rejects_tampered_body():
|
|
259
|
+
body = b'{"x":1}'
|
|
260
|
+
assert not verify_webhook(b'{"x":2}', _sign(body, "s3cret"), "s3cret")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_parse_webhook_returns_event_within_tolerance():
|
|
264
|
+
body = json.dumps(
|
|
265
|
+
{"event": "job.completed", "timestamp": 1000, "data": {"job_id": "j", "status": "completed"}}
|
|
266
|
+
).encode()
|
|
267
|
+
event = parse_webhook(body, _sign(body, "k"), "k", now=1500)
|
|
268
|
+
assert event["data"]["job_id"] == "j"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_parse_webhook_rejects_stale_event():
|
|
272
|
+
body = json.dumps({"event": "job.completed", "timestamp": 1000, "data": {}}).encode()
|
|
273
|
+
with pytest.raises(ValueError, match="stale"):
|
|
274
|
+
parse_webhook(body, _sign(body, "k"), "k", now=1000 + 13 * 3600)
|