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.
@@ -0,0 +1,9 @@
1
+ **/.venv/
2
+ **/.pytest_cache/
3
+ **/__pycache__/
4
+ **/*.pyc
5
+ **/node_modules/
6
+ **/dist/
7
+ **/build/
8
+ **/*.egg-info/
9
+ **/.coverage
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,4 @@
1
+ from .client import DEFAULT_BASE_URL, YunmaoClient
2
+ from .errors import YunmaoAPIError
3
+
4
+ __all__ = ["DEFAULT_BASE_URL", "YunmaoAPIError", "YunmaoClient"]
@@ -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,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"]