yunmao 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.
- yunmao-0.1.0/.gitignore +9 -0
- yunmao-0.1.0/PKG-INFO +149 -0
- yunmao-0.1.0/README.md +134 -0
- yunmao-0.1.0/pyproject.toml +28 -0
- yunmao-0.1.0/src/yunmao/__init__.py +4 -0
- yunmao-0.1.0/src/yunmao/client.py +174 -0
- yunmao-0.1.0/src/yunmao/errors.py +30 -0
- yunmao-0.1.0/src/yunmao/resources/__init__.py +1 -0
- yunmao-0.1.0/src/yunmao/resources/asr.py +46 -0
- yunmao-0.1.0/src/yunmao/resources/oil.py +26 -0
- yunmao-0.1.0/src/yunmao/resources/translate.py +32 -0
- yunmao-0.1.0/src/yunmao/resources/tts.py +34 -0
- yunmao-0.1.0/src/yunmao/resources/video.py +20 -0
- yunmao-0.1.0/src/yunmao/resources/voices.py +36 -0
- yunmao-0.1.0/src/yunmao/types.py +109 -0
- yunmao-0.1.0/tests/test_client.py +93 -0
yunmao-0.1.0/.gitignore
ADDED
yunmao-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yunmao
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for Yunmao Open Platform APIs
|
|
5
|
+
Project-URL: Homepage, https://open.xjymai.com
|
|
6
|
+
Project-URL: Documentation, https://open.xjymai.com/docs
|
|
7
|
+
Author: Yunmao
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: asr,openapi,translation,tts,yunmao
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: httpx<1,>=0.26
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Yunmao Python SDK
|
|
17
|
+
|
|
18
|
+
Python SDK for Yunmao Open Platform.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install yunmao
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For local development:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -e ".[test]"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from yunmao import YunmaoClient
|
|
36
|
+
|
|
37
|
+
client = YunmaoClient(
|
|
38
|
+
api_key="ym_sk_xxxxxxxxxxxxxxxxx",
|
|
39
|
+
# Use this for dev testing:
|
|
40
|
+
# base_url="https://gateway-dpkhzbkdjj.cn-hangzhou.fcapp.run",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
result = client.translate.text(
|
|
44
|
+
"Welcome to Yunmao Open Platform.",
|
|
45
|
+
target_language="ug",
|
|
46
|
+
source_language="auto",
|
|
47
|
+
)
|
|
48
|
+
print(result["translated_text"])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The default `base_url` is `https://api.xjymai.com`. Do not use production API keys for development tests; pass the dev gateway `base_url` with a dev API key.
|
|
52
|
+
|
|
53
|
+
## Examples
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
client.translate.text("hello", target_language="ug")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
client.tts.synthesize(
|
|
61
|
+
"欢迎使用云猫开放平台。",
|
|
62
|
+
voice_id="voice_id_here",
|
|
63
|
+
language_code="zh",
|
|
64
|
+
speed=1.0,
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
client.asr.recognize(
|
|
70
|
+
"/path/to/audio.wav",
|
|
71
|
+
language_hint="mul_cn",
|
|
72
|
+
sample_rate=16000,
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
client.video.parse(url="https://v.douyin.com/example/")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
client.oil.price(province="新疆")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
client.voices.clone(
|
|
86
|
+
name="Demo voice",
|
|
87
|
+
language_code="zh",
|
|
88
|
+
ref_text="这是样本音频中的朗读文本。",
|
|
89
|
+
sample_url="https://oss.example.com/sample.wav",
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Errors
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from yunmao import YunmaoAPIError
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
client.translate.text("hello", target_language="ug")
|
|
100
|
+
except YunmaoAPIError as error:
|
|
101
|
+
print(error.status_code)
|
|
102
|
+
print(error.code)
|
|
103
|
+
print(error.message)
|
|
104
|
+
print(error.request_id)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The SDK preserves:
|
|
108
|
+
|
|
109
|
+
- `status_code`: HTTP status
|
|
110
|
+
- `code`: Yunmao business error code
|
|
111
|
+
- `message`: server `msg`
|
|
112
|
+
- `request_id`: from `X-Request-ID`, `request_id`, or `requestId` when the server provides it
|
|
113
|
+
- `response_body`: parsed raw response body
|
|
114
|
+
|
|
115
|
+
Common Open API codes:
|
|
116
|
+
|
|
117
|
+
| code | HTTP | Meaning |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| `40102` | 401 | Invalid or missing API Key |
|
|
120
|
+
| `40301` | 403 | API Key scope denied |
|
|
121
|
+
| `40302` | 403 | Quota exhausted |
|
|
122
|
+
| `40303` | 403 | Real-name verification required |
|
|
123
|
+
| `40401` | 404 | Resource not found |
|
|
124
|
+
| `42201` | 422 | Invalid JSON body |
|
|
125
|
+
| `42202` | 422 | Invalid parameter |
|
|
126
|
+
| `42901` | 429 | Concurrency exceeded |
|
|
127
|
+
| `50201` | 502 | Downstream service unavailable |
|
|
128
|
+
| `50301` | 503 | Database unavailable |
|
|
129
|
+
| `50000` | 500 | Internal server error |
|
|
130
|
+
|
|
131
|
+
## Voice List Note
|
|
132
|
+
|
|
133
|
+
The current voice list endpoint is a console JWT endpoint (`GET /api/v1/open-platform/voices`), not an API Key Open API endpoint. This SDK exposes API Key calls only, so it includes `voices.clone()` but does not expose voice listing yet. Add a server-side `GET /v1/voices` endpoint before adding that SDK method.
|
|
134
|
+
|
|
135
|
+
## Test
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pytest
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Tests use `httpx.MockTransport`; they do not call dev or production paid APIs.
|
|
142
|
+
|
|
143
|
+
## Publish
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
python -m pip install build twine
|
|
147
|
+
python -m build
|
|
148
|
+
twine upload dist/*
|
|
149
|
+
```
|
yunmao-0.1.0/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Yunmao Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for Yunmao Open Platform.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install yunmao
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For local development:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install -e ".[test]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from yunmao import YunmaoClient
|
|
21
|
+
|
|
22
|
+
client = YunmaoClient(
|
|
23
|
+
api_key="ym_sk_xxxxxxxxxxxxxxxxx",
|
|
24
|
+
# Use this for dev testing:
|
|
25
|
+
# base_url="https://gateway-dpkhzbkdjj.cn-hangzhou.fcapp.run",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
result = client.translate.text(
|
|
29
|
+
"Welcome to Yunmao Open Platform.",
|
|
30
|
+
target_language="ug",
|
|
31
|
+
source_language="auto",
|
|
32
|
+
)
|
|
33
|
+
print(result["translated_text"])
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The default `base_url` is `https://api.xjymai.com`. Do not use production API keys for development tests; pass the dev gateway `base_url` with a dev API key.
|
|
37
|
+
|
|
38
|
+
## Examples
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
client.translate.text("hello", target_language="ug")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
client.tts.synthesize(
|
|
46
|
+
"欢迎使用云猫开放平台。",
|
|
47
|
+
voice_id="voice_id_here",
|
|
48
|
+
language_code="zh",
|
|
49
|
+
speed=1.0,
|
|
50
|
+
)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client.asr.recognize(
|
|
55
|
+
"/path/to/audio.wav",
|
|
56
|
+
language_hint="mul_cn",
|
|
57
|
+
sample_rate=16000,
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
client.video.parse(url="https://v.douyin.com/example/")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
client.oil.price(province="新疆")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
client.voices.clone(
|
|
71
|
+
name="Demo voice",
|
|
72
|
+
language_code="zh",
|
|
73
|
+
ref_text="这是样本音频中的朗读文本。",
|
|
74
|
+
sample_url="https://oss.example.com/sample.wav",
|
|
75
|
+
)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Errors
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from yunmao import YunmaoAPIError
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
client.translate.text("hello", target_language="ug")
|
|
85
|
+
except YunmaoAPIError as error:
|
|
86
|
+
print(error.status_code)
|
|
87
|
+
print(error.code)
|
|
88
|
+
print(error.message)
|
|
89
|
+
print(error.request_id)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The SDK preserves:
|
|
93
|
+
|
|
94
|
+
- `status_code`: HTTP status
|
|
95
|
+
- `code`: Yunmao business error code
|
|
96
|
+
- `message`: server `msg`
|
|
97
|
+
- `request_id`: from `X-Request-ID`, `request_id`, or `requestId` when the server provides it
|
|
98
|
+
- `response_body`: parsed raw response body
|
|
99
|
+
|
|
100
|
+
Common Open API codes:
|
|
101
|
+
|
|
102
|
+
| code | HTTP | Meaning |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `40102` | 401 | Invalid or missing API Key |
|
|
105
|
+
| `40301` | 403 | API Key scope denied |
|
|
106
|
+
| `40302` | 403 | Quota exhausted |
|
|
107
|
+
| `40303` | 403 | Real-name verification required |
|
|
108
|
+
| `40401` | 404 | Resource not found |
|
|
109
|
+
| `42201` | 422 | Invalid JSON body |
|
|
110
|
+
| `42202` | 422 | Invalid parameter |
|
|
111
|
+
| `42901` | 429 | Concurrency exceeded |
|
|
112
|
+
| `50201` | 502 | Downstream service unavailable |
|
|
113
|
+
| `50301` | 503 | Database unavailable |
|
|
114
|
+
| `50000` | 500 | Internal server error |
|
|
115
|
+
|
|
116
|
+
## Voice List Note
|
|
117
|
+
|
|
118
|
+
The current voice list endpoint is a console JWT endpoint (`GET /api/v1/open-platform/voices`), not an API Key Open API endpoint. This SDK exposes API Key calls only, so it includes `voices.clone()` but does not expose voice listing yet. Add a server-side `GET /v1/voices` endpoint before adding that SDK method.
|
|
119
|
+
|
|
120
|
+
## Test
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Tests use `httpx.MockTransport`; they do not call dev or production paid APIs.
|
|
127
|
+
|
|
128
|
+
## Publish
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python -m pip install build twine
|
|
132
|
+
python -m build
|
|
133
|
+
twine upload dist/*
|
|
134
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "yunmao"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for Yunmao Open Platform APIs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Yunmao" }]
|
|
13
|
+
keywords = ["yunmao", "openapi", "translation", "tts", "asr"]
|
|
14
|
+
dependencies = ["httpx>=0.26,<1"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
test = ["pytest>=8"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://open.xjymai.com"
|
|
21
|
+
Documentation = "https://open.xjymai.com/docs"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/yunmao"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
28
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, BinaryIO, Mapping
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import YunmaoAPIError
|
|
11
|
+
from .resources.asr import ASRResource
|
|
12
|
+
from .resources.oil import OilResource
|
|
13
|
+
from .resources.translate import TranslateResource
|
|
14
|
+
from .resources.tts import TTSResource
|
|
15
|
+
from .resources.video import VideoResource
|
|
16
|
+
from .resources.voices import VoicesResource
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://api.xjymai.com"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class YunmaoClient:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_key: str,
|
|
25
|
+
*,
|
|
26
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
27
|
+
timeout: float | httpx.Timeout = 60.0,
|
|
28
|
+
http_client: httpx.Client | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if not api_key or not api_key.strip():
|
|
31
|
+
raise ValueError("api_key is required")
|
|
32
|
+
|
|
33
|
+
self.api_key = api_key.strip()
|
|
34
|
+
self.base_url = base_url.rstrip("/")
|
|
35
|
+
self._owns_client = http_client is None
|
|
36
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
37
|
+
|
|
38
|
+
self.translate = TranslateResource(self)
|
|
39
|
+
self.tts = TTSResource(self)
|
|
40
|
+
self.asr = ASRResource(self)
|
|
41
|
+
self.video = VideoResource(self)
|
|
42
|
+
self.oil = OilResource(self)
|
|
43
|
+
self.voices = VoicesResource(self)
|
|
44
|
+
|
|
45
|
+
def close(self) -> None:
|
|
46
|
+
if self._owns_client:
|
|
47
|
+
self._client.close()
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> "YunmaoClient":
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *_: Any) -> None:
|
|
53
|
+
self.close()
|
|
54
|
+
|
|
55
|
+
def request(
|
|
56
|
+
self,
|
|
57
|
+
method: str,
|
|
58
|
+
path: str,
|
|
59
|
+
*,
|
|
60
|
+
json_body: Mapping[str, Any] | None = None,
|
|
61
|
+
params: Mapping[str, Any] | None = None,
|
|
62
|
+
files: Any = None,
|
|
63
|
+
data: Mapping[str, Any] | None = None,
|
|
64
|
+
) -> Any:
|
|
65
|
+
headers = {"Authorization": f"Bearer {self.api_key}"}
|
|
66
|
+
if json_body is not None:
|
|
67
|
+
headers["Content-Type"] = "application/json"
|
|
68
|
+
|
|
69
|
+
response = self._client.request(
|
|
70
|
+
method,
|
|
71
|
+
self._url(path),
|
|
72
|
+
headers=headers,
|
|
73
|
+
json=_compact(json_body) if json_body is not None else None,
|
|
74
|
+
params=_compact(params),
|
|
75
|
+
files=files,
|
|
76
|
+
data=_compact(data),
|
|
77
|
+
)
|
|
78
|
+
return self._parse_response(response)
|
|
79
|
+
|
|
80
|
+
def _url(self, path: str) -> str:
|
|
81
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
82
|
+
return path
|
|
83
|
+
return f"{self.base_url}/{path.lstrip('/')}"
|
|
84
|
+
|
|
85
|
+
def _parse_response(self, response: httpx.Response) -> Any:
|
|
86
|
+
payload: Any
|
|
87
|
+
try:
|
|
88
|
+
payload = response.json()
|
|
89
|
+
except json.JSONDecodeError as exc:
|
|
90
|
+
raise YunmaoAPIError(
|
|
91
|
+
"服务响应格式错误",
|
|
92
|
+
status_code=response.status_code,
|
|
93
|
+
request_id=_request_id(response, None),
|
|
94
|
+
response_body=response.text,
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
envelope = payload if isinstance(payload, dict) and "code" in payload and "data" in payload else None
|
|
98
|
+
request_id = _request_id(response, payload)
|
|
99
|
+
|
|
100
|
+
if not response.is_success:
|
|
101
|
+
raise YunmaoAPIError(
|
|
102
|
+
_message(payload, response.reason_phrase or "请求失败"),
|
|
103
|
+
status_code=response.status_code,
|
|
104
|
+
code=_code(payload, response.status_code),
|
|
105
|
+
request_id=request_id,
|
|
106
|
+
response_body=payload,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if envelope is not None:
|
|
110
|
+
code = int(envelope.get("code") or 0)
|
|
111
|
+
if code != 0:
|
|
112
|
+
raise YunmaoAPIError(
|
|
113
|
+
str(envelope.get("msg") or "请求失败"),
|
|
114
|
+
status_code=response.status_code,
|
|
115
|
+
code=code,
|
|
116
|
+
request_id=request_id,
|
|
117
|
+
response_body=payload,
|
|
118
|
+
)
|
|
119
|
+
return envelope.get("data")
|
|
120
|
+
|
|
121
|
+
return payload
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _compact(values: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
125
|
+
if not values:
|
|
126
|
+
return None
|
|
127
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _message(payload: Any, fallback: str) -> str:
|
|
131
|
+
if isinstance(payload, dict):
|
|
132
|
+
value = payload.get("msg") or payload.get("message") or payload.get("error")
|
|
133
|
+
if value:
|
|
134
|
+
return str(value)
|
|
135
|
+
return fallback
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _code(payload: Any, fallback: int) -> int:
|
|
139
|
+
if isinstance(payload, dict):
|
|
140
|
+
value = payload.get("code")
|
|
141
|
+
if isinstance(value, int):
|
|
142
|
+
return value
|
|
143
|
+
return fallback
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _request_id(response: httpx.Response, payload: Any) -> str | None:
|
|
147
|
+
for header in ("X-Request-ID", "X-Request-Id", "x-request-id", "Request-Id"):
|
|
148
|
+
value = response.headers.get(header)
|
|
149
|
+
if value:
|
|
150
|
+
return value
|
|
151
|
+
if isinstance(payload, dict):
|
|
152
|
+
value = payload.get("request_id") or payload.get("requestId")
|
|
153
|
+
if value:
|
|
154
|
+
return str(value)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_audio_file(
|
|
159
|
+
audio_file: str | Path | bytes | BinaryIO,
|
|
160
|
+
*,
|
|
161
|
+
filename: str | None = None,
|
|
162
|
+
content_type: str | None = None,
|
|
163
|
+
) -> tuple[str, Any, str | None]:
|
|
164
|
+
if isinstance(audio_file, (str, Path)):
|
|
165
|
+
path = Path(audio_file)
|
|
166
|
+
guessed_type = content_type or mimetypes.guess_type(path.name)[0]
|
|
167
|
+
return path.name, path.open("rb"), guessed_type
|
|
168
|
+
if isinstance(audio_file, bytes):
|
|
169
|
+
name = filename or "audio.wav"
|
|
170
|
+
guessed_type = content_type or mimetypes.guess_type(name)[0]
|
|
171
|
+
return name, audio_file, guessed_type
|
|
172
|
+
name = filename or getattr(audio_file, "name", None) or "audio.wav"
|
|
173
|
+
guessed_type = content_type or mimetypes.guess_type(str(name))[0]
|
|
174
|
+
return Path(str(name)).name, audio_file, guessed_type
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class YunmaoAPIError(Exception):
|
|
7
|
+
"""Error returned by Yunmao Open Platform or raised while parsing a response."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
status_code: int | None = None,
|
|
14
|
+
code: int | None = None,
|
|
15
|
+
request_id: str | None = None,
|
|
16
|
+
response_body: Any = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.message = message
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
self.code = code
|
|
22
|
+
self.request_id = request_id
|
|
23
|
+
self.response_body = response_body
|
|
24
|
+
|
|
25
|
+
def __repr__(self) -> str:
|
|
26
|
+
return (
|
|
27
|
+
"YunmaoAPIError("
|
|
28
|
+
f"message={self.message!r}, status_code={self.status_code!r}, "
|
|
29
|
+
f"code={self.code!r}, request_id={self.request_id!r})"
|
|
30
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TYPE_CHECKING, BinaryIO
|
|
5
|
+
|
|
6
|
+
from ..types import ASRResponse
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from ..client import YunmaoClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ASRResource:
|
|
13
|
+
def __init__(self, client: "YunmaoClient") -> None:
|
|
14
|
+
self._client = client
|
|
15
|
+
|
|
16
|
+
def recognize(
|
|
17
|
+
self,
|
|
18
|
+
audio_file: str | Path | bytes | BinaryIO,
|
|
19
|
+
*,
|
|
20
|
+
filename: str | None = None,
|
|
21
|
+
content_type: str | None = None,
|
|
22
|
+
language_hint: str | None = None,
|
|
23
|
+
audio_format: str | None = None,
|
|
24
|
+
sample_rate: int | None = None,
|
|
25
|
+
audio_duration_ms: int | None = None,
|
|
26
|
+
) -> ASRResponse:
|
|
27
|
+
from ..client import build_audio_file
|
|
28
|
+
|
|
29
|
+
file_name, file_content, guessed_type = build_audio_file(
|
|
30
|
+
audio_file,
|
|
31
|
+
filename=filename,
|
|
32
|
+
content_type=content_type,
|
|
33
|
+
)
|
|
34
|
+
should_close = hasattr(file_content, "close")
|
|
35
|
+
try:
|
|
36
|
+
files = {"audio_file": (file_name, file_content, guessed_type)}
|
|
37
|
+
data = {
|
|
38
|
+
"language_hint": language_hint,
|
|
39
|
+
"audio_format": audio_format,
|
|
40
|
+
"sample_rate": sample_rate,
|
|
41
|
+
"audio_duration_ms": audio_duration_ms,
|
|
42
|
+
}
|
|
43
|
+
return self._client.request("POST", "/v1/asr/recognize", files=files, data=data)
|
|
44
|
+
finally:
|
|
45
|
+
if should_close:
|
|
46
|
+
file_content.close()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..types import OilPriceResponse
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..client import YunmaoClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OilResource:
|
|
12
|
+
def __init__(self, client: "YunmaoClient") -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
def price(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
province: str | None = None,
|
|
19
|
+
region_name: str | None = None,
|
|
20
|
+
region_code: str | None = None,
|
|
21
|
+
) -> OilPriceResponse:
|
|
22
|
+
return self._client.request(
|
|
23
|
+
"GET",
|
|
24
|
+
"/v1/oil/price",
|
|
25
|
+
params={"province": province, "region_name": region_name, "region_code": region_code},
|
|
26
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..types import TranslateTextResponse
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..client import YunmaoClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TranslateResource:
|
|
12
|
+
def __init__(self, client: "YunmaoClient") -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
def text(
|
|
16
|
+
self,
|
|
17
|
+
text: str,
|
|
18
|
+
*,
|
|
19
|
+
target_language: str,
|
|
20
|
+
source_language: str | None = None,
|
|
21
|
+
direction: str | None = None,
|
|
22
|
+
) -> TranslateTextResponse:
|
|
23
|
+
return self._client.request(
|
|
24
|
+
"POST",
|
|
25
|
+
"/v1/translate/text",
|
|
26
|
+
json_body={
|
|
27
|
+
"text": text,
|
|
28
|
+
"target_language": target_language,
|
|
29
|
+
"source_language": source_language,
|
|
30
|
+
"direction": direction,
|
|
31
|
+
},
|
|
32
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..types import TTSResponse
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..client import YunmaoClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TTSResource:
|
|
12
|
+
def __init__(self, client: "YunmaoClient") -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
def synthesize(
|
|
16
|
+
self,
|
|
17
|
+
text: str,
|
|
18
|
+
*,
|
|
19
|
+
voice_id: str | None = None,
|
|
20
|
+
language_code: str | None = None,
|
|
21
|
+
speed: float | None = None,
|
|
22
|
+
duration_seconds: float | None = None,
|
|
23
|
+
) -> TTSResponse:
|
|
24
|
+
return self._client.request(
|
|
25
|
+
"POST",
|
|
26
|
+
"/v1/tts/synthesize",
|
|
27
|
+
json_body={
|
|
28
|
+
"text": text,
|
|
29
|
+
"voice_id": voice_id,
|
|
30
|
+
"language_code": language_code,
|
|
31
|
+
"speed": speed,
|
|
32
|
+
"duration_seconds": duration_seconds,
|
|
33
|
+
},
|
|
34
|
+
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..types import VideoParseResponse
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..client import YunmaoClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VideoResource:
|
|
12
|
+
def __init__(self, client: "YunmaoClient") -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
def parse(self, *, url: str | None = None, content: str | None = None) -> VideoParseResponse:
|
|
16
|
+
return self._client.request(
|
|
17
|
+
"POST",
|
|
18
|
+
"/v1/video/parse",
|
|
19
|
+
json_body={"url": url, "content": content},
|
|
20
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from ..types import VoiceCloneResponse
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ..client import YunmaoClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VoicesResource:
|
|
12
|
+
def __init__(self, client: "YunmaoClient") -> None:
|
|
13
|
+
self._client = client
|
|
14
|
+
|
|
15
|
+
def clone(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
name: str,
|
|
19
|
+
ref_text: str,
|
|
20
|
+
language_code: str | None = None,
|
|
21
|
+
icon_url: str | None = None,
|
|
22
|
+
sample_object_key: str | None = None,
|
|
23
|
+
sample_url: str | None = None,
|
|
24
|
+
) -> VoiceCloneResponse:
|
|
25
|
+
return self._client.request(
|
|
26
|
+
"POST",
|
|
27
|
+
"/v1/voice/clone",
|
|
28
|
+
json_body={
|
|
29
|
+
"name": name,
|
|
30
|
+
"language_code": language_code,
|
|
31
|
+
"ref_text": ref_text,
|
|
32
|
+
"icon_url": icon_url,
|
|
33
|
+
"sample_object_key": sample_object_key,
|
|
34
|
+
"sample_url": sample_url,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Usage(TypedDict):
|
|
7
|
+
unit: str
|
|
8
|
+
amount: int
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TranslationVariant(TypedDict):
|
|
12
|
+
id: str
|
|
13
|
+
label: str
|
|
14
|
+
translated_text: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TranslateTextResponse(TypedDict, total=False):
|
|
18
|
+
id: str
|
|
19
|
+
source_language: str
|
|
20
|
+
target_language: str
|
|
21
|
+
source_text: str
|
|
22
|
+
translated_text: str
|
|
23
|
+
translation_variants: List[TranslationVariant]
|
|
24
|
+
usage: Usage
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TTSResponse(TypedDict, total=False):
|
|
28
|
+
id: str
|
|
29
|
+
voice_id: str
|
|
30
|
+
language_code: str
|
|
31
|
+
text: str
|
|
32
|
+
audio_url: str
|
|
33
|
+
audio_format: str
|
|
34
|
+
sample_rate: Optional[int]
|
|
35
|
+
audio_duration_ms: Optional[int]
|
|
36
|
+
usage: Usage
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ASRResponse(TypedDict, total=False):
|
|
40
|
+
id: str
|
|
41
|
+
language_hint: str
|
|
42
|
+
recognized_text: str
|
|
43
|
+
audio_format: str
|
|
44
|
+
sample_rate: int
|
|
45
|
+
audio_duration_ms: Optional[int]
|
|
46
|
+
status: str
|
|
47
|
+
usage: Usage
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VideoParseResponse(TypedDict, total=False):
|
|
51
|
+
id: str
|
|
52
|
+
source_url: str
|
|
53
|
+
resolved_url: Optional[str]
|
|
54
|
+
platform: Optional[str]
|
|
55
|
+
content_type: Optional[str]
|
|
56
|
+
title: Optional[str]
|
|
57
|
+
description: Optional[str]
|
|
58
|
+
author_name: Optional[str]
|
|
59
|
+
author_avatar_url: Optional[str]
|
|
60
|
+
cover_url: Optional[str]
|
|
61
|
+
video_url: Optional[str]
|
|
62
|
+
audio_url: Optional[str]
|
|
63
|
+
duration_seconds: Optional[int]
|
|
64
|
+
image_count: int
|
|
65
|
+
images: List[Dict[str, Any]]
|
|
66
|
+
status: str
|
|
67
|
+
usage: Usage
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class OilPriceResponse(TypedDict, total=False):
|
|
71
|
+
region_name: str
|
|
72
|
+
fuel_grade: str
|
|
73
|
+
fuel_price: str
|
|
74
|
+
fuel_details: List[Dict[str, Any]]
|
|
75
|
+
currency: str
|
|
76
|
+
source_mode: str
|
|
77
|
+
fuel_updated_date: Optional[str]
|
|
78
|
+
items: List[Dict[str, Any]]
|
|
79
|
+
fetched_at: Optional[str]
|
|
80
|
+
usage: Usage
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class VoiceAsset(TypedDict, total=False):
|
|
84
|
+
voice_id: str
|
|
85
|
+
organization_id: Optional[str]
|
|
86
|
+
name: str
|
|
87
|
+
voice_type: str
|
|
88
|
+
language_code: Optional[str]
|
|
89
|
+
icon_url: Optional[str]
|
|
90
|
+
ref_text: Optional[str]
|
|
91
|
+
sample_object_key: Optional[str]
|
|
92
|
+
sample_url: Optional[str]
|
|
93
|
+
preview_audio_url: Optional[str]
|
|
94
|
+
status: str
|
|
95
|
+
is_default: bool
|
|
96
|
+
sort_order: int
|
|
97
|
+
created_at: str
|
|
98
|
+
updated_at: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class VoiceEntitlement(TypedDict, total=False):
|
|
102
|
+
max_voice_count: int
|
|
103
|
+
used_voice_count: int
|
|
104
|
+
remaining_clone_count: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class VoiceCloneResponse(TypedDict, total=False):
|
|
108
|
+
voice: VoiceAsset
|
|
109
|
+
entitlement: VoiceEntitlement
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from yunmao import YunmaoAPIError, YunmaoClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_client(handler):
|
|
10
|
+
transport = httpx.MockTransport(handler)
|
|
11
|
+
return YunmaoClient(
|
|
12
|
+
"ym_sk_test",
|
|
13
|
+
base_url="https://dev.example.com",
|
|
14
|
+
http_client=httpx.Client(transport=transport),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_translate_sends_auth_and_unwraps_envelope():
|
|
19
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
20
|
+
assert request.url == "https://dev.example.com/v1/translate/text"
|
|
21
|
+
assert request.headers["authorization"] == "Bearer ym_sk_test"
|
|
22
|
+
assert json.loads(request.content) == {"text": "hello", "target_language": "ug"}
|
|
23
|
+
return httpx.Response(
|
|
24
|
+
200,
|
|
25
|
+
json={
|
|
26
|
+
"code": 0,
|
|
27
|
+
"msg": "ok",
|
|
28
|
+
"data": {"translated_text": "ياخشىمۇسىز", "usage": {"unit": "character", "amount": 5}},
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
client = make_client(handler)
|
|
33
|
+
result = client.translate.text("hello", target_language="ug")
|
|
34
|
+
|
|
35
|
+
assert result["translated_text"] == "ياخشىمۇسىز"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_tts_accepts_direct_success_payload():
|
|
39
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
40
|
+
return httpx.Response(200, json={"audio_url": "https://oss.example.com/a.wav"})
|
|
41
|
+
|
|
42
|
+
client = make_client(handler)
|
|
43
|
+
result = client.tts.synthesize("hi", voice_id="voice_1")
|
|
44
|
+
|
|
45
|
+
assert result["audio_url"] == "https://oss.example.com/a.wav"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_error_preserves_status_code_code_msg_request_id_and_body():
|
|
49
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
50
|
+
return httpx.Response(
|
|
51
|
+
403,
|
|
52
|
+
headers={"X-Request-ID": "req_123"},
|
|
53
|
+
json={"code": 40302, "msg": "额度不足", "data": None},
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
client = make_client(handler)
|
|
57
|
+
|
|
58
|
+
with pytest.raises(YunmaoAPIError) as exc_info:
|
|
59
|
+
client.oil.price(province="新疆")
|
|
60
|
+
|
|
61
|
+
error = exc_info.value
|
|
62
|
+
assert error.status_code == 403
|
|
63
|
+
assert error.code == 40302
|
|
64
|
+
assert error.message == "额度不足"
|
|
65
|
+
assert error.request_id == "req_123"
|
|
66
|
+
assert error.response_body["code"] == 40302
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_asr_uploads_multipart_audio_file():
|
|
70
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
71
|
+
body = request.content
|
|
72
|
+
assert b'name="audio_file"; filename="sample.wav"' in body
|
|
73
|
+
assert b'name="language_hint"' in body
|
|
74
|
+
assert b"mul_cn" in body
|
|
75
|
+
return httpx.Response(200, json={"recognized_text": "hello", "usage": {"unit": "minute", "amount": 1}})
|
|
76
|
+
|
|
77
|
+
client = make_client(handler)
|
|
78
|
+
result = client.asr.recognize(b"RIFF....", filename="sample.wav", language_hint="mul_cn")
|
|
79
|
+
|
|
80
|
+
assert result["recognized_text"] == "hello"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_video_and_voice_paths():
|
|
84
|
+
seen_paths = []
|
|
85
|
+
|
|
86
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
87
|
+
seen_paths.append(request.url.path)
|
|
88
|
+
return httpx.Response(200, json={"code": 0, "msg": "ok", "data": {"ok": True}})
|
|
89
|
+
|
|
90
|
+
client = make_client(handler)
|
|
91
|
+
assert client.video.parse(url="https://v.example")["ok"] is True
|
|
92
|
+
assert client.voices.clone(name="demo", ref_text="hello", sample_url="https://oss.example/a.wav")["ok"] is True
|
|
93
|
+
assert seen_paths == ["/v1/video/parse", "/v1/voice/clone"]
|