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.
- verhub_sdk-0.2.3/.gitignore +5 -0
- verhub_sdk-0.2.3/PKG-INFO +126 -0
- verhub_sdk-0.2.3/README.md +100 -0
- verhub_sdk-0.2.3/pyproject.toml +50 -0
- verhub_sdk-0.2.3/verhub_sdk/__init__.py +51 -0
- verhub_sdk-0.2.3/verhub_sdk/_http.py +318 -0
- verhub_sdk-0.2.3/verhub_sdk/_unset.py +29 -0
- verhub_sdk-0.2.3/verhub_sdk/_version.py +3 -0
- verhub_sdk-0.2.3/verhub_sdk/admin_api.py +1016 -0
- verhub_sdk-0.2.3/verhub_sdk/client.py +118 -0
- verhub_sdk-0.2.3/verhub_sdk/errors.py +38 -0
- verhub_sdk-0.2.3/verhub_sdk/models.py +286 -0
- verhub_sdk-0.2.3/verhub_sdk/public_api.py +235 -0
- verhub_sdk-0.2.3/verhub_sdk/py.typed +0 -0
|
@@ -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()
|