clavis-sinica 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.
- clavis_sinica-0.1.0/LICENSE +21 -0
- clavis_sinica-0.1.0/PKG-INFO +64 -0
- clavis_sinica-0.1.0/README.md +46 -0
- clavis_sinica-0.1.0/clavis_sinica/__init__.py +39 -0
- clavis_sinica-0.1.0/clavis_sinica/client.py +263 -0
- clavis_sinica-0.1.0/clavis_sinica.egg-info/PKG-INFO +64 -0
- clavis_sinica-0.1.0/clavis_sinica.egg-info/SOURCES.txt +10 -0
- clavis_sinica-0.1.0/clavis_sinica.egg-info/dependency_links.txt +1 -0
- clavis_sinica-0.1.0/clavis_sinica.egg-info/requires.txt +4 -0
- clavis_sinica-0.1.0/clavis_sinica.egg-info/top_level.txt +1 -0
- clavis_sinica-0.1.0/pyproject.toml +26 -0
- clavis_sinica-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clavis Sinica
|
|
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,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clavis-sinica
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Clavis Sinica API — Chinese idiom, sentence, passage, classical Chinese and lesson decomposition.
|
|
5
|
+
Author: Clavis Sinica
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://clavissinica.org
|
|
8
|
+
Project-URL: Documentation, https://clavissinica.org/docs
|
|
9
|
+
Project-URL: API Reference, https://clavissinica.org/openapi.json
|
|
10
|
+
Keywords: chinese,nlp,idiom,classical-chinese,hsk,api-client
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: requests>=2.25
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# clavis-sinica
|
|
20
|
+
|
|
21
|
+
Official Python client for the [Clavis Sinica](https://clavissinica.org) API —
|
|
22
|
+
Chinese idiom, sentence, passage, classical Chinese and lesson decomposition.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install clavis-sinica
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from clavis_sinica import Clavis
|
|
30
|
+
|
|
31
|
+
c = Clavis("ck-live_...")
|
|
32
|
+
|
|
33
|
+
c.run("decompose.idiom", {"idiom": "守株待兔"})
|
|
34
|
+
c.run("convert.script", {"text": "汉字", "direction": "s2t"})
|
|
35
|
+
|
|
36
|
+
# Async services poll automatically until done
|
|
37
|
+
c.run("decompose.lesson", {"text": "...", "title": "第一课"})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What it handles for you
|
|
41
|
+
|
|
42
|
+
- `Authorization: Bearer` auth — the only accepted scheme
|
|
43
|
+
- The `payload` request shape, which works for every service code
|
|
44
|
+
- Fetches the field spec from `GET /v1/chinese/services` at startup instead of
|
|
45
|
+
hardcoding a local copy
|
|
46
|
+
- Retries `429` honouring `Retry-After`, and `502`/`503` with exponential
|
|
47
|
+
backoff; never retries `4xx` that won't succeed on retry
|
|
48
|
+
- Polls async services (`decompose.lesson`, `decompose.wenyan_lesson`) to
|
|
49
|
+
completion
|
|
50
|
+
- Logs the `warnings` array — unknown top-level request fields are dropped
|
|
51
|
+
silently, and that array is the only signal
|
|
52
|
+
|
|
53
|
+
## Errors
|
|
54
|
+
|
|
55
|
+
`ClavisAuthError` (401) · `ClavisParamError` (400, has `.field` / `.expected`) ·
|
|
56
|
+
`ClavisNotFound` (404) · `ClavisRateLimited` (429, has `.retry_after` /
|
|
57
|
+
`.scope`) · `ClavisUpstreamError` (502/503, worth retrying) · `ClavisTimeout`
|
|
58
|
+
|
|
59
|
+
## Not this API
|
|
60
|
+
|
|
61
|
+
Single-character and vocabulary decomposition belong to the sister product
|
|
62
|
+
chinesekey.org — separate pricing, `pk_live_` keys, not interchangeable.
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# clavis-sinica
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Clavis Sinica](https://clavissinica.org) API —
|
|
4
|
+
Chinese idiom, sentence, passage, classical Chinese and lesson decomposition.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install clavis-sinica
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from clavis_sinica import Clavis
|
|
12
|
+
|
|
13
|
+
c = Clavis("ck-live_...")
|
|
14
|
+
|
|
15
|
+
c.run("decompose.idiom", {"idiom": "守株待兔"})
|
|
16
|
+
c.run("convert.script", {"text": "汉字", "direction": "s2t"})
|
|
17
|
+
|
|
18
|
+
# Async services poll automatically until done
|
|
19
|
+
c.run("decompose.lesson", {"text": "...", "title": "第一课"})
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## What it handles for you
|
|
23
|
+
|
|
24
|
+
- `Authorization: Bearer` auth — the only accepted scheme
|
|
25
|
+
- The `payload` request shape, which works for every service code
|
|
26
|
+
- Fetches the field spec from `GET /v1/chinese/services` at startup instead of
|
|
27
|
+
hardcoding a local copy
|
|
28
|
+
- Retries `429` honouring `Retry-After`, and `502`/`503` with exponential
|
|
29
|
+
backoff; never retries `4xx` that won't succeed on retry
|
|
30
|
+
- Polls async services (`decompose.lesson`, `decompose.wenyan_lesson`) to
|
|
31
|
+
completion
|
|
32
|
+
- Logs the `warnings` array — unknown top-level request fields are dropped
|
|
33
|
+
silently, and that array is the only signal
|
|
34
|
+
|
|
35
|
+
## Errors
|
|
36
|
+
|
|
37
|
+
`ClavisAuthError` (401) · `ClavisParamError` (400, has `.field` / `.expected`) ·
|
|
38
|
+
`ClavisNotFound` (404) · `ClavisRateLimited` (429, has `.retry_after` /
|
|
39
|
+
`.scope`) · `ClavisUpstreamError` (502/503, worth retrying) · `ClavisTimeout`
|
|
40
|
+
|
|
41
|
+
## Not this API
|
|
42
|
+
|
|
43
|
+
Single-character and vocabulary decomposition belong to the sister product
|
|
44
|
+
chinesekey.org — separate pricing, `pk_live_` keys, not interchangeable.
|
|
45
|
+
|
|
46
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Clavis Sinica —— 中文语义分解 API 的官方 Python 客户端。
|
|
2
|
+
|
|
3
|
+
设计取舍(每一条都来自 2026-09-10 的真实实测, 不是照文档抄的):
|
|
4
|
+
|
|
5
|
+
* 认证只有 `Authorization: Bearer ck-live_...` 一种。实测过 X-API-Key /
|
|
6
|
+
Authorization 裸值 / 小写变体 / 查询参数, 全部 401。
|
|
7
|
+
* 请求一律用 `payload` 形态。`{"input": ...}` 那种快捷写法只能设置单字段
|
|
8
|
+
服务的主字段, 存在的唯一理由是历史兼容 —— 统一走 payload 就不必判断
|
|
9
|
+
"这个服务能不能用 input"。
|
|
10
|
+
* 字段规格不硬编码, 启动时从 `/v1/chinese/services` 拉。那个接口零费用,
|
|
11
|
+
且与引擎校验用的是同一份定义。此前有接入方维护本地手抄规格表, 累计
|
|
12
|
+
出过五处错误, 根因都是"规格只存在于服务端的代码里"。
|
|
13
|
+
* 429 按 `Retry-After` 退避, 5xx 指数退避, 4xx 立即抛。被限流的请求不
|
|
14
|
+
扣费, 所以重试是安全的。
|
|
15
|
+
* `upstream_error` / `upstream_unavailable` 是服务端侧故障, 与调用方的
|
|
16
|
+
请求无关, 值得重试; `invalid_param` 这类改了参数再发才有意义。
|
|
17
|
+
* 异步服务(decompose.lesson / decompose.wenyan_lesson)自动轮询到终态,
|
|
18
|
+
调用方不需要自己写轮询循环。
|
|
19
|
+
* 响应里的 `warnings` 默认打到 logging.WARNING。顶层未知字段会被静默
|
|
20
|
+
丢弃, 这个数组是唯一提示 —— 曾有接入方在顶层发了几个月的无效参数,
|
|
21
|
+
一切正常、毫无信号。
|
|
22
|
+
"""
|
|
23
|
+
from .client import (
|
|
24
|
+
Clavis,
|
|
25
|
+
ClavisError,
|
|
26
|
+
ClavisAuthError,
|
|
27
|
+
ClavisParamError,
|
|
28
|
+
ClavisRateLimited,
|
|
29
|
+
ClavisUpstreamError,
|
|
30
|
+
ClavisNotFound,
|
|
31
|
+
ClavisTimeout,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Clavis", "ClavisError", "ClavisAuthError", "ClavisParamError",
|
|
37
|
+
"ClavisRateLimited", "ClavisUpstreamError", "ClavisNotFound",
|
|
38
|
+
"ClavisTimeout",
|
|
39
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
log = logging.getLogger("clavis_sinica")
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://clavissinica.org"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClavisError(Exception):
|
|
14
|
+
"""所有本客户端抛出的错误的基类。"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message, *, status_code=None, error=None, payload=None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.error = error
|
|
20
|
+
self.payload = payload or {}
|
|
21
|
+
|
|
22
|
+
def __str__(self):
|
|
23
|
+
base = super().__str__()
|
|
24
|
+
return "%s (HTTP %s, error=%s)" % (base, self.status_code, self.error)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ClavisAuthError(ClavisError):
|
|
28
|
+
"""401 —— Key 缺失/格式错/前缀不对/已吊销。重试没有意义。"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClavisParamError(ClavisError):
|
|
32
|
+
"""400 invalid_param —— 参数有问题。`field` 指出是哪一个。"""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def field(self):
|
|
36
|
+
return self.payload.get("field")
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def expected(self):
|
|
40
|
+
return self.payload.get("expected")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ClavisNotFound(ClavisError):
|
|
44
|
+
"""404 —— 接口正常, 内容不在词典里。重试没有意义。"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClavisRateLimited(ClavisError):
|
|
48
|
+
"""429 —— 触发限流。被拒的请求不扣费。"""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def retry_after(self):
|
|
52
|
+
return self.payload.get("retry_after")
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def scope(self):
|
|
56
|
+
return self.payload.get("scope")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ClavisUpstreamError(ClavisError):
|
|
60
|
+
"""502/503 —— 服务端侧故障, 与你的请求无关。退避后重试是合理的。"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ClavisTimeout(ClavisError):
|
|
64
|
+
"""异步任务在给定时间内没有到达终态。任务本身可能仍在跑。"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_RETRYABLE_STATUS = (429, 502, 503)
|
|
68
|
+
_TERMINAL_TASK_STATES = ("done", "error", "success", "completed", "failed")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Clavis:
|
|
72
|
+
"""Clavis Sinica API 客户端。
|
|
73
|
+
|
|
74
|
+
c = Clavis("ck-live_...")
|
|
75
|
+
c.run("decompose.idiom", {"idiom": "守株待兔"})
|
|
76
|
+
c.run("decompose.lesson", {"text": "...", "title": "..."}) # 自动轮询
|
|
77
|
+
|
|
78
|
+
参数
|
|
79
|
+
api_key 形如 ck-live_ / ck-test_ 的 Key
|
|
80
|
+
base_url 默认 https://clavissinica.org
|
|
81
|
+
max_retries 429/5xx 的最大重试次数, 默认 4
|
|
82
|
+
load_catalog 构造时是否拉取服务目录, 默认 True。设 False 可以
|
|
83
|
+
省掉一次网络往返, 但异步服务将无法自动识别,
|
|
84
|
+
需要显式传 poll=True
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL,
|
|
88
|
+
timeout: float = 180.0, max_retries: int = 4,
|
|
89
|
+
session: Optional[requests.Session] = None,
|
|
90
|
+
load_catalog: bool = True):
|
|
91
|
+
if not api_key or not api_key.startswith("ck-"):
|
|
92
|
+
# 提前拦住最常见的两种错: 传了 chinesekey.org 的 pk_live_ Key,
|
|
93
|
+
# 或者把 Stripe 的 cs_live_ 当成了 API Key。服务端也会拒,
|
|
94
|
+
# 但在这里拒能省一次往返, 而且错误信息更直白。
|
|
95
|
+
raise ValueError(
|
|
96
|
+
"api_key 必须以 ck- 开头(ck-live_ 或 ck-test_)。"
|
|
97
|
+
"chinesekey.org 的 pk_live_ Key 不能用在这里; "
|
|
98
|
+
"cs_live_ 开头的是 Stripe checkout 会话 id, 不是 API Key。")
|
|
99
|
+
self.api_key = api_key
|
|
100
|
+
self.base_url = base_url.rstrip("/")
|
|
101
|
+
self.timeout = timeout
|
|
102
|
+
self.max_retries = max_retries
|
|
103
|
+
self.http = session or requests.Session()
|
|
104
|
+
self.http.headers.update({
|
|
105
|
+
"Authorization": "Bearer " + api_key,
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
"User-Agent": "clavis-sinica-python/0.1.0",
|
|
108
|
+
})
|
|
109
|
+
self._catalog: Optional[Dict[str, Any]] = None
|
|
110
|
+
self._modes: Dict[str, str] = {}
|
|
111
|
+
if load_catalog:
|
|
112
|
+
try:
|
|
113
|
+
self.refresh_catalog()
|
|
114
|
+
except ClavisError:
|
|
115
|
+
# 目录拉不到不该让构造失败 —— 调用时显式传 poll 仍然可用
|
|
116
|
+
log.warning("服务目录拉取失败, 异步服务需显式传 poll=True")
|
|
117
|
+
|
|
118
|
+
# ── 服务目录 ────────────────────────────────────────
|
|
119
|
+
def refresh_catalog(self) -> Dict[str, Any]:
|
|
120
|
+
"""拉取 /v1/chinese/services。零费用, 任意有效 Key 可读。"""
|
|
121
|
+
self._catalog = self._request("GET", "/v1/chinese/services")
|
|
122
|
+
self._modes = {
|
|
123
|
+
s["service_code"]: (s.get("execution") or {}).get("mode", "sync")
|
|
124
|
+
for s in self._catalog.get("services", [])
|
|
125
|
+
}
|
|
126
|
+
return self._catalog
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def catalog(self) -> Dict[str, Any]:
|
|
130
|
+
if self._catalog is None:
|
|
131
|
+
self.refresh_catalog()
|
|
132
|
+
return self._catalog
|
|
133
|
+
|
|
134
|
+
def service_codes(self) -> List[str]:
|
|
135
|
+
return list(self._modes) or [
|
|
136
|
+
s["service_code"] for s in self.catalog.get("services", [])]
|
|
137
|
+
|
|
138
|
+
def spec(self, service_code: str) -> Dict[str, Any]:
|
|
139
|
+
"""某个服务的完整字段规格 —— 权威来源, 不要在本地抄一份。"""
|
|
140
|
+
for s in self.catalog.get("services", []):
|
|
141
|
+
if s["service_code"] == service_code:
|
|
142
|
+
return s
|
|
143
|
+
raise KeyError(service_code)
|
|
144
|
+
|
|
145
|
+
# ── 调用 ────────────────────────────────────────────
|
|
146
|
+
def run(self, service_code: str, payload: Dict[str, Any],
|
|
147
|
+
idempotency_key: Optional[str] = None,
|
|
148
|
+
poll: Optional[bool] = None,
|
|
149
|
+
poll_interval: float = 5.0,
|
|
150
|
+
poll_timeout: float = 600.0) -> Dict[str, Any]:
|
|
151
|
+
"""调用一个服务。
|
|
152
|
+
|
|
153
|
+
同步服务直接返回完整响应体。异步服务(submit_poll)默认自动轮询
|
|
154
|
+
到终态再返回; 传 poll=False 可以拿到 {"task_id": ...} 自己轮询。
|
|
155
|
+
"""
|
|
156
|
+
body = {"service_code": service_code, "payload": payload}
|
|
157
|
+
if idempotency_key:
|
|
158
|
+
body["idempotency_key"] = idempotency_key
|
|
159
|
+
out = self._request("POST", "/v1/chinese/run", json=body)
|
|
160
|
+
|
|
161
|
+
if poll is None:
|
|
162
|
+
poll = self._modes.get(service_code) == "submit_poll"
|
|
163
|
+
task_id = out.get("task_id")
|
|
164
|
+
if task_id and poll:
|
|
165
|
+
return self.wait_task(task_id, interval=poll_interval,
|
|
166
|
+
timeout=poll_timeout)
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
def get_task(self, task_id: str) -> Dict[str, Any]:
|
|
170
|
+
return self._request("GET", "/v1/chinese/tasks/" + task_id)
|
|
171
|
+
|
|
172
|
+
def wait_task(self, task_id: str, interval: float = 5.0,
|
|
173
|
+
timeout: float = 600.0) -> Dict[str, Any]:
|
|
174
|
+
deadline = time.time() + timeout
|
|
175
|
+
while True:
|
|
176
|
+
t = self.get_task(task_id)
|
|
177
|
+
if (t.get("status") or "") in _TERMINAL_TASK_STATES:
|
|
178
|
+
return t
|
|
179
|
+
if time.time() >= deadline:
|
|
180
|
+
raise ClavisTimeout(
|
|
181
|
+
"任务 %s 在 %.0f 秒内没有到达终态(它可能仍在执行, "
|
|
182
|
+
"可以稍后用 get_task 继续查)" % (task_id, timeout),
|
|
183
|
+
payload={"task_id": task_id, "last_status": t.get("status")})
|
|
184
|
+
time.sleep(interval)
|
|
185
|
+
|
|
186
|
+
# ── 账户 ────────────────────────────────────────────
|
|
187
|
+
def balance(self) -> Dict[str, Any]:
|
|
188
|
+
return self._request("GET", "/v1/account/balance")
|
|
189
|
+
|
|
190
|
+
def usage(self) -> Dict[str, Any]:
|
|
191
|
+
return self._request("GET", "/v1/account/usage")
|
|
192
|
+
|
|
193
|
+
def ledger(self) -> Dict[str, Any]:
|
|
194
|
+
return self._request("GET", "/v1/account/ledger")
|
|
195
|
+
|
|
196
|
+
# ── 内部 ────────────────────────────────────────────
|
|
197
|
+
def _request(self, method: str, path: str, **kw) -> Dict[str, Any]:
|
|
198
|
+
url = self.base_url + path
|
|
199
|
+
last_exc = None
|
|
200
|
+
for attempt in range(self.max_retries + 1):
|
|
201
|
+
try:
|
|
202
|
+
r = self.http.request(method, url, timeout=self.timeout, **kw)
|
|
203
|
+
except requests.RequestException as e:
|
|
204
|
+
last_exc = ClavisUpstreamError("网络请求失败: %s" % e)
|
|
205
|
+
if attempt < self.max_retries:
|
|
206
|
+
time.sleep(min(2 ** attempt, 30))
|
|
207
|
+
continue
|
|
208
|
+
raise last_exc
|
|
209
|
+
|
|
210
|
+
if r.status_code < 300:
|
|
211
|
+
data = r.json()
|
|
212
|
+
for w in (data.get("warnings") or []):
|
|
213
|
+
# 顶层未知字段会被静默丢弃, 这个数组是唯一提示。
|
|
214
|
+
log.warning("clavis warning: %s", w)
|
|
215
|
+
return data
|
|
216
|
+
|
|
217
|
+
detail, err, msg = self._parse_error(r)
|
|
218
|
+
if r.status_code in _RETRYABLE_STATUS and attempt < self.max_retries:
|
|
219
|
+
wait = self._retry_wait(r, detail, attempt)
|
|
220
|
+
log.warning("HTTP %s (%s), %.1f 秒后重试 (%d/%d)",
|
|
221
|
+
r.status_code, err, wait, attempt + 1, self.max_retries)
|
|
222
|
+
time.sleep(wait)
|
|
223
|
+
continue
|
|
224
|
+
raise self._to_exception(r.status_code, err, msg, detail)
|
|
225
|
+
raise last_exc or ClavisError("请求失败")
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _parse_error(r):
|
|
229
|
+
try:
|
|
230
|
+
body = r.json()
|
|
231
|
+
except Exception:
|
|
232
|
+
# nginx 直接返回的错误是 HTML(比如请求体超过 8MB 被挡在应用之外)
|
|
233
|
+
return ({}, "http_%d" % r.status_code,
|
|
234
|
+
(r.text or "")[:200].strip())
|
|
235
|
+
detail = body.get("detail") if isinstance(body.get("detail"), dict) else body
|
|
236
|
+
return detail, detail.get("error"), detail.get("msg") or str(body)[:200]
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _retry_wait(r, detail, attempt):
|
|
240
|
+
hdr = r.headers.get("Retry-After")
|
|
241
|
+
if hdr:
|
|
242
|
+
try:
|
|
243
|
+
return float(hdr)
|
|
244
|
+
except ValueError:
|
|
245
|
+
pass
|
|
246
|
+
if isinstance(detail, dict) and detail.get("retry_after"):
|
|
247
|
+
return float(detail["retry_after"])
|
|
248
|
+
return min(2 ** attempt, 30)
|
|
249
|
+
|
|
250
|
+
@staticmethod
|
|
251
|
+
def _to_exception(status, err, msg, detail):
|
|
252
|
+
kw = dict(status_code=status, error=err, payload=detail or {})
|
|
253
|
+
if status == 401:
|
|
254
|
+
return ClavisAuthError(msg, **kw)
|
|
255
|
+
if status == 404 or err == "not_found":
|
|
256
|
+
return ClavisNotFound(msg, **kw)
|
|
257
|
+
if status == 429 or err == "rate_limited":
|
|
258
|
+
return ClavisRateLimited(msg, **kw)
|
|
259
|
+
if status in (502, 503) or (err or "").startswith("upstream_"):
|
|
260
|
+
return ClavisUpstreamError(msg, **kw)
|
|
261
|
+
if status == 400 or err == "invalid_param":
|
|
262
|
+
return ClavisParamError(msg, **kw)
|
|
263
|
+
return ClavisError(msg, **kw)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clavis-sinica
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the Clavis Sinica API — Chinese idiom, sentence, passage, classical Chinese and lesson decomposition.
|
|
5
|
+
Author: Clavis Sinica
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://clavissinica.org
|
|
8
|
+
Project-URL: Documentation, https://clavissinica.org/docs
|
|
9
|
+
Project-URL: API Reference, https://clavissinica.org/openapi.json
|
|
10
|
+
Keywords: chinese,nlp,idiom,classical-chinese,hsk,api-client
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: requests>=2.25
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# clavis-sinica
|
|
20
|
+
|
|
21
|
+
Official Python client for the [Clavis Sinica](https://clavissinica.org) API —
|
|
22
|
+
Chinese idiom, sentence, passage, classical Chinese and lesson decomposition.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install clavis-sinica
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from clavis_sinica import Clavis
|
|
30
|
+
|
|
31
|
+
c = Clavis("ck-live_...")
|
|
32
|
+
|
|
33
|
+
c.run("decompose.idiom", {"idiom": "守株待兔"})
|
|
34
|
+
c.run("convert.script", {"text": "汉字", "direction": "s2t"})
|
|
35
|
+
|
|
36
|
+
# Async services poll automatically until done
|
|
37
|
+
c.run("decompose.lesson", {"text": "...", "title": "第一课"})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## What it handles for you
|
|
41
|
+
|
|
42
|
+
- `Authorization: Bearer` auth — the only accepted scheme
|
|
43
|
+
- The `payload` request shape, which works for every service code
|
|
44
|
+
- Fetches the field spec from `GET /v1/chinese/services` at startup instead of
|
|
45
|
+
hardcoding a local copy
|
|
46
|
+
- Retries `429` honouring `Retry-After`, and `502`/`503` with exponential
|
|
47
|
+
backoff; never retries `4xx` that won't succeed on retry
|
|
48
|
+
- Polls async services (`decompose.lesson`, `decompose.wenyan_lesson`) to
|
|
49
|
+
completion
|
|
50
|
+
- Logs the `warnings` array — unknown top-level request fields are dropped
|
|
51
|
+
silently, and that array is the only signal
|
|
52
|
+
|
|
53
|
+
## Errors
|
|
54
|
+
|
|
55
|
+
`ClavisAuthError` (401) · `ClavisParamError` (400, has `.field` / `.expected`) ·
|
|
56
|
+
`ClavisNotFound` (404) · `ClavisRateLimited` (429, has `.retry_after` /
|
|
57
|
+
`.scope`) · `ClavisUpstreamError` (502/503, worth retrying) · `ClavisTimeout`
|
|
58
|
+
|
|
59
|
+
## Not this API
|
|
60
|
+
|
|
61
|
+
Single-character and vocabulary decomposition belong to the sister product
|
|
62
|
+
chinesekey.org — separate pricing, `pk_live_` keys, not interchangeable.
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
clavis_sinica/__init__.py
|
|
5
|
+
clavis_sinica/client.py
|
|
6
|
+
clavis_sinica.egg-info/PKG-INFO
|
|
7
|
+
clavis_sinica.egg-info/SOURCES.txt
|
|
8
|
+
clavis_sinica.egg-info/dependency_links.txt
|
|
9
|
+
clavis_sinica.egg-info/requires.txt
|
|
10
|
+
clavis_sinica.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clavis_sinica
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "clavis-sinica"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the Clavis Sinica API — Chinese idiom, sentence, passage, classical Chinese and lesson decomposition."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Clavis Sinica"}]
|
|
13
|
+
keywords = ["chinese", "nlp", "idiom", "classical-chinese", "hsk", "api-client"]
|
|
14
|
+
dependencies = ["requests>=2.25"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://clavissinica.org"
|
|
18
|
+
Documentation = "https://clavissinica.org/docs"
|
|
19
|
+
"API Reference" = "https://clavissinica.org/openapi.json"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["pytest>=7.0"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["."]
|
|
26
|
+
include = ["clavis_sinica*"]
|