verhub-sdk 0.2.3__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,5 @@
1
+ dist/
2
+ *.egg-info/
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: verhub-sdk
3
+ Version: 0.2.3
4
+ Summary: Verhub 版本与发布管理平台的官方 Python SDK
5
+ Project-URL: Homepage, https://github.com/IvanHanloth/verhub
6
+ Project-URL: Documentation, https://ivanhanloth.github.io/Verhub/reference/sdk
7
+ Project-URL: Repository, https://github.com/IvanHanloth/verhub
8
+ Project-URL: Issues, https://github.com/IvanHanloth/verhub/issues
9
+ Author-email: IvanHanloth <ivan@hanloth.com>
10
+ License-Expression: Apache-2.0
11
+ Keywords: changelog,release,sdk,update,verhub,version
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: requests<3,>=2.28
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Verhub SDK (Python)
28
+
29
+ [Verhub](https://github.com/IvanHanloth/verhub) 版本与发布管理平台的官方 Python SDK。
30
+
31
+ 接口面与 TypeScript / Rust / 纯 JS 版一一对应,只是方法名按 Python 习惯写成 snake_case。
32
+ 完整的方法清单与跨语言对照见[《SDK 参考》](https://ivanhanloth.github.io/Verhub/reference/sdk)。
33
+
34
+ ## 安装
35
+
36
+ ```bash
37
+ pip install verhub-sdk
38
+ ```
39
+
40
+ ## 快速开始
41
+
42
+ ```python
43
+ from verhub_sdk import VerhubClient
44
+
45
+ # 第二个参数是绑定的项目 key,之后项目作用域的方法都不用再传它
46
+ client = VerhubClient("https://verhub.example.com/api/v1", "verhub")
47
+
48
+ result = client.public.check_update(current_version="1.1.0")
49
+ if result["should_update"]:
50
+ print(result["target_version"]["version"], result["target_version"]["content"])
51
+ ```
52
+
53
+ `base_url` 要带上 `/api/v1` 前缀,也就是浏览器里能直接打开 `/health` 的那个地址。
54
+
55
+ ## 两个命名空间
56
+
57
+ - `client.public` — 公开接口,不需要凭据,客户端 App 直接调用
58
+ - `client.admin` — 管理接口,需要管理员 JWT 或 API Key
59
+
60
+ ```python
61
+ client = VerhubClient("https://verhub.example.com/api/v1", "verhub", token="vh_xxx")
62
+
63
+ client.admin.upsert_version(
64
+ "v1.2.0",
65
+ comparable_version="1.2.0",
66
+ title="稳定版",
67
+ content="修复若干问题。",
68
+ is_latest=True,
69
+ )
70
+ ```
71
+
72
+ 凭据与绑定项目都可以事后更换:`client.set_token(token)` / `client.set_project_key(key)`。
73
+ 没绑定项目就调项目作用域的方法会抛 `VerhubError`。
74
+
75
+ ## 省略与置空
76
+
77
+ 可选参数的默认值是 `UNSET` 而不是 `None`,这两者含义不同:
78
+
79
+ - 不传该参数 → 字段不出现在请求里 → 更新接口保持原值
80
+ - 显式传 `None` → 字段以 JSON `null` 提交 → 更新接口把该字段置空
81
+
82
+ ```python
83
+ client.admin.update_version("ver-001", download_url=None) # 清空下载地址
84
+ client.admin.update_version("ver-001", title="改个标题") # 只动标题
85
+ ```
86
+
87
+ ## 平台与系统版本声明
88
+
89
+ SDK 默认按运行环境自动探测平台**与系统版本**(如 Windows `11`、`ubuntu 24.04`、
90
+ macOS `14.5.0`),通过 `x-verhub-platform` / `x-verhub-platform-version` 两个请求头
91
+ 声明,供服务端做来源统计——这不影响任何接口的返回内容。
92
+
93
+ ```python
94
+ # 覆盖探测结果
95
+ client = VerhubClient(base_url, "verhub", platform="linux", platform_version="ubuntu 24.04")
96
+
97
+ # 事后更新
98
+ client.set_platform("linux")
99
+ client.set_platform_version("ubuntu 24.04")
100
+
101
+ # 完全不声明平台(也就不再自动探测版本)
102
+ client = VerhubClient(base_url, "verhub", platform=None)
103
+ ```
104
+
105
+ ## 错误处理
106
+
107
+ ```python
108
+ from verhub_sdk import VerhubApiError, VerhubConnectionError
109
+
110
+ try:
111
+ client.public.get_latest_version()
112
+ except VerhubApiError as exc:
113
+ print(exc.status, exc.message, exc.body) # 服务端返回非 2xx
114
+ except VerhubConnectionError as exc:
115
+ print(exc.cause) # 请求没到服务端
116
+ ```
117
+
118
+ 两者都继承自 `VerhubError`。
119
+
120
+ ## 其他
121
+
122
+ - 返回值是解析后的 `dict`;`verhub_sdk.models` 里有对应的 `TypedDict`,
123
+ 供编辑器补全和 mypy 使用,运行时不做校验也不做拷贝。
124
+ - 需要代理、自定义证书或重试策略时,传入自己的 `requests.Session`:
125
+ `VerhubClient(base_url, session=my_session)`。
126
+ - 客户端可作为上下文管理器使用,退出时关闭连接池。
@@ -0,0 +1,100 @@
1
+ # Verhub SDK (Python)
2
+
3
+ [Verhub](https://github.com/IvanHanloth/verhub) 版本与发布管理平台的官方 Python SDK。
4
+
5
+ 接口面与 TypeScript / Rust / 纯 JS 版一一对应,只是方法名按 Python 习惯写成 snake_case。
6
+ 完整的方法清单与跨语言对照见[《SDK 参考》](https://ivanhanloth.github.io/Verhub/reference/sdk)。
7
+
8
+ ## 安装
9
+
10
+ ```bash
11
+ pip install verhub-sdk
12
+ ```
13
+
14
+ ## 快速开始
15
+
16
+ ```python
17
+ from verhub_sdk import VerhubClient
18
+
19
+ # 第二个参数是绑定的项目 key,之后项目作用域的方法都不用再传它
20
+ client = VerhubClient("https://verhub.example.com/api/v1", "verhub")
21
+
22
+ result = client.public.check_update(current_version="1.1.0")
23
+ if result["should_update"]:
24
+ print(result["target_version"]["version"], result["target_version"]["content"])
25
+ ```
26
+
27
+ `base_url` 要带上 `/api/v1` 前缀,也就是浏览器里能直接打开 `/health` 的那个地址。
28
+
29
+ ## 两个命名空间
30
+
31
+ - `client.public` — 公开接口,不需要凭据,客户端 App 直接调用
32
+ - `client.admin` — 管理接口,需要管理员 JWT 或 API Key
33
+
34
+ ```python
35
+ client = VerhubClient("https://verhub.example.com/api/v1", "verhub", token="vh_xxx")
36
+
37
+ client.admin.upsert_version(
38
+ "v1.2.0",
39
+ comparable_version="1.2.0",
40
+ title="稳定版",
41
+ content="修复若干问题。",
42
+ is_latest=True,
43
+ )
44
+ ```
45
+
46
+ 凭据与绑定项目都可以事后更换:`client.set_token(token)` / `client.set_project_key(key)`。
47
+ 没绑定项目就调项目作用域的方法会抛 `VerhubError`。
48
+
49
+ ## 省略与置空
50
+
51
+ 可选参数的默认值是 `UNSET` 而不是 `None`,这两者含义不同:
52
+
53
+ - 不传该参数 → 字段不出现在请求里 → 更新接口保持原值
54
+ - 显式传 `None` → 字段以 JSON `null` 提交 → 更新接口把该字段置空
55
+
56
+ ```python
57
+ client.admin.update_version("ver-001", download_url=None) # 清空下载地址
58
+ client.admin.update_version("ver-001", title="改个标题") # 只动标题
59
+ ```
60
+
61
+ ## 平台与系统版本声明
62
+
63
+ SDK 默认按运行环境自动探测平台**与系统版本**(如 Windows `11`、`ubuntu 24.04`、
64
+ macOS `14.5.0`),通过 `x-verhub-platform` / `x-verhub-platform-version` 两个请求头
65
+ 声明,供服务端做来源统计——这不影响任何接口的返回内容。
66
+
67
+ ```python
68
+ # 覆盖探测结果
69
+ client = VerhubClient(base_url, "verhub", platform="linux", platform_version="ubuntu 24.04")
70
+
71
+ # 事后更新
72
+ client.set_platform("linux")
73
+ client.set_platform_version("ubuntu 24.04")
74
+
75
+ # 完全不声明平台(也就不再自动探测版本)
76
+ client = VerhubClient(base_url, "verhub", platform=None)
77
+ ```
78
+
79
+ ## 错误处理
80
+
81
+ ```python
82
+ from verhub_sdk import VerhubApiError, VerhubConnectionError
83
+
84
+ try:
85
+ client.public.get_latest_version()
86
+ except VerhubApiError as exc:
87
+ print(exc.status, exc.message, exc.body) # 服务端返回非 2xx
88
+ except VerhubConnectionError as exc:
89
+ print(exc.cause) # 请求没到服务端
90
+ ```
91
+
92
+ 两者都继承自 `VerhubError`。
93
+
94
+ ## 其他
95
+
96
+ - 返回值是解析后的 `dict`;`verhub_sdk.models` 里有对应的 `TypedDict`,
97
+ 供编辑器补全和 mypy 使用,运行时不做校验也不做拷贝。
98
+ - 需要代理、自定义证书或重试策略时,传入自己的 `requests.Session`:
99
+ `VerhubClient(base_url, session=my_session)`。
100
+ - 客户端可作为上下文管理器使用,退出时关闭连接池。
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "verhub-sdk"
7
+ dynamic = ["version"]
8
+ description = "Verhub 版本与发布管理平台的官方 Python SDK"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "IvanHanloth", email = "ivan@hanloth.com" }]
13
+ keywords = ["verhub", "version", "release", "changelog", "update", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["requests>=2.28,<3"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/IvanHanloth/verhub"
31
+ Documentation = "https://ivanhanloth.github.io/Verhub/reference/sdk"
32
+ Repository = "https://github.com/IvanHanloth/verhub"
33
+ Issues = "https://github.com/IvanHanloth/verhub/issues"
34
+
35
+ [tool.hatch.version]
36
+ path = "verhub_sdk/_version.py"
37
+ pattern = 'VERHUB_SDK_VERSION = "(?P<version>[^"]+)"'
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["verhub_sdk"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["verhub_sdk", "README.md"]
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py39"
48
+
49
+ [tool.ruff.lint]
50
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,51 @@
1
+ """
2
+ Verhub Python SDK。
3
+
4
+ 接口面与 TypeScript / Rust / 纯 JS 版一一对应,只是方法名按各语言习惯改写
5
+ (Python 用 snake_case)。契约以仓库根目录的 ``verhub.openapi.yaml`` 为准。
6
+ """
7
+
8
+ from ._http import (
9
+ PLATFORM_HEADER,
10
+ PLATFORM_VERSION_HEADER,
11
+ VERHUB_SDK_VERSION,
12
+ detect_platform,
13
+ detect_platform_version,
14
+ )
15
+ from ._unset import UNSET, UnsetType
16
+ from .admin_api import AdminApi
17
+ from .client import VerhubClient, VerhubSDK
18
+ from .errors import VerhubApiError, VerhubConnectionError, VerhubError
19
+ from .models import (
20
+ LOG_LEVEL_DEBUG,
21
+ LOG_LEVEL_ERROR,
22
+ LOG_LEVEL_INFO,
23
+ LOG_LEVEL_WARNING,
24
+ PLATFORMS,
25
+ )
26
+ from .public_api import PublicApi
27
+
28
+ __version__ = VERHUB_SDK_VERSION
29
+
30
+ __all__ = [
31
+ "VerhubClient",
32
+ "VerhubSDK",
33
+ "PublicApi",
34
+ "AdminApi",
35
+ "VerhubError",
36
+ "VerhubApiError",
37
+ "VerhubConnectionError",
38
+ "UNSET",
39
+ "UnsetType",
40
+ "PLATFORMS",
41
+ "PLATFORM_HEADER",
42
+ "PLATFORM_VERSION_HEADER",
43
+ "LOG_LEVEL_DEBUG",
44
+ "LOG_LEVEL_INFO",
45
+ "LOG_LEVEL_WARNING",
46
+ "LOG_LEVEL_ERROR",
47
+ "detect_platform",
48
+ "detect_platform_version",
49
+ "VERHUB_SDK_VERSION",
50
+ "__version__",
51
+ ]
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from typing import Any, Dict, Mapping, Optional
6
+ from urllib.parse import quote, urlencode
7
+
8
+ import requests
9
+
10
+ from ._unset import UNSET, UnsetType
11
+ from ._version import VERHUB_SDK_VERSION
12
+ from .errors import VerhubApiError, VerhubConnectionError, VerhubError
13
+
14
+ #: 客户端平台声明头。仅用于服务端请求统计,不影响接口返回内容。
15
+ PLATFORM_HEADER = "x-verhub-platform"
16
+
17
+ #: 客户端系统版本明细头,如 ``11`` / ``ubuntu 24.04``;超过 32 字符会被服务端丢弃。
18
+ PLATFORM_VERSION_HEADER = "x-verhub-platform-version"
19
+
20
+ #: 系统版本明细的长度上限,与服务端一致,超出直接截断。
21
+ MAX_PLATFORM_VERSION_LENGTH = 32
22
+
23
+
24
+ def detect_platform() -> str:
25
+ """
26
+ 猜测当前运行平台,用于填充 :data:`PLATFORM_HEADER`。
27
+
28
+ 只区分契约里的七个取值;认不出时返回 ``others`` 而不是瞎猜,
29
+ 服务端拿到 ``others`` 至少知道这是「说不清的平台」。
30
+
31
+ :return: 平台标识
32
+ """
33
+ name = sys.platform
34
+ if name.startswith("win"):
35
+ return "windows"
36
+ if name == "darwin":
37
+ return "macos"
38
+ if name.startswith("linux"):
39
+ return "linux"
40
+ return "others"
41
+
42
+
43
+ def detect_platform_version() -> str:
44
+ """
45
+ 从系统信息里提取系统版本明细,用于填充 :data:`PLATFORM_VERSION_HEADER`。
46
+
47
+ Windows 按内核构建号还原市场版本号(11 / 10 …),macOS 取产品版本号,
48
+ Linux 读 os-release 拼成 ``发行版 版本号``。取不到就返回空串,交给服务端
49
+ 从 User-Agent 兜底推断。
50
+
51
+ :return: 系统版本明细;空串表示无从得知
52
+ """
53
+ name = sys.platform
54
+ try:
55
+ if name.startswith("win"):
56
+ info = sys.getwindowsversion() # type: ignore[attr-defined]
57
+ # Win11 仍上报内核 10.0,只有构建号 >= 22000 能区分出来。
58
+ if info.major == 10 and info.build >= 22000:
59
+ return "11"
60
+ return str(info.major)
61
+
62
+ if name == "darwin":
63
+ import platform as _platform
64
+
65
+ return _platform.mac_ver()[0] or ""
66
+
67
+ if name.startswith("linux"):
68
+ import platform as _platform
69
+
70
+ read_os_release = getattr(_platform, "freedesktop_os_release", None)
71
+ if read_os_release is not None:
72
+ try:
73
+ data = read_os_release()
74
+ distro = (data.get("ID") or "").strip().lower()
75
+ version = (data.get("VERSION_ID") or "").strip()
76
+ combined = f"{distro} {version}".strip()
77
+ if combined:
78
+ return combined[:MAX_PLATFORM_VERSION_LENGTH]
79
+ except OSError:
80
+ pass
81
+ return ""
82
+ except Exception:
83
+ # 版本探测纯属锦上添花,任何异常都不该阻断请求。
84
+ return ""
85
+
86
+ return ""
87
+
88
+
89
+ def compact(source: Mapping[str, Any]) -> Dict[str, Any]:
90
+ """
91
+ 丢掉值为 :data:`~verhub_sdk._unset.UNSET` 的字段,保留显式的 ``None``。
92
+
93
+ ``None`` 会被序列化成 JSON null,是「把这个字段置空」的意思;只有
94
+ 完全没提供的字段才该从请求里消失。
95
+
96
+ :param source: 原始字段表
97
+ :return: 过滤后的字段表
98
+ """
99
+ return {key: value for key, value in source.items() if not isinstance(value, UnsetType)}
100
+
101
+
102
+ class HttpClient:
103
+ """底层 HTTP 客户端,两个命名空间共用一份连接、凭据与来源声明。"""
104
+
105
+ def __init__(
106
+ self,
107
+ base_url: str,
108
+ project_key: Optional[str] = None,
109
+ token: Optional[str] = None,
110
+ *,
111
+ platform: Any = UNSET,
112
+ platform_version: Any = UNSET,
113
+ timeout: float = 15.0,
114
+ session: Optional[requests.Session] = None,
115
+ user_agent: Optional[str] = None,
116
+ ) -> None:
117
+ """
118
+ :param base_url: API 根地址,须包含 ``/api/v1`` 前缀
119
+ :param project_key: 绑定的项目标识;项目作用域的方法默认用它
120
+ :param token: 管理员 JWT 或 API Key,仅 admin 接口需要
121
+ :param platform: 平台声明;省略则自动探测,传 ``None`` 则不声明
122
+ :param platform_version: 系统版本明细;省略时若平台也是自动探测则一并
123
+ 自动探测,传 ``None`` 则不声明
124
+ :param timeout: 单次请求超时(秒)
125
+ :param session: 自定义 ``requests.Session``,可用于配置代理、重试、证书
126
+ :param user_agent: 覆盖默认 User-Agent
127
+ """
128
+ self.base_url = base_url.strip().rstrip("/")
129
+ self.project_key = project_key
130
+ self.token = token or ""
131
+ self.timeout = timeout
132
+ self.session = session or requests.Session()
133
+ self.user_agent = user_agent or f"verhub-sdk-python/{VERHUB_SDK_VERSION}"
134
+
135
+ auto_platform = isinstance(platform, UnsetType)
136
+ self.platform = detect_platform() if auto_platform else platform
137
+
138
+ if isinstance(platform_version, UnsetType):
139
+ # 平台是自己探测出来的,才顺带把版本也探测了——用户指定了平台却由我们
140
+ # 猜版本,很容易出现「平台 linux、版本却是 windows 11」的错配。
141
+ self.platform_version = (
142
+ (detect_platform_version() or None) if (auto_platform and self.platform) else None
143
+ )
144
+ else:
145
+ self.platform_version = platform_version
146
+
147
+ def set_token(self, token: str) -> None:
148
+ """
149
+ :param token: 管理员 JWT 或 API Key
150
+ """
151
+ self.token = token
152
+
153
+ def clear_token(self) -> None:
154
+ """清除当前凭据,之后调用 admin 接口会直接抛错。"""
155
+ self.token = ""
156
+
157
+ def set_project_key(self, project_key: str) -> None:
158
+ """
159
+ :param project_key: 新的绑定项目标识
160
+ """
161
+ self.project_key = project_key
162
+
163
+ def set_platform(self, platform: Optional[str]) -> None:
164
+ """
165
+ :param platform: 平台声明;传 ``None`` 则不再声明平台
166
+ """
167
+ self.platform = platform
168
+
169
+ def set_platform_version(self, platform_version: Optional[str]) -> None:
170
+ """
171
+ :param platform_version: 系统版本明细;传 ``None`` 则不再声明
172
+ """
173
+ self.platform_version = platform_version
174
+
175
+ def require_project_key(self) -> str:
176
+ """
177
+ :return: 绑定的项目标识
178
+ :raises VerhubError: 未绑定 project_key
179
+ """
180
+ if not self.project_key:
181
+ raise VerhubError("未设置 project_key:请在创建客户端时传入,或调用 set_project_key()")
182
+ return self.project_key
183
+
184
+ def request(
185
+ self,
186
+ method: str,
187
+ path_template: str,
188
+ *,
189
+ path_params: Optional[Mapping[str, str]] = None,
190
+ query: Optional[Mapping[str, Any]] = None,
191
+ body: Optional[Mapping[str, Any]] = None,
192
+ auth: bool = False,
193
+ ) -> Any:
194
+ """
195
+ :param method: HTTP 方法
196
+ :param path_template: 形如 ``/public/{projectKey}`` 的路径模板
197
+ :param path_params: 路径参数,值会被 URL 编码
198
+ :param query: 查询参数,值为 ``None`` 的项会被丢弃
199
+ :param body: JSON 请求体
200
+ :param auth: 是否附带 Bearer 凭据
201
+ :return: 已解析的响应体
202
+ :raises VerhubApiError: 服务端返回非 2xx
203
+ :raises VerhubConnectionError: 请求未能到达服务端
204
+ """
205
+ url = self._build_url(self._resolve_path(path_template, path_params), query)
206
+
207
+ headers: Dict[str, str] = {
208
+ "Accept": "application/json",
209
+ "User-Agent": self.user_agent,
210
+ }
211
+ if self.platform:
212
+ headers[PLATFORM_HEADER] = self.platform
213
+ if self.platform_version:
214
+ headers[PLATFORM_VERSION_HEADER] = self.platform_version
215
+
216
+ if auth:
217
+ if not self.token:
218
+ raise VerhubApiError("缺少凭据:请先设置 token", 401, None)
219
+ headers["Authorization"] = f"Bearer {self.token}"
220
+
221
+ payload = None
222
+ if body is not None:
223
+ headers["Content-Type"] = "application/json"
224
+ payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
225
+
226
+ try:
227
+ response = self.session.request(
228
+ method=method,
229
+ url=url,
230
+ headers=headers,
231
+ data=payload,
232
+ timeout=self.timeout,
233
+ )
234
+ except requests.RequestException as exc:
235
+ raise VerhubConnectionError(f"请求 {method} {url} 失败:{exc}", exc) from exc
236
+
237
+ data = self._parse_json(response.text)
238
+ if not response.ok:
239
+ message = self._error_message(data) or f"请求失败,HTTP {response.status_code}"
240
+ raise VerhubApiError(message, response.status_code, data)
241
+
242
+ return data
243
+
244
+ def _resolve_path(self, template: str, params: Optional[Mapping[str, str]]) -> str:
245
+ """
246
+ :param template: 路径模板
247
+ :param params: 路径参数
248
+ :return: 填充后的路径
249
+ """
250
+ path = template
251
+ while True:
252
+ left = path.find("{")
253
+ if left < 0:
254
+ break
255
+ right = path.find("}", left)
256
+ if right < 0:
257
+ break
258
+
259
+ key = path[left + 1 : right]
260
+ value = (params or {}).get(key)
261
+ if value is None or value == "":
262
+ raise ValueError(f"缺少路径参数:{key}")
263
+
264
+ path = f"{path[:left]}{quote(str(value), safe='')}{path[right + 1 :]}"
265
+
266
+ return path
267
+
268
+ def _build_url(self, path: str, query: Optional[Mapping[str, Any]]) -> str:
269
+ """
270
+ :param path: 已填充的路径
271
+ :param query: 查询参数
272
+ :return: 完整 URL
273
+ """
274
+ if not query:
275
+ return f"{self.base_url}{path}"
276
+
277
+ pairs = []
278
+ for key, value in query.items():
279
+ if value is None or isinstance(value, UnsetType):
280
+ continue
281
+ if isinstance(value, bool):
282
+ pairs.append((key, "true" if value else "false"))
283
+ else:
284
+ pairs.append((key, str(value)))
285
+
286
+ if not pairs:
287
+ return f"{self.base_url}{path}"
288
+
289
+ return f"{self.base_url}{path}?{urlencode(pairs)}"
290
+
291
+ def _parse_json(self, raw: str) -> Any:
292
+ """
293
+ :param raw: 原始响应文本
294
+ :return: 解析结果;不是 JSON 时原样返回文本
295
+ """
296
+ if not raw:
297
+ return {}
298
+
299
+ try:
300
+ return json.loads(raw)
301
+ except json.JSONDecodeError:
302
+ return raw
303
+
304
+ def _error_message(self, body: Any) -> Optional[str]:
305
+ """
306
+ :param body: 已解析的响应体
307
+ :return: 错误信息;NestJS 校验失败时 message 是字符串数组
308
+ """
309
+ if not isinstance(body, dict):
310
+ return None
311
+
312
+ message = body.get("message")
313
+ if isinstance(message, str):
314
+ return message
315
+ if isinstance(message, list) and message:
316
+ return "; ".join(str(item) for item in message)
317
+
318
+ return None
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Final
4
+
5
+
6
+ class UnsetType:
7
+ """
8
+ 「未提供」哨兵。
9
+
10
+ 可选字段的默认值必须与显式 ``None`` 区分开:省略字段表示保持原值,
11
+ 显式传 ``None`` 表示把该字段置空(服务端收到 JSON null)。若直接用
12
+ ``None`` 作默认值,这两种意图就没法表达了。
13
+ """
14
+
15
+ _instance: "UnsetType | None" = None
16
+
17
+ def __new__(cls) -> "UnsetType":
18
+ if cls._instance is None:
19
+ cls._instance = super().__new__(cls)
20
+ return cls._instance
21
+
22
+ def __bool__(self) -> bool:
23
+ return False
24
+
25
+ def __repr__(self) -> str:
26
+ return "UNSET"
27
+
28
+
29
+ UNSET: Final[Any] = UnsetType()
@@ -0,0 +1,3 @@
1
+ # 版本号的唯一真源,由 scripts/version.mjs 在发版时写入;
2
+ # pyproject.toml 通过 hatchling 的 dynamic version 读取这里,不再单独维护一份。
3
+ VERHUB_SDK_VERSION = "0.2.3"