bkai-init 0.1.0rc10__py3-none-any.whl
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.
- bkai_init/__init__.py +19 -0
- bkai_init/__main__.py +5 -0
- bkai_init/api/__init__.py +5 -0
- bkai_init/api/client.py +398 -0
- bkai_init/api/uploads.py +63 -0
- bkai_init/cli.py +293 -0
- bkai_init/enums.py +67 -0
- bkai_init/services/__init__.py +20 -0
- bkai_init/services/initializer.py +155 -0
- bkai_init/services/inspection.py +483 -0
- bkai_init/services/knowledge.py +33 -0
- bkai_init/services/planning.py +177 -0
- bkai_init/services/references.py +149 -0
- bkai_init/services/scaffolding.py +167 -0
- bkai_init/services/sync.py +732 -0
- bkai_init/settings.py +175 -0
- bkai_init/utils/__init__.py +1 -0
- bkai_init/utils/archive.py +84 -0
- bkai_init/utils/codes.py +37 -0
- bkai_init/utils/exceptions.py +57 -0
- bkai_init/utils/knowledge_archive.py +51 -0
- bkai_init/utils/manifest.py +352 -0
- bkai_init/utils/protocol.py +250 -0
- bkai_init/utils/selection.py +37 -0
- bkai_init/utils/variables.py +151 -0
- bkai_init-0.1.0rc10.dist-info/METADATA +351 -0
- bkai_init-0.1.0rc10.dist-info/RECORD +30 -0
- bkai_init-0.1.0rc10.dist-info/WHEEL +5 -0
- bkai_init-0.1.0rc10.dist-info/entry_points.txt +2 -0
- bkai_init-0.1.0rc10.dist-info/top_level.txt +1 -0
bkai_init/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""AIDEV Agent Package initialization CLI."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .services import BkaiInit
|
|
6
|
+
from .utils.exceptions import APIError, BkaiCliError, ConfigurationError, ManifestError
|
|
7
|
+
|
|
8
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"APIError",
|
|
14
|
+
"BkaiCliError",
|
|
15
|
+
"BkaiInit",
|
|
16
|
+
"ConfigurationError",
|
|
17
|
+
"ManifestError",
|
|
18
|
+
"__version__",
|
|
19
|
+
]
|
bkai_init/__main__.py
ADDED
bkai_init/api/client.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""HTTP client for the BKAIDEV application OpenAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from .uploads import upload_archive
|
|
14
|
+
from .. import settings
|
|
15
|
+
from ..utils.exceptions import APIError, ConfigurationError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AidevClient:
|
|
19
|
+
"""Small API client with application authentication and response unwrapping."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
base_url: str,
|
|
24
|
+
app_code: str,
|
|
25
|
+
app_secret: str,
|
|
26
|
+
*,
|
|
27
|
+
tenant_id: str = settings.DEFAULT_TENANT_ID,
|
|
28
|
+
access_token: str | None = None,
|
|
29
|
+
username: str | None = None,
|
|
30
|
+
timeout: float = settings.DEFAULT_TIMEOUT,
|
|
31
|
+
session: requests.Session | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
if not base_url:
|
|
34
|
+
raise ValueError("base_url 不能为空")
|
|
35
|
+
self.api_root = _api_root(base_url)
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
self.tenant_id = tenant_id
|
|
38
|
+
self._secrets = tuple(value for value in (app_secret, access_token) if value)
|
|
39
|
+
self.session = session or requests.Session()
|
|
40
|
+
authorization = {"bk_app_code": app_code, "bk_app_secret": app_secret}
|
|
41
|
+
if access_token:
|
|
42
|
+
authorization["access_token"] = access_token
|
|
43
|
+
headers = {
|
|
44
|
+
"Accept": "application/json",
|
|
45
|
+
"X-Bk-Tenant-Id": tenant_id,
|
|
46
|
+
"X-Bkapi-Authorization": json.dumps(authorization),
|
|
47
|
+
}
|
|
48
|
+
if username:
|
|
49
|
+
headers["X-BKAIDEV-USER"] = username
|
|
50
|
+
self.session.headers.update({name: _latin1_header(name, value) for name, value in headers.items()})
|
|
51
|
+
|
|
52
|
+
def upload_skill(self, archive: Path, space_id: str) -> dict[str, Any]:
|
|
53
|
+
with archive.open("rb") as file_obj:
|
|
54
|
+
return self._request(
|
|
55
|
+
"POST",
|
|
56
|
+
"/upload/",
|
|
57
|
+
data={"module": "skill", "space_id": space_id},
|
|
58
|
+
files={"file": (archive.name, file_obj, "application/zip")},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def upsert_skill(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
62
|
+
return self._request("POST", "/skills/upsert/", json=payload)
|
|
63
|
+
|
|
64
|
+
def upload_knowledge(self, archive: Path, space_id: str) -> dict[str, Any]:
|
|
65
|
+
with archive.open("rb") as content:
|
|
66
|
+
sha256 = hashlib.file_digest(content, "sha256").hexdigest()
|
|
67
|
+
size = archive.stat().st_size
|
|
68
|
+
grant = self._request(
|
|
69
|
+
"POST",
|
|
70
|
+
"/upload/url/",
|
|
71
|
+
json={
|
|
72
|
+
"space_id": space_id,
|
|
73
|
+
"module": "knowledge",
|
|
74
|
+
"file_name": archive.name,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
if (
|
|
78
|
+
not isinstance(grant, dict)
|
|
79
|
+
or not isinstance(grant.get("url"), str)
|
|
80
|
+
or not grant["url"].startswith("bkrepo://")
|
|
81
|
+
):
|
|
82
|
+
raise self._http_error(
|
|
83
|
+
"平台返回的知识库直传授权不完整",
|
|
84
|
+
"POST",
|
|
85
|
+
"/upload/url/",
|
|
86
|
+
{"json": {"space_id": space_id, "module": "knowledge", "file_name": archive.name}},
|
|
87
|
+
grant,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
upload_archive(archive, grant, self.timeout)
|
|
91
|
+
except APIError:
|
|
92
|
+
# A timeout may mean the upload succeeded. Never blindly repeat a PUT.
|
|
93
|
+
self.knowledge_upload_status(grant["url"], space_id, size, sha256)
|
|
94
|
+
else:
|
|
95
|
+
self.knowledge_upload_status(grant["url"], space_id, size, sha256)
|
|
96
|
+
return {"url": grant["url"], "file_name": archive.stem, "file_type": "zip", "file_size": size}
|
|
97
|
+
|
|
98
|
+
def knowledge_upload_status(self, url: str, space_id: str, size: int, sha256: str) -> dict[str, Any]:
|
|
99
|
+
payload = {"space_id": space_id, "url": url}
|
|
100
|
+
data = self._request("POST", "/upload/status/", json=payload)
|
|
101
|
+
if (
|
|
102
|
+
not isinstance(data, dict)
|
|
103
|
+
or data.get("uploaded") is not True
|
|
104
|
+
or data.get("file_size") != size
|
|
105
|
+
or data.get("sha256") != sha256
|
|
106
|
+
):
|
|
107
|
+
raise self._http_error(
|
|
108
|
+
"知识 ZIP 上传状态校验失败", "POST", "/upload/status/", {"json": payload}, data
|
|
109
|
+
)
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
def import_knowledge_archive(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
113
|
+
return self._request("POST", "/knowledges/archive/import/", json=payload)
|
|
114
|
+
|
|
115
|
+
def knowledge_status(self, space_id: str, anchor_path: str) -> dict[str, Any]:
|
|
116
|
+
return self._request(
|
|
117
|
+
"GET", "/knowledges/status_info/", params={"space_id": space_id, "anchor_path": anchor_path}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def list_skills(self, space_id: str, code: str) -> list[dict[str, Any]]:
|
|
121
|
+
return self._listed(
|
|
122
|
+
"GET",
|
|
123
|
+
"/skills/",
|
|
124
|
+
params={"space_id": space_id, "fuzzy": code, "page": 1, "page_size": 200},
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def get_skill(self, skill_id: int, space_id: str, version: str | None = None) -> dict[str, Any]:
|
|
128
|
+
params: dict[str, Any] = {"space_id": space_id}
|
|
129
|
+
if version:
|
|
130
|
+
params["version"] = version
|
|
131
|
+
return self._request("GET", f"/skills/{skill_id}/", params=params)
|
|
132
|
+
|
|
133
|
+
def list_agents(self, space_id: str, code: str) -> list[dict[str, Any]]:
|
|
134
|
+
return self._listed(
|
|
135
|
+
"GET",
|
|
136
|
+
"/agents/",
|
|
137
|
+
params={"space_id": space_id, "agent_code": code, "page": 1, "page_size": 200},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def get_agent(self, agent_id: int, space_id: str, version: str | None = None) -> dict[str, Any]:
|
|
141
|
+
params: dict[str, Any] = {"space_id": space_id}
|
|
142
|
+
if version:
|
|
143
|
+
params["version"] = version
|
|
144
|
+
return self._request("GET", f"/agents/{agent_id}/", params=params)
|
|
145
|
+
|
|
146
|
+
def create_agent(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
147
|
+
return self._request("POST", "/agents/", json=payload)
|
|
148
|
+
|
|
149
|
+
def update_agent(self, agent_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
|
150
|
+
return self._request("PUT", f"/agents/{agent_id}/update/", json=payload)
|
|
151
|
+
|
|
152
|
+
def publish_agent(self, agent_id: int, space_id: str, *, publish_config_only: bool = True) -> dict[str, Any]:
|
|
153
|
+
return self._request(
|
|
154
|
+
"POST",
|
|
155
|
+
f"/agents/{agent_id}/publish/",
|
|
156
|
+
json={
|
|
157
|
+
"space_id": space_id,
|
|
158
|
+
"publish_config_only": publish_config_only,
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def list_mcps(self, space_id: str, code: str, mcp_type: str) -> list[dict[str, Any]]:
|
|
163
|
+
return self._listed(
|
|
164
|
+
"GET",
|
|
165
|
+
"/mcps/",
|
|
166
|
+
params={
|
|
167
|
+
"space_id": space_id,
|
|
168
|
+
"mcp_code": code,
|
|
169
|
+
"mcp_type": mcp_type,
|
|
170
|
+
"page": 1,
|
|
171
|
+
"page_size": 200,
|
|
172
|
+
},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def get_mcp_by_code(
|
|
176
|
+
self, space_id: str | None, code: str, *, mcp_type: str = "apigw", agent_code: str | None = None
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
if not code or any(
|
|
179
|
+
char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for char in code
|
|
180
|
+
):
|
|
181
|
+
raise APIError("MCP code 只能包含字母、数字、下划线和减号")
|
|
182
|
+
if mcp_type not in {"apigw", "resource"}:
|
|
183
|
+
raise APIError("mcp_type 必须为 apigw 或 resource")
|
|
184
|
+
params = {"mcp_type": mcp_type}
|
|
185
|
+
if space_id is not None:
|
|
186
|
+
params["space_id"] = space_id
|
|
187
|
+
if agent_code is not None:
|
|
188
|
+
params["agent_code"] = agent_code
|
|
189
|
+
return self._request("GET", f"/mcps/by-code/{code}/", params=params)
|
|
190
|
+
|
|
191
|
+
def list_collections(self, space_id: str, code: str) -> list[dict[str, Any]]:
|
|
192
|
+
return self._listed(
|
|
193
|
+
"GET",
|
|
194
|
+
"/collections/",
|
|
195
|
+
params={
|
|
196
|
+
"space_id": space_id,
|
|
197
|
+
"collection_code": code,
|
|
198
|
+
"page": 1,
|
|
199
|
+
"page_size": 200,
|
|
200
|
+
},
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def get_collection(self, collection_id: int, space_id: str) -> dict[str, Any]:
|
|
204
|
+
return self._request("GET", f"/collections/{collection_id}/", params={"space_id": space_id})
|
|
205
|
+
|
|
206
|
+
def upsert_collection(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
207
|
+
return self._request("POST", "/collections/upsert/", json=payload)
|
|
208
|
+
|
|
209
|
+
def list_knowledgebases(self, space_id: str, code: str) -> list[dict[str, Any]]:
|
|
210
|
+
return self._listed(
|
|
211
|
+
"POST",
|
|
212
|
+
"/knowledgebase/list/",
|
|
213
|
+
json={
|
|
214
|
+
"space_id": space_id,
|
|
215
|
+
"knowledgebase_code": code,
|
|
216
|
+
"generate_type": "all",
|
|
217
|
+
"page": 1,
|
|
218
|
+
"page_size": 200,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def create_knowledgebase(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
223
|
+
return self._request("POST", "/knowledgebase/", json=payload)
|
|
224
|
+
|
|
225
|
+
def update_knowledgebase(self, knowledgebase_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
|
226
|
+
return self._request("PATCH", f"/knowledgebase/{knowledgebase_id}/update/", json=payload)
|
|
227
|
+
|
|
228
|
+
def _listed(self, method: str, path: str, **kwargs: Any) -> list[dict[str, Any]]:
|
|
229
|
+
data = self._request(method, path, **kwargs)
|
|
230
|
+
try:
|
|
231
|
+
return _results(data)
|
|
232
|
+
except APIError as exc:
|
|
233
|
+
raise self._http_error(exc.args[0], method, path, kwargs, data) from None
|
|
234
|
+
|
|
235
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
236
|
+
if not path.startswith("/") or any(token in path for token in ("..", ":", "?", "#", "%", "//")):
|
|
237
|
+
raise APIError("仅允许调用 app 下的相对资源路径")
|
|
238
|
+
url = f"{self.api_root}{path}"
|
|
239
|
+
try:
|
|
240
|
+
response = self.session.request(method, url, timeout=self.timeout, allow_redirects=False, **kwargs)
|
|
241
|
+
except requests.RequestException as exc:
|
|
242
|
+
raise self._http_error(
|
|
243
|
+
f"请求 AIDEV app OpenAPI 失败:{type(exc).__name__}",
|
|
244
|
+
method,
|
|
245
|
+
path,
|
|
246
|
+
kwargs,
|
|
247
|
+
str(exc),
|
|
248
|
+
) from None
|
|
249
|
+
|
|
250
|
+
if 300 <= response.status_code < 400:
|
|
251
|
+
raise self._http_error(
|
|
252
|
+
"app OpenAPI 返回重定向,已拒绝携带凭据跳转",
|
|
253
|
+
method,
|
|
254
|
+
path,
|
|
255
|
+
kwargs,
|
|
256
|
+
{"status": response.status_code},
|
|
257
|
+
status=response.status_code,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
payload = response.json()
|
|
262
|
+
except ValueError:
|
|
263
|
+
payload = None
|
|
264
|
+
if not response.ok:
|
|
265
|
+
detail = self._redact(_error_detail(payload) or response.text[:500])
|
|
266
|
+
raise self._http_error(
|
|
267
|
+
f"AIDEV OpenAPI 返回 {response.status_code}:{detail}",
|
|
268
|
+
method,
|
|
269
|
+
path,
|
|
270
|
+
kwargs,
|
|
271
|
+
payload if payload is not None else response.text[:500],
|
|
272
|
+
status=response.status_code,
|
|
273
|
+
)
|
|
274
|
+
if isinstance(payload, dict) and payload.get("result") is False:
|
|
275
|
+
raise self._http_error(
|
|
276
|
+
f"AIDEV OpenAPI 业务失败:{self._redact(_error_detail(payload))}",
|
|
277
|
+
method,
|
|
278
|
+
path,
|
|
279
|
+
kwargs,
|
|
280
|
+
payload,
|
|
281
|
+
)
|
|
282
|
+
if isinstance(payload, dict) and "data" in payload:
|
|
283
|
+
return payload["data"]
|
|
284
|
+
return payload
|
|
285
|
+
|
|
286
|
+
def _http_error(
|
|
287
|
+
self,
|
|
288
|
+
message: str,
|
|
289
|
+
method: str,
|
|
290
|
+
path: str,
|
|
291
|
+
kwargs: dict[str, Any],
|
|
292
|
+
output: Any,
|
|
293
|
+
*,
|
|
294
|
+
status: int | None = None,
|
|
295
|
+
) -> APIError:
|
|
296
|
+
return APIError(
|
|
297
|
+
message,
|
|
298
|
+
url=f"{self.api_root}{path}",
|
|
299
|
+
method=method,
|
|
300
|
+
params=self._redact_value(_request_params(kwargs)),
|
|
301
|
+
output=self._redact_value(output),
|
|
302
|
+
hint=_error_hint(path, status, message),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def _redact_value(self, value: Any) -> Any:
|
|
306
|
+
if isinstance(value, str):
|
|
307
|
+
return self._redact(value)
|
|
308
|
+
if isinstance(value, dict):
|
|
309
|
+
return {key: self._redact_value(item) for key, item in value.items()}
|
|
310
|
+
if isinstance(value, list):
|
|
311
|
+
return [self._redact_value(item) for item in value]
|
|
312
|
+
return value
|
|
313
|
+
|
|
314
|
+
def _redact(self, message: str) -> str:
|
|
315
|
+
for secret in self._secrets:
|
|
316
|
+
message = message.replace(secret, "[REDACTED]")
|
|
317
|
+
return message
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _latin1_header(name: str, value: str) -> str:
|
|
321
|
+
try:
|
|
322
|
+
value.encode("latin-1")
|
|
323
|
+
except UnicodeEncodeError:
|
|
324
|
+
raise ConfigurationError(f"请求头 {name} 包含非 Latin-1 字符") from None
|
|
325
|
+
return value
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _api_root(base_url: str) -> str:
|
|
329
|
+
normalized = base_url.rstrip("/")
|
|
330
|
+
parsed = urlsplit(normalized)
|
|
331
|
+
if (
|
|
332
|
+
parsed.scheme not in {"http", "https"}
|
|
333
|
+
or not parsed.netloc
|
|
334
|
+
or parsed.username
|
|
335
|
+
or parsed.query
|
|
336
|
+
or parsed.fragment
|
|
337
|
+
):
|
|
338
|
+
raise ValueError("base_url 必须是无凭据、查询参数和片段的 HTTP(S) 地址")
|
|
339
|
+
if "/private" in parsed.path or ".." in parsed.path or "%" in parsed.path:
|
|
340
|
+
raise ValueError("base_url 不允许 private 接口或路径跳转")
|
|
341
|
+
if normalized.endswith(settings.DEFAULT_API_PREFIX):
|
|
342
|
+
return normalized
|
|
343
|
+
return f"{normalized}{settings.DEFAULT_API_PREFIX}"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _results(data: Any) -> list[dict[str, Any]]:
|
|
347
|
+
if isinstance(data, dict) and isinstance(data.get("results"), list):
|
|
348
|
+
items = data["results"]
|
|
349
|
+
count = data.get("count", len(items))
|
|
350
|
+
if type(count) is not int or count < 0:
|
|
351
|
+
raise APIError("列表接口 count 无效")
|
|
352
|
+
if count > len(items):
|
|
353
|
+
raise APIError("列表超过单页上限,无法保证 code 唯一;已停止操作")
|
|
354
|
+
if any(not isinstance(item, dict) for item in items):
|
|
355
|
+
raise APIError("列表响应包含无效资源对象")
|
|
356
|
+
return items
|
|
357
|
+
if isinstance(data, list):
|
|
358
|
+
if any(not isinstance(item, dict) for item in data):
|
|
359
|
+
raise APIError("列表响应包含无效资源对象")
|
|
360
|
+
return data
|
|
361
|
+
raise APIError("列表接口响应结构无效,不能按资源不存在处理")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _error_detail(payload: Any) -> str:
|
|
365
|
+
if not isinstance(payload, dict):
|
|
366
|
+
return ""
|
|
367
|
+
error = payload.get("error")
|
|
368
|
+
if isinstance(error, dict) and error.get("message"):
|
|
369
|
+
return str(error["message"])
|
|
370
|
+
return str(payload.get("message") or error or payload)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _request_params(kwargs: dict[str, Any]) -> Any:
|
|
374
|
+
snapshot = {key: kwargs[key] for key in ("params", "json", "data") if kwargs.get(key) is not None}
|
|
375
|
+
if len(snapshot) == 1:
|
|
376
|
+
return next(iter(snapshot.values()))
|
|
377
|
+
return snapshot or None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _error_hint(path: str, status: int | None, detail: str) -> str | None:
|
|
381
|
+
lowered = detail.lower()
|
|
382
|
+
if "/mcps/by-code/" in path:
|
|
383
|
+
code = path.rstrip("/").rsplit("/", 1)[-1]
|
|
384
|
+
if status == 404 or "not found" in lowered:
|
|
385
|
+
return (
|
|
386
|
+
f"bkai-init 不会创建 MCP。请先在平台创建并发布 APIGW MCP {code},"
|
|
387
|
+
"完成当前应用授权,并确认 Agent YAML 中的 code 与线上 mcp_code 一致后再重试。"
|
|
388
|
+
)
|
|
389
|
+
if status in {401, 403}:
|
|
390
|
+
return (
|
|
391
|
+
"查询 MCP 需要应用已获目标空间授权。"
|
|
392
|
+
"非公开 MCP 还需智能体已存在并完成网关授权;plan 不会为此提前创建智能体。"
|
|
393
|
+
)
|
|
394
|
+
if status in {401, 403}:
|
|
395
|
+
return "请检查应用凭据,以及应用是否已获目标空间和相关资源授权。"
|
|
396
|
+
if status is not None and 300 <= status < 400:
|
|
397
|
+
return "请求被重定向,已拒绝跟随以免泄露凭据。请使用可直接访问的 app OpenAPI 地址。"
|
|
398
|
+
return None
|
bkai_init/api/uploads.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Raw BKRepo transport. Never reuse the application-authenticated session."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from ..utils.exceptions import APIError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def upload_archive(archive: Path, grant: dict, timeout: float) -> None:
|
|
12
|
+
try:
|
|
13
|
+
url = grant["upload_url"]
|
|
14
|
+
parsed = urlsplit(url)
|
|
15
|
+
headers = grant["headers"]
|
|
16
|
+
if (
|
|
17
|
+
grant.get("method") != "PUT"
|
|
18
|
+
or parsed.scheme not in {"http", "https"}
|
|
19
|
+
or not parsed.hostname
|
|
20
|
+
or parsed.username
|
|
21
|
+
or parsed.password
|
|
22
|
+
or parsed.fragment
|
|
23
|
+
or any(ord(c) < 32 for c in url)
|
|
24
|
+
or not isinstance(headers, dict)
|
|
25
|
+
or set(headers) - {"Content-Type", "X-BKREPO-OVERWRITE", "X-Checksum-Sha256"}
|
|
26
|
+
):
|
|
27
|
+
raise ValueError
|
|
28
|
+
except (KeyError, TypeError, ValueError):
|
|
29
|
+
raise APIError("平台返回的直传授权不合法") from None
|
|
30
|
+
public_url = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
|
|
31
|
+
# trust_env=False prevents implicit .netrc authentication and proxy credentials.
|
|
32
|
+
with requests.Session() as session:
|
|
33
|
+
session.trust_env = False
|
|
34
|
+
try:
|
|
35
|
+
with archive.open("rb") as content:
|
|
36
|
+
response = session.put(url, data=content, headers=headers, timeout=timeout, allow_redirects=False)
|
|
37
|
+
if not 200 <= response.status_code < 300:
|
|
38
|
+
raise APIError(
|
|
39
|
+
f"BKRepo 直传失败(HTTP {response.status_code})",
|
|
40
|
+
url=public_url,
|
|
41
|
+
method="PUT",
|
|
42
|
+
output={"status": response.status_code},
|
|
43
|
+
hint="直传失败。请查询上传状态确认是否已成功,不要直接重试 PUT。",
|
|
44
|
+
)
|
|
45
|
+
if response.content:
|
|
46
|
+
payload = response.json()
|
|
47
|
+
if not isinstance(payload, dict) or payload.get("code") != 0:
|
|
48
|
+
raise APIError(
|
|
49
|
+
"BKRepo 直传业务失败",
|
|
50
|
+
url=public_url,
|
|
51
|
+
method="PUT",
|
|
52
|
+
output=payload,
|
|
53
|
+
hint="直传业务失败。请查询上传状态确认是否已成功,不要直接重试 PUT。",
|
|
54
|
+
)
|
|
55
|
+
except APIError:
|
|
56
|
+
raise
|
|
57
|
+
except (requests.RequestException, ValueError):
|
|
58
|
+
raise APIError(
|
|
59
|
+
"BKRepo 直传失败或结果未知,请查询上传状态",
|
|
60
|
+
url=public_url,
|
|
61
|
+
method="PUT",
|
|
62
|
+
hint="请查询上传状态确认是否已成功,不要直接重试 PUT。",
|
|
63
|
+
) from None
|