tinet-agent-cli 0.1.0.dev36__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.
- taco/__init__.py +1 -0
- taco/agent_openapi/__init__.py +1 -0
- taco/agent_openapi/catalog.py +198 -0
- taco/agent_openapi/models.py +47 -0
- taco/agent_openapi/parameters.py +111 -0
- taco/agent_openapi/service.py +73 -0
- taco/aikb/__init__.py +4 -0
- taco/aikb/catalog.py +380 -0
- taco/aikb/executors.py +157 -0
- taco/aikb/file_input.py +55 -0
- taco/aikb/models.py +42 -0
- taco/aikb/parameters.py +135 -0
- taco/aikb/service.py +68 -0
- taco/assets/__init__.py +1 -0
- taco/assets/agent_openapi/catalog.json +14 -0
- taco/assets/agent_openapi/operations/conversation/list-conversations.json +67 -0
- taco/assets/agent_openapi/operations/message/list-conversation-messages.json +80 -0
- taco/assets/agent_openapi/operations/trace/list-message-traces.json +72 -0
- taco/assets/aikb/catalog.json +41 -0
- taco/assets/aikb/operations/conversation/chat-conversation-on-open.json +99 -0
- taco/assets/aikb/operations/directory/delete-directory.json +81 -0
- taco/assets/aikb/operations/directory/edit-directory.json +97 -0
- taco/assets/aikb/operations/directory/list-directory-tree.json +106 -0
- taco/assets/aikb/operations/directory/save-directory.json +109 -0
- taco/assets/aikb/operations/faq/create-faq.json +193 -0
- taco/assets/aikb/operations/faq/delete-faq.json +81 -0
- taco/assets/aikb/operations/faq/describe-faq.json +82 -0
- taco/assets/aikb/operations/faq/edit-faq.json +193 -0
- taco/assets/aikb/operations/faq/list-faqs.json +136 -0
- taco/assets/aikb/operations/file/create-file.json +145 -0
- taco/assets/aikb/operations/file/delete-files.json +110 -0
- taco/assets/aikb/operations/file/describe-file.json +82 -0
- taco/assets/aikb/operations/file/get-file-upload-url.json +154 -0
- taco/assets/aikb/operations/file/list-files.json +146 -0
- taco/assets/aikb/operations/media/describe-faq-media-url.json +98 -0
- taco/assets/aikb/operations/media/describe-file-media-url.json +90 -0
- taco/assets/aikb/operations/oss/signature-for-upload.json +85 -0
- taco/assets/aikb/operations/recycle-bin/list-recycled-items.json +151 -0
- taco/assets/aikb/operations/repository/describe-bot-repository.json +71 -0
- taco/assets/aikb/operations/repository/list-private-repositories.json +75 -0
- taco/assets/aikb/operations/repository/list-public-repositories.json +75 -0
- taco/assets/aikb/operations/search/search-knowledge-on-open.json +180 -0
- taco/assets/examples/README.md +3 -0
- taco/assets/examples/agent-basic.json +16 -0
- taco/assets/examples/agent-builtin-tool.json +28 -0
- taco/assets/examples/agent-workflow-tool.json +30 -0
- taco/assets/examples/chatflow-basic.json +24 -0
- taco/assets/examples/workflow-http.json +34 -0
- taco/assets/manifest.json +12 -0
- taco/assets/scenarios/README.md +3 -0
- taco/assets/scenarios/agent-basic.json +80 -0
- taco/assets/scenarios/agent-builtin-tool.json +116 -0
- taco/assets/scenarios/agent-workflow-tool.json +277 -0
- taco/assets/schemas/README.md +3 -0
- taco/assets/schemas/agent-tool.json +71 -0
- taco/assets/schemas/agent.json +199 -0
- taco/assets/schemas/chatflow.json +1048 -0
- taco/assets/schemas/workflow.http.json +1052 -0
- taco/assets/schemas/workflow.json +1052 -0
- taco/assets/skills/README.md +3 -0
- taco/assets/skills/taco/SKILL.md +68 -0
- taco/assets/skills/taco/manifest.json +10 -0
- taco/cli.py +127 -0
- taco/commands/__init__.py +1 -0
- taco/commands/agent.py +760 -0
- taco/commands/aikb.py +132 -0
- taco/commands/app.py +151 -0
- taco/commands/category.py +37 -0
- taco/commands/chatflow.py +183 -0
- taco/commands/credential.py +222 -0
- taco/commands/discovery.py +409 -0
- taco/commands/model.py +55 -0
- taco/commands/profile.py +124 -0
- taco/commands/tool.py +61 -0
- taco/commands/workflow.py +714 -0
- taco/core/__init__.py +1 -0
- taco/core/access_token_manager.py +98 -0
- taco/core/api_client.py +263 -0
- taco/core/assets.py +81 -0
- taco/core/config_store.py +209 -0
- taco/core/context.py +19 -0
- taco/core/developer_center_client.py +165 -0
- taco/core/errors.py +19 -0
- taco/core/file_lock.py +80 -0
- taco/core/http_headers.py +21 -0
- taco/core/openapi_signer.py +221 -0
- taco/core/output.py +72 -0
- taco/core/profile_manager.py +217 -0
- taco/core/profile_resolver.py +151 -0
- taco/core/secure_file.py +76 -0
- taco/release.py +363 -0
- taco/services/__init__.py +1 -0
- taco/services/agent_service.py +314 -0
- taco/services/app_service.py +153 -0
- taco/services/category_service.py +57 -0
- taco/services/model_service.py +117 -0
- taco/services/tool_service.py +482 -0
- taco/services/workflow_compiler.py +1665 -0
- taco/services/workflow_service.py +652 -0
- taco/services/workflow_tool_service.py +439 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/METADATA +158 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/RECORD +104 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/WHEEL +4 -0
- tinet_agent_cli-0.1.0.dev36.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from taco.core.errors import TacoError
|
|
10
|
+
from taco.core.http_headers import with_cli_user_agent
|
|
11
|
+
from taco.core.openapi_signer import OpenApiSigner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
15
|
+
_TIME_ERROR_CODES = frozenset(
|
|
16
|
+
{"SignaturesExpired", "SignatureExpired", "RequestTimeTooSkewed"}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DeveloperCenterClient:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
endpoint: str,
|
|
25
|
+
access_key_id: str,
|
|
26
|
+
access_key_secret: str,
|
|
27
|
+
http_client: httpx.Client | None = None,
|
|
28
|
+
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
|
|
29
|
+
now_provider: Callable[[], datetime] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.signer = OpenApiSigner(
|
|
32
|
+
endpoint=endpoint,
|
|
33
|
+
access_key_id=access_key_id,
|
|
34
|
+
access_key_secret=access_key_secret,
|
|
35
|
+
)
|
|
36
|
+
self.endpoint = self.signer.endpoint
|
|
37
|
+
self._owns_http_client = http_client is None
|
|
38
|
+
self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
|
|
39
|
+
self._now_provider = now_provider or (lambda: datetime.now(UTC))
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
if self._owns_http_client:
|
|
43
|
+
self.http_client.close()
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> DeveloperCenterClient:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, *args: object) -> None:
|
|
49
|
+
self.close()
|
|
50
|
+
|
|
51
|
+
def cli_auth(self) -> dict[str, Any]:
|
|
52
|
+
path = "/agent/v1/cli-auth"
|
|
53
|
+
payload = self.request_json("POST", path)
|
|
54
|
+
if not isinstance(payload, dict) or not isinstance(
|
|
55
|
+
payload.get("result"), dict
|
|
56
|
+
):
|
|
57
|
+
raise _invalid_response_error(path, _request_id(payload))
|
|
58
|
+
result = payload["result"]
|
|
59
|
+
access_token = result.get("access_token")
|
|
60
|
+
if not isinstance(access_token, str) or not access_token.strip():
|
|
61
|
+
raise _invalid_response_error(path, _request_id(payload))
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def request_json(
|
|
65
|
+
self,
|
|
66
|
+
method: str,
|
|
67
|
+
path: str,
|
|
68
|
+
*,
|
|
69
|
+
query: Mapping[str, Any] | None = None,
|
|
70
|
+
json_body: Any = None,
|
|
71
|
+
) -> Any:
|
|
72
|
+
signed = self.signer.sign(
|
|
73
|
+
method,
|
|
74
|
+
path,
|
|
75
|
+
query,
|
|
76
|
+
now=self._now_provider(),
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
response = self.http_client.request(
|
|
80
|
+
method,
|
|
81
|
+
f"{self.endpoint}{path}",
|
|
82
|
+
params=signed.params,
|
|
83
|
+
json=json_body,
|
|
84
|
+
headers=with_cli_user_agent(),
|
|
85
|
+
)
|
|
86
|
+
except httpx.TimeoutException:
|
|
87
|
+
raise TacoError(
|
|
88
|
+
code="OPENAPI_TIMEOUT",
|
|
89
|
+
message="开发者中心请求超时。",
|
|
90
|
+
exit_code=4,
|
|
91
|
+
path=path,
|
|
92
|
+
hint="请检查网络连接后重试。",
|
|
93
|
+
) from None
|
|
94
|
+
except httpx.HTTPError:
|
|
95
|
+
raise TacoError(
|
|
96
|
+
code="OPENAPI_CONNECTION_FAILED",
|
|
97
|
+
message="无法连接开发者中心。",
|
|
98
|
+
exit_code=4,
|
|
99
|
+
path=path,
|
|
100
|
+
hint="请检查网络、代理、证书和 endpoint。",
|
|
101
|
+
) from None
|
|
102
|
+
|
|
103
|
+
if response.status_code >= 400:
|
|
104
|
+
raise _request_error(response, path)
|
|
105
|
+
try:
|
|
106
|
+
return response.json()
|
|
107
|
+
except ValueError:
|
|
108
|
+
raise _invalid_response_error(path) from None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _request_error(response: httpx.Response, path: str) -> TacoError:
|
|
112
|
+
try:
|
|
113
|
+
payload = response.json()
|
|
114
|
+
except ValueError:
|
|
115
|
+
payload = None
|
|
116
|
+
remote_code, request_id = _safe_error_fields(payload)
|
|
117
|
+
hint = "请检查 AccessKey 权限、IP 白名单和请求参数。"
|
|
118
|
+
if remote_code in _TIME_ERROR_CODES:
|
|
119
|
+
hint = "请检查本机 UTC 时间并启用 NTP 时间同步后重试。"
|
|
120
|
+
return TacoError(
|
|
121
|
+
code="OPENAPI_REQUEST_FAILED",
|
|
122
|
+
message=f"开发者中心请求失败,HTTP 状态码 {response.status_code}。",
|
|
123
|
+
exit_code=4,
|
|
124
|
+
status=response.status_code,
|
|
125
|
+
hint=hint,
|
|
126
|
+
remote_code=remote_code,
|
|
127
|
+
request_id=request_id,
|
|
128
|
+
path=path,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _safe_error_fields(payload: Any) -> tuple[str | None, str | None]:
|
|
133
|
+
if not isinstance(payload, dict):
|
|
134
|
+
return None, None
|
|
135
|
+
request_id = _request_id(payload)
|
|
136
|
+
detail = payload.get("error")
|
|
137
|
+
if not isinstance(detail, dict):
|
|
138
|
+
detail = payload
|
|
139
|
+
remote_code = detail.get("code")
|
|
140
|
+
if not isinstance(remote_code, str) or not remote_code.strip():
|
|
141
|
+
remote_code = None
|
|
142
|
+
return remote_code, request_id
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _request_id(payload: Any) -> str | None:
|
|
146
|
+
if not isinstance(payload, dict):
|
|
147
|
+
return None
|
|
148
|
+
request_id = payload.get("requestId")
|
|
149
|
+
if isinstance(request_id, str) and request_id.strip():
|
|
150
|
+
return request_id
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _invalid_response_error(
|
|
155
|
+
path: str,
|
|
156
|
+
request_id: str | None = None,
|
|
157
|
+
) -> TacoError:
|
|
158
|
+
return TacoError(
|
|
159
|
+
code="OPENAPI_RESPONSE_INVALID",
|
|
160
|
+
message="开发者中心成功响应不是合法的 JSON 业务对象。",
|
|
161
|
+
exit_code=4,
|
|
162
|
+
hint="请根据 requestId 检查网关和服务端响应。",
|
|
163
|
+
request_id=request_id,
|
|
164
|
+
path=path,
|
|
165
|
+
)
|
taco/core/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class TacoError(Exception):
|
|
8
|
+
code: str
|
|
9
|
+
message: str
|
|
10
|
+
exit_code: int = 1
|
|
11
|
+
status: int | None = None
|
|
12
|
+
hint: str | None = None
|
|
13
|
+
remote_code: str | None = None
|
|
14
|
+
app_id: str | None = None
|
|
15
|
+
request_id: str | None = None
|
|
16
|
+
path: str | None = None
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
Exception.__init__(self, self.message)
|
taco/core/file_lock.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from filelock import FileLock, Timeout
|
|
6
|
+
|
|
7
|
+
from taco.core.errors import TacoError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_LOCK_TIMEOUT_SECONDS = 10.0
|
|
11
|
+
LOCK_POLL_INTERVAL_SECONDS = 0.05
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InterProcessFileLock:
|
|
15
|
+
"""Cross-platform advisory file lock with bounded waiting."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
path: Path,
|
|
20
|
+
*,
|
|
21
|
+
timeout_seconds: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.path = path
|
|
24
|
+
self.timeout_seconds = max(0.0, timeout_seconds)
|
|
25
|
+
self._lock = FileLock(
|
|
26
|
+
path,
|
|
27
|
+
timeout=self.timeout_seconds,
|
|
28
|
+
mode=0o600,
|
|
29
|
+
)
|
|
30
|
+
self._acquired = False
|
|
31
|
+
|
|
32
|
+
def acquire(self) -> None:
|
|
33
|
+
if self._acquired:
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
self._lock.acquire(
|
|
37
|
+
timeout=self.timeout_seconds,
|
|
38
|
+
poll_interval=LOCK_POLL_INTERVAL_SECONDS,
|
|
39
|
+
)
|
|
40
|
+
self._acquired = True
|
|
41
|
+
try:
|
|
42
|
+
self.path.chmod(0o600)
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
except Timeout:
|
|
46
|
+
raise TacoError(
|
|
47
|
+
code="CONFIG_FILE_BUSY",
|
|
48
|
+
message="TACO 配置文件正被其他进程修改。",
|
|
49
|
+
exit_code=2,
|
|
50
|
+
hint="请稍后重试。",
|
|
51
|
+
path=str(self.path),
|
|
52
|
+
) from None
|
|
53
|
+
except OSError as error:
|
|
54
|
+
raise _lock_file_error(self.path) from error
|
|
55
|
+
|
|
56
|
+
def release(self) -> None:
|
|
57
|
+
if not self._acquired:
|
|
58
|
+
return
|
|
59
|
+
self._acquired = False
|
|
60
|
+
try:
|
|
61
|
+
self._lock.release()
|
|
62
|
+
except OSError as error:
|
|
63
|
+
raise _lock_file_error(self.path) from error
|
|
64
|
+
|
|
65
|
+
def __enter__(self) -> InterProcessFileLock:
|
|
66
|
+
self.acquire()
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
def __exit__(self, *args: object) -> None:
|
|
70
|
+
self.release()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _lock_file_error(path: Path) -> TacoError:
|
|
74
|
+
return TacoError(
|
|
75
|
+
code="CONFIG_FILE_WRITE_FAILED",
|
|
76
|
+
message="无法创建或锁定 TACO 配置锁文件。",
|
|
77
|
+
exit_code=2,
|
|
78
|
+
hint=f"请检查目录权限和文件类型:{path.parent}",
|
|
79
|
+
path=str(path),
|
|
80
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from taco import __version__
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cli_user_agent() -> str:
|
|
9
|
+
return f"CLI/TACO/{__version__}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def with_cli_user_agent(
|
|
13
|
+
headers: Mapping[str, str] | None = None,
|
|
14
|
+
) -> dict[str, str]:
|
|
15
|
+
merged = {
|
|
16
|
+
key: value
|
|
17
|
+
for key, value in (headers or {}).items()
|
|
18
|
+
if key.lower() != "user-agent"
|
|
19
|
+
}
|
|
20
|
+
merged["User-Agent"] = cli_user_agent()
|
|
21
|
+
return merged
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import ipaddress
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import quote, urlsplit
|
|
13
|
+
|
|
14
|
+
from taco.core.errors import TacoError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_EXPIRES_SECONDS = 60
|
|
18
|
+
MIN_EXPIRES_SECONDS = 1
|
|
19
|
+
MAX_EXPIRES_SECONDS = 86400
|
|
20
|
+
RESERVED_QUERY_NAMES = frozenset(
|
|
21
|
+
{"AccessKeyId", "Expires", "Timestamp", "Signature"}
|
|
22
|
+
)
|
|
23
|
+
ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "DELETE", "PATCH"})
|
|
24
|
+
_DOMAIN_PATTERN = re.compile(r"^[A-Za-z0-9.-]+$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class SignedRequest:
|
|
29
|
+
params: tuple[tuple[str, str], ...] = field(repr=False)
|
|
30
|
+
signature: str = field(repr=False)
|
|
31
|
+
canonical_string: str = field(repr=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OpenApiSigner:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
endpoint: str,
|
|
39
|
+
access_key_id: str,
|
|
40
|
+
access_key_secret: str,
|
|
41
|
+
expires: int = DEFAULT_EXPIRES_SECONDS,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.endpoint, self.authority = normalize_endpoint(endpoint)
|
|
44
|
+
if not access_key_id.strip() or not access_key_secret.strip():
|
|
45
|
+
raise TacoError(
|
|
46
|
+
code="OPENAPI_CREDENTIAL_REQUIRED",
|
|
47
|
+
message="AccessKeyId 和 AccessKeySecret 不能为空。",
|
|
48
|
+
exit_code=2,
|
|
49
|
+
)
|
|
50
|
+
if not MIN_EXPIRES_SECONDS <= expires <= MAX_EXPIRES_SECONDS:
|
|
51
|
+
raise TacoError(
|
|
52
|
+
code="OPENAPI_EXPIRES_INVALID",
|
|
53
|
+
message="签名有效时间必须在 1 到 86400 秒之间。",
|
|
54
|
+
exit_code=2,
|
|
55
|
+
)
|
|
56
|
+
self._access_key_id = access_key_id
|
|
57
|
+
self._access_key_secret = access_key_secret
|
|
58
|
+
self._expires = expires
|
|
59
|
+
|
|
60
|
+
def sign(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
query: Mapping[str, Any] | None = None,
|
|
65
|
+
*,
|
|
66
|
+
now: datetime | None = None,
|
|
67
|
+
) -> SignedRequest:
|
|
68
|
+
normalized_method = method.upper()
|
|
69
|
+
if normalized_method not in ALLOWED_METHODS:
|
|
70
|
+
raise TacoError(
|
|
71
|
+
code="OPENAPI_METHOD_INVALID",
|
|
72
|
+
message="开发者中心请求方法不受支持。",
|
|
73
|
+
exit_code=2,
|
|
74
|
+
path=_safe_path(path),
|
|
75
|
+
)
|
|
76
|
+
_validate_path(path)
|
|
77
|
+
timestamp = _format_timestamp(now or datetime.now(UTC))
|
|
78
|
+
business_params = _expand_query(query or {})
|
|
79
|
+
common_params = [
|
|
80
|
+
("AccessKeyId", self._access_key_id),
|
|
81
|
+
("Expires", str(self._expires)),
|
|
82
|
+
("Timestamp", timestamp),
|
|
83
|
+
]
|
|
84
|
+
unsigned_params = tuple(sorted((*business_params, *common_params)))
|
|
85
|
+
encoded_query = "&".join(
|
|
86
|
+
f"{_encode(name)}={_encode(value)}" for name, value in unsigned_params
|
|
87
|
+
)
|
|
88
|
+
canonical_string = (
|
|
89
|
+
f"{normalized_method}{self.authority}{path}?{encoded_query}"
|
|
90
|
+
)
|
|
91
|
+
digest = hmac.new(
|
|
92
|
+
self._access_key_secret.encode("utf-8"),
|
|
93
|
+
canonical_string.encode("utf-8"),
|
|
94
|
+
hashlib.sha1,
|
|
95
|
+
).digest()
|
|
96
|
+
signature = base64.b64encode(digest).decode("ascii")
|
|
97
|
+
return SignedRequest(
|
|
98
|
+
params=(*unsigned_params, ("Signature", signature)),
|
|
99
|
+
signature=signature,
|
|
100
|
+
canonical_string=canonical_string,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def normalize_endpoint(endpoint: str) -> tuple[str, str]:
|
|
105
|
+
try:
|
|
106
|
+
parsed = urlsplit(endpoint)
|
|
107
|
+
port = parsed.port
|
|
108
|
+
except (TypeError, ValueError) as error:
|
|
109
|
+
raise _endpoint_error() from error
|
|
110
|
+
if (
|
|
111
|
+
parsed.scheme.lower() != "https"
|
|
112
|
+
or not parsed.hostname
|
|
113
|
+
or parsed.username is not None
|
|
114
|
+
or parsed.password is not None
|
|
115
|
+
or parsed.path not in ("", "/")
|
|
116
|
+
or parsed.query
|
|
117
|
+
or parsed.fragment
|
|
118
|
+
):
|
|
119
|
+
raise _endpoint_error()
|
|
120
|
+
|
|
121
|
+
host = parsed.hostname.lower()
|
|
122
|
+
if any(character.isspace() for character in host):
|
|
123
|
+
raise _endpoint_error()
|
|
124
|
+
try:
|
|
125
|
+
ipaddress.ip_address(host)
|
|
126
|
+
is_ipv6 = ":" in host
|
|
127
|
+
except ValueError:
|
|
128
|
+
if not _DOMAIN_PATTERN.fullmatch(host):
|
|
129
|
+
raise _endpoint_error()
|
|
130
|
+
is_ipv6 = False
|
|
131
|
+
|
|
132
|
+
authority = f"[{host}]" if is_ipv6 else host
|
|
133
|
+
if port is not None and port != 443:
|
|
134
|
+
authority = f"{authority}:{port}"
|
|
135
|
+
return f"https://{authority}", authority
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _expand_query(query: Mapping[str, Any]) -> list[tuple[str, str]]:
|
|
139
|
+
params: list[tuple[str, str]] = []
|
|
140
|
+
for name, value in query.items():
|
|
141
|
+
if not isinstance(name, str) or not name:
|
|
142
|
+
raise TacoError(
|
|
143
|
+
code="OPENAPI_QUERY_INVALID",
|
|
144
|
+
message="开发者中心 Query 参数名必须是非空字符串。",
|
|
145
|
+
exit_code=2,
|
|
146
|
+
)
|
|
147
|
+
if name in RESERVED_QUERY_NAMES:
|
|
148
|
+
raise TacoError(
|
|
149
|
+
code="OPENAPI_QUERY_RESERVED",
|
|
150
|
+
message="业务参数不能覆盖开发者中心公共鉴权参数。",
|
|
151
|
+
exit_code=2,
|
|
152
|
+
)
|
|
153
|
+
if value is None:
|
|
154
|
+
continue
|
|
155
|
+
if isinstance(value, Sequence) and not isinstance(
|
|
156
|
+
value, (str, bytes, bytearray)
|
|
157
|
+
):
|
|
158
|
+
for index, item in enumerate(value):
|
|
159
|
+
params.append((f"{name}[{index}]", _query_value(item)))
|
|
160
|
+
else:
|
|
161
|
+
params.append((name, _query_value(value)))
|
|
162
|
+
return params
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _query_value(value: Any) -> str:
|
|
166
|
+
if isinstance(value, bool):
|
|
167
|
+
return "true" if value else "false"
|
|
168
|
+
if isinstance(value, (str, int, float)):
|
|
169
|
+
return str(value)
|
|
170
|
+
raise TacoError(
|
|
171
|
+
code="OPENAPI_QUERY_INVALID",
|
|
172
|
+
message="开发者中心 Query 参数值类型不受支持。",
|
|
173
|
+
exit_code=2,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _format_timestamp(value: datetime) -> str:
|
|
178
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
179
|
+
raise TacoError(
|
|
180
|
+
code="OPENAPI_TIMESTAMP_INVALID",
|
|
181
|
+
message="签名时间必须包含时区。",
|
|
182
|
+
exit_code=2,
|
|
183
|
+
)
|
|
184
|
+
return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _validate_path(path: str) -> None:
|
|
188
|
+
parsed = urlsplit(path)
|
|
189
|
+
if (
|
|
190
|
+
not path.startswith("/")
|
|
191
|
+
or path.startswith("//")
|
|
192
|
+
or parsed.scheme
|
|
193
|
+
or parsed.netloc
|
|
194
|
+
or parsed.query
|
|
195
|
+
or parsed.fragment
|
|
196
|
+
):
|
|
197
|
+
raise TacoError(
|
|
198
|
+
code="OPENAPI_PATH_INVALID",
|
|
199
|
+
message="开发者中心 API path 必须是绝对路径且不能包含 Query 或 fragment。",
|
|
200
|
+
exit_code=2,
|
|
201
|
+
path=_safe_path(path),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _safe_path(path: object) -> str | None:
|
|
206
|
+
if not isinstance(path, str):
|
|
207
|
+
return None
|
|
208
|
+
return path.split("?", 1)[0].split("#", 1)[0]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _encode(value: str) -> str:
|
|
212
|
+
return quote(value, safe="-_.~", encoding="utf-8", errors="strict")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _endpoint_error() -> TacoError:
|
|
216
|
+
return TacoError(
|
|
217
|
+
code="OPENAPI_ENDPOINT_INVALID",
|
|
218
|
+
message="开发者中心 endpoint 必须是 HTTPS 平台根地址。",
|
|
219
|
+
exit_code=2,
|
|
220
|
+
hint="只允许 scheme、host 和可选端口,不能包含路径、用户信息、Query 或 fragment。",
|
|
221
|
+
)
|
taco/core/output.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from taco.core.context import get_cli_context
|
|
9
|
+
from taco.core.errors import TacoError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def mask_token(token: str | None) -> str:
|
|
13
|
+
if not token:
|
|
14
|
+
return ""
|
|
15
|
+
if len(token) <= 8:
|
|
16
|
+
return "***"
|
|
17
|
+
return f"{token[:4]}...{token[-4:]}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def success(data: Any) -> dict[str, Any]:
|
|
21
|
+
return {"ok": True, "data": data}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def failure(error: TacoError) -> dict[str, Any]:
|
|
25
|
+
payload: dict[str, Any] = {
|
|
26
|
+
"code": error.code,
|
|
27
|
+
"message": error.message,
|
|
28
|
+
"exit_code": error.exit_code,
|
|
29
|
+
}
|
|
30
|
+
if error.status is not None:
|
|
31
|
+
payload["status"] = error.status
|
|
32
|
+
if error.hint is not None:
|
|
33
|
+
payload["hint"] = error.hint
|
|
34
|
+
if error.app_id is not None:
|
|
35
|
+
payload["app_id"] = error.app_id
|
|
36
|
+
if error.remote_code is not None:
|
|
37
|
+
payload["remote_code"] = error.remote_code
|
|
38
|
+
if error.request_id is not None:
|
|
39
|
+
payload["request_id"] = error.request_id
|
|
40
|
+
if error.path is not None:
|
|
41
|
+
payload["path"] = error.path
|
|
42
|
+
return {"ok": False, "error": payload}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_json_line(payload: dict[str, Any]) -> str:
|
|
46
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def emit_success(
|
|
50
|
+
ctx: typer.Context,
|
|
51
|
+
data: Any,
|
|
52
|
+
*,
|
|
53
|
+
human_message: str | None = None,
|
|
54
|
+
json_default: bool = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
if get_cli_context(ctx).json_output or json_default or human_message is None:
|
|
57
|
+
typer.echo(to_json_line(success(data)))
|
|
58
|
+
else:
|
|
59
|
+
typer.echo(human_message)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def exit_with_error(
|
|
63
|
+
ctx: typer.Context,
|
|
64
|
+
error: TacoError,
|
|
65
|
+
*,
|
|
66
|
+
json_default: bool = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
if get_cli_context(ctx).json_output or json_default:
|
|
69
|
+
typer.echo(to_json_line(failure(error)), err=True)
|
|
70
|
+
else:
|
|
71
|
+
typer.echo(f"{error.code}: {error.message}", err=True)
|
|
72
|
+
raise typer.Exit(error.exit_code)
|