@qingflow-tech/qingflow-cli 1.1.47-beta.5 → 1.1.48
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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +4 -26
- package/skills/qingflow-common/SKILL.md +15 -5
- package/src/qingflow_mcp/builder_facade/service.py +1 -119
- package/src/qingflow_mcp/cli/commands/__init__.py +1 -2
- package/src/qingflow_mcp/cli/commands/auth.py +1 -1
- package/src/qingflow_mcp/cli/context.py +1 -4
- package/src/qingflow_mcp/cli/formatters.py +3 -62
- package/src/qingflow_mcp/cli/main.py +1 -4
- package/src/qingflow_mcp/public_surface.py +0 -6
- package/src/qingflow_mcp/response_trim.py +3 -3
- package/src/qingflow_mcp/server.py +0 -2
- package/src/qingflow_mcp/server_app_builder.py +0 -16
- package/src/qingflow_mcp/server_app_user.py +3 -10
- package/src/qingflow_mcp/tools/ai_builder_tools.py +39 -208
- package/src/qingflow_mcp/tools/app_tools.py +13 -52
- package/src/qingflow_mcp/tools/login_link_tools.py +6 -34
- package/src/qingflow_mcp/tools/record_tools.py +46 -11
- package/src/qingflow_mcp/tools/resource_read_tools.py +0 -13
- package/src/qingflow_mcp/cli/commands/data.py +0 -107
- package/src/qingflow_mcp/resource_uri.py +0 -241
- package/src/qingflow_mcp/tools/csv_export.py +0 -168
- package/src/qingflow_mcp/tools/data_tools.py +0 -240
|
@@ -10,7 +10,6 @@ from ..config import DEFAULT_PROFILE
|
|
|
10
10
|
from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
|
|
11
11
|
from ..json_types import JSONObject
|
|
12
12
|
from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS, get_app_publish_status_label
|
|
13
|
-
from ..resource_uri import try_build_resource_uri
|
|
14
13
|
from ..solution.compiler.icon_utils import workspace_icon_config
|
|
15
14
|
from .base import ToolBase
|
|
16
15
|
|
|
@@ -47,15 +46,6 @@ class AppTools(ToolBase):
|
|
|
47
46
|
unfiltered_count = len(items)
|
|
48
47
|
if normalized_query:
|
|
49
48
|
items = self._filter_visible_apps(items, normalized_query)
|
|
50
|
-
for item in items:
|
|
51
|
-
uri, warning = try_build_resource_uri(
|
|
52
|
-
ws_id=session_profile.selected_ws_id,
|
|
53
|
-
resource_type="app",
|
|
54
|
-
app_key=item.get("app_key"),
|
|
55
|
-
)
|
|
56
|
-
if uri:
|
|
57
|
-
item["uri"] = uri
|
|
58
|
-
_append_unique_warning(warnings, warning)
|
|
59
49
|
response = {
|
|
60
50
|
"profile": profile,
|
|
61
51
|
"ws_id": session_profile.selected_ws_id,
|
|
@@ -210,10 +200,9 @@ class AppTools(ToolBase):
|
|
|
210
200
|
if not _is_optional_app_metadata_error(exc):
|
|
211
201
|
raise
|
|
212
202
|
visible_app = self._resolve_visible_app(context, app_key)
|
|
213
|
-
if visible_app is None:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
app_icon = str(visible_app.get("app_icon") or "").strip() or None
|
|
203
|
+
if visible_app is not None:
|
|
204
|
+
app_name = str(visible_app.get("app_name") or visible_app.get("title") or app_key).strip() or app_key
|
|
205
|
+
app_icon = str(visible_app.get("app_icon") or "").strip() or None
|
|
217
206
|
warnings.append(
|
|
218
207
|
_with_api_error_fields(
|
|
219
208
|
{
|
|
@@ -235,45 +224,22 @@ class AppTools(ToolBase):
|
|
|
235
224
|
accessible_views.extend(custom_views)
|
|
236
225
|
import_capability, import_warnings = _derive_import_capability(base_info)
|
|
237
226
|
warnings.extend(import_warnings)
|
|
238
|
-
app_uri, uri_warning = try_build_resource_uri(
|
|
239
|
-
ws_id=session_profile.selected_ws_id,
|
|
240
|
-
resource_type="app",
|
|
241
|
-
app_key=app_key,
|
|
242
|
-
)
|
|
243
|
-
_append_unique_warning(warnings, uri_warning)
|
|
244
|
-
for view in accessible_views:
|
|
245
|
-
view_id = str(view.get("view_id") or "")
|
|
246
|
-
view_kind, separator, view_key = view_id.partition(":")
|
|
247
|
-
if not separator:
|
|
248
|
-
continue
|
|
249
|
-
uri, warning = try_build_resource_uri(
|
|
250
|
-
ws_id=session_profile.selected_ws_id,
|
|
251
|
-
resource_type="view",
|
|
252
|
-
app_key=app_key,
|
|
253
|
-
view_kind=view_kind,
|
|
254
|
-
view_key=view_key,
|
|
255
|
-
)
|
|
256
|
-
if uri:
|
|
257
|
-
view["uri"] = uri
|
|
258
|
-
_append_unique_warning(warnings, warning)
|
|
259
|
-
data: JSONObject = {
|
|
260
|
-
"app_key": app_key,
|
|
261
|
-
"app_name": app_name,
|
|
262
|
-
"app_icon": app_icon,
|
|
263
|
-
"icon_config": workspace_icon_config(app_icon),
|
|
264
|
-
**({"uri": app_uri} if app_uri else {}),
|
|
265
|
-
**({"visible_app": visible_app} if base_info is None and visible_app is not None else {}),
|
|
266
|
-
"can_create": can_create,
|
|
267
|
-
"import_capability": import_capability,
|
|
268
|
-
"accessible_views": accessible_views,
|
|
269
|
-
}
|
|
270
227
|
return {
|
|
271
228
|
"profile": profile,
|
|
272
229
|
"ws_id": session_profile.selected_ws_id,
|
|
273
230
|
"ok": True,
|
|
274
231
|
"request_route": {"base_url": context.base_url, "qf_version": context.qf_version},
|
|
275
232
|
"warnings": warnings,
|
|
276
|
-
"data":
|
|
233
|
+
"data": {
|
|
234
|
+
"app_key": app_key,
|
|
235
|
+
"app_name": app_name,
|
|
236
|
+
"app_icon": app_icon,
|
|
237
|
+
"icon_config": workspace_icon_config(app_icon),
|
|
238
|
+
**({"visible_app": visible_app} if base_info is None and visible_app is not None else {}),
|
|
239
|
+
"can_create": can_create,
|
|
240
|
+
"import_capability": import_capability,
|
|
241
|
+
"accessible_views": accessible_views,
|
|
242
|
+
},
|
|
277
243
|
}
|
|
278
244
|
|
|
279
245
|
return self._run(profile, runner, tool_name='应用详情')
|
|
@@ -1082,11 +1048,6 @@ def _derive_editability(base_info: Any) -> tuple[JSONObject, list[JSONObject]]:
|
|
|
1082
1048
|
}, warnings
|
|
1083
1049
|
|
|
1084
1050
|
|
|
1085
|
-
def _append_unique_warning(warnings: list[JSONObject], warning: JSONObject | None) -> None:
|
|
1086
|
-
if warning and not any(item.get("code") == warning.get("code") for item in warnings):
|
|
1087
|
-
warnings.append(warning)
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
1051
|
def _coerce_positive_int(value: Any) -> int | None:
|
|
1091
1052
|
try:
|
|
1092
1053
|
number = int(value)
|
|
@@ -6,30 +6,18 @@ from urllib.parse import parse_qs, unquote, urlencode, urlsplit
|
|
|
6
6
|
|
|
7
7
|
from mcp.server.fastmcp import FastMCP
|
|
8
8
|
|
|
9
|
-
from ..config import DEFAULT_PROFILE
|
|
9
|
+
from ..config import DEFAULT_PROFILE, get_web_base_url
|
|
10
10
|
from ..errors import QingflowApiError
|
|
11
11
|
from ..json_types import JSONObject
|
|
12
|
-
from ..resource_uri import parse_resource_path, resolve_resource_web_origin
|
|
13
12
|
from .base import ToolBase
|
|
14
13
|
|
|
15
14
|
LOGIN_LINK_DESCRIPTION = (
|
|
16
15
|
"轻流页面入口:查看页面、继续编辑、处理业务、查看结果或进入工作区(open/view/visit page in browser,login)。"
|
|
17
16
|
"从 AI 对话进入页面时,使用当前 MCP 身份和工作区生成短时(约 5 分钟)一次性免登录链接。"
|
|
18
17
|
"redirect_path 可选:传已确认的站内业务路径,可带 query/hash;省略进入首页,不接受外部地址或认证入口。"
|
|
19
|
-
"
|
|
18
|
+
"纯数据查询无需调用;用户指定页面但路径未知时先定位,不猜路径或静默改为首页。"
|
|
20
19
|
"返回 url 和毫秒整数 expires_at;必要安全验证及原有页面权限仍然生效。"
|
|
21
|
-
"
|
|
22
|
-
"采用“完成结果 + 查看入口”,文案使用“在轻流中查看应用/应用包/视图”,不默认称为“继续编辑”。"
|
|
23
|
-
"安装方案默认提供应用包入口;直接创建或修改应用、视图时提供对应入口;用户明确要求多个主要对象时分别提供。"
|
|
24
|
-
"使用业务结果中的稳定 `uri`,按 URI 去重,不为全部子资源预签票据;主要对象没有可靠 URI 时说明限制,不猜地址或替换为不相关页面。"
|
|
25
|
-
"部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明;"
|
|
26
|
-
"纯数据问答、中间读取、失败资源和删除操作不自动签发,用户明确要求不提供链接时遵从。"
|
|
27
|
-
"目标浏览器登录态未知时,临近交付用资源 URI 的同源 pathname 为每个实际展示的入口分别签发,使用返回的完整 URL,不额外追问登录态。"
|
|
28
|
-
"只有已有用户确认同一浏览器、网页域名、账号和工作区已登录且无失效信号的事实时,才直接交付稳定 URI;"
|
|
29
|
-
"调用过本工具或 Agent 自动化浏览器已登录不等于用户浏览器已登录,已消费链接不可复用。"
|
|
30
|
-
"用户未要求打开时不提前打开或消费链接;要求打开时才用浏览器打开返回的完整链接。"
|
|
31
|
-
"签发失败时说明免登录链接未生成,提供标注“需登录”的稳定 URI 作为备用入口,不重做业务操作。"
|
|
32
|
-
"机器结果继续保留稳定 URI,临时登录链接仅用于面向用户交付。"
|
|
20
|
+
"用户要链接时仅交付,不提前消费;要求打开时才用浏览器打开返回的完整链接。"
|
|
33
21
|
)
|
|
34
22
|
|
|
35
23
|
|
|
@@ -82,31 +70,15 @@ class LoginLinkTools(ToolBase):
|
|
|
82
70
|
"""执行工具方法逻辑。"""
|
|
83
71
|
|
|
84
72
|
def runner(session_profile, context):
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
target = requested_target
|
|
88
|
-
try:
|
|
89
|
-
resource = parse_resource_path(target)
|
|
90
|
-
except ValueError:
|
|
91
|
-
raise QingflowApiError(
|
|
92
|
-
category="validation",
|
|
93
|
-
message="redirect_path 必须是合法的稳定资源路径",
|
|
94
|
-
) from None
|
|
95
|
-
if resource.ws_id != session_profile.selected_ws_id:
|
|
96
|
-
raise QingflowApiError(
|
|
97
|
-
category="validation",
|
|
98
|
-
message="redirect_path 的资源工作区必须与当前选中工作区一致",
|
|
99
|
-
)
|
|
100
|
-
else:
|
|
101
|
-
target = validate_redirect_path(requested_target)
|
|
102
|
-
web_base_url = resolve_resource_web_origin()
|
|
73
|
+
target = validate_redirect_path(redirect_path)
|
|
74
|
+
web_base_url = get_web_base_url()
|
|
103
75
|
result = self.backend.request(
|
|
104
76
|
"POST",
|
|
105
77
|
context,
|
|
106
78
|
"/mcp/auth/login-link",
|
|
107
79
|
json_body={"wsId": session_profile.selected_ws_id},
|
|
108
80
|
)
|
|
109
|
-
#
|
|
81
|
+
# 后端仅提供票据,网页地址由 MCP 配置确定,避免工作区域名为空。
|
|
110
82
|
try:
|
|
111
83
|
source_url = result.get("url") if isinstance(result, dict) else None
|
|
112
84
|
if not isinstance(source_url, str):
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import csv
|
|
3
4
|
import html
|
|
4
5
|
import mimetypes
|
|
5
6
|
import json
|
|
@@ -33,7 +34,6 @@ from ..list_type_labels import (
|
|
|
33
34
|
)
|
|
34
35
|
from .base import ToolBase
|
|
35
36
|
from .directory_tools import _directory_has_more, _directory_items
|
|
36
|
-
from .csv_export import CsvShardWriter
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
DEFAULT_QUERY_PAGE_SIZE = 50
|
|
@@ -2263,22 +2263,57 @@ class RecordTools(ToolBase):
|
|
|
2263
2263
|
}
|
|
2264
2264
|
|
|
2265
2265
|
run_dir = _record_access_run_dir()
|
|
2266
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
2266
2267
|
header = ["record_id"] + [str(field["column_name"]) for field in fields_payload]
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2268
|
+
files: list[JSONObject] = []
|
|
2269
|
+
shard_part = 0
|
|
2270
|
+
shard_rows = 0
|
|
2271
|
+
row_count = 0
|
|
2272
|
+
file_handle: Any | None = None
|
|
2273
|
+
writer: Any | None = None
|
|
2274
|
+
|
|
2275
|
+
def close_shard() -> None:
|
|
2276
|
+
nonlocal file_handle, writer, shard_rows
|
|
2277
|
+
if file_handle is None:
|
|
2278
|
+
return
|
|
2279
|
+
path = Path(file_handle.name)
|
|
2280
|
+
file_handle.close()
|
|
2281
|
+
files.append(
|
|
2282
|
+
{
|
|
2283
|
+
"local_path": str(path),
|
|
2284
|
+
"row_count": shard_rows,
|
|
2285
|
+
"part": len(files) + 1,
|
|
2286
|
+
}
|
|
2287
|
+
)
|
|
2288
|
+
file_handle = None
|
|
2289
|
+
writer = None
|
|
2290
|
+
shard_rows = 0
|
|
2291
|
+
|
|
2292
|
+
def ensure_writer() -> Any:
|
|
2293
|
+
nonlocal file_handle, writer, shard_part
|
|
2294
|
+
if writer is not None:
|
|
2295
|
+
return writer
|
|
2296
|
+
shard_part += 1
|
|
2297
|
+
path = run_dir / f"records-{shard_part:04d}.csv"
|
|
2298
|
+
file_handle = path.open("w", newline="", encoding="utf-8-sig")
|
|
2299
|
+
writer = csv.writer(file_handle)
|
|
2300
|
+
writer.writerow(header)
|
|
2301
|
+
return writer
|
|
2273
2302
|
|
|
2274
2303
|
def write_record(apply_id: int | None, answer_list: list[JSONValue]) -> None:
|
|
2304
|
+
nonlocal row_count, shard_rows
|
|
2275
2305
|
if apply_id is None:
|
|
2276
2306
|
return
|
|
2307
|
+
if shard_rows >= DEFAULT_RECORD_ACCESS_SHARD_ROWS:
|
|
2308
|
+
close_shard()
|
|
2309
|
+
csv_writer = ensure_writer()
|
|
2277
2310
|
values: list[str] = [_public_record_id_text(apply_id) or ""]
|
|
2278
2311
|
for field in selected_fields:
|
|
2279
2312
|
answer = _find_answer_for_field(answer_list, field)
|
|
2280
2313
|
values.append(_record_access_csv_cell(_normalize_answer_field_value_for_output(answer, field)))
|
|
2281
|
-
|
|
2314
|
+
csv_writer.writerow(values)
|
|
2315
|
+
row_count += 1
|
|
2316
|
+
shard_rows += 1
|
|
2282
2317
|
|
|
2283
2318
|
current_page = 1
|
|
2284
2319
|
has_more = False
|
|
@@ -2318,7 +2353,7 @@ class RecordTools(ToolBase):
|
|
|
2318
2353
|
break
|
|
2319
2354
|
page = fetch_page(current_page, BACKEND_RECORD_ACCESS_PAGE_SIZE)
|
|
2320
2355
|
finally:
|
|
2321
|
-
|
|
2356
|
+
close_shard()
|
|
2322
2357
|
|
|
2323
2358
|
if stopped_reason == "TIME_BUDGET_EXCEEDED":
|
|
2324
2359
|
warnings.append(_record_access_time_budget_warning(reported_total=reported_total, fetched_pages=fetched_pages))
|
|
@@ -2343,11 +2378,11 @@ class RecordTools(ToolBase):
|
|
|
2343
2378
|
"view_id": view_route.view_id,
|
|
2344
2379
|
"format": "csv",
|
|
2345
2380
|
"local_dir": str(run_dir),
|
|
2346
|
-
"row_count":
|
|
2381
|
+
"row_count": row_count,
|
|
2347
2382
|
"complete": complete,
|
|
2348
2383
|
"truncated": not complete,
|
|
2349
2384
|
"safe_for_final_conclusion": safe_for_final_conclusion,
|
|
2350
|
-
"files":
|
|
2385
|
+
"files": files,
|
|
2351
2386
|
"fields": fields_payload,
|
|
2352
2387
|
"warnings": warnings,
|
|
2353
2388
|
"verification": verification_payload,
|
|
@@ -6,7 +6,6 @@ from typing import Any
|
|
|
6
6
|
from ..errors import QingflowApiError, backend_code_int, is_auth_like_error, raise_tool_error
|
|
7
7
|
from ..json_types import JSONObject
|
|
8
8
|
from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS
|
|
9
|
-
from ..resource_uri import try_build_resource_uri
|
|
10
9
|
from ..solution.compiler.icon_utils import workspace_icon_config
|
|
11
10
|
from .app_tools import _analysis_supported_for_view_type
|
|
12
11
|
from .base import ToolBase
|
|
@@ -228,17 +227,6 @@ class ResourceReadTools(ToolBase):
|
|
|
228
227
|
"message": f"view_get supports exporting `{view_id}`, but the export call still needs an explicit `app_key` from the parent app context.",
|
|
229
228
|
}
|
|
230
229
|
)
|
|
231
|
-
uri = None
|
|
232
|
-
if resolved_app_key:
|
|
233
|
-
uri, uri_warning = try_build_resource_uri(
|
|
234
|
-
ws_id=session_profile.selected_ws_id,
|
|
235
|
-
resource_type="view",
|
|
236
|
-
app_key=resolved_app_key,
|
|
237
|
-
view_kind="custom",
|
|
238
|
-
view_key=view_key,
|
|
239
|
-
)
|
|
240
|
-
if uri_warning:
|
|
241
|
-
warnings.append(uri_warning)
|
|
242
230
|
return {
|
|
243
231
|
"profile": profile,
|
|
244
232
|
"ws_id": session_profile.selected_ws_id,
|
|
@@ -261,7 +249,6 @@ class ResourceReadTools(ToolBase):
|
|
|
261
249
|
],
|
|
262
250
|
"analysis_supported": _analysis_supported_for_view_type(raw_view_type or None),
|
|
263
251
|
"export_capability": export_capability,
|
|
264
|
-
**({"uri": uri} if uri else {}),
|
|
265
252
|
},
|
|
266
253
|
}
|
|
267
254
|
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
|
|
5
|
-
from ..context import CliContext
|
|
6
|
-
from .common import load_list_arg, require_list_arg, require_object_arg, raise_config_error
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
10
|
-
parser = subparsers.add_parser("data", help="视图无关的应用数据面")
|
|
11
|
-
commands = parser.add_subparsers(dest="data_command", required=True, metavar="{query,get,insert,update,delete}")
|
|
12
|
-
|
|
13
|
-
query = commands.add_parser("query", help="分页查询应用数据")
|
|
14
|
-
query.add_argument("--app-key", required=True)
|
|
15
|
-
query.add_argument("--columns-file")
|
|
16
|
-
query.add_argument("--where-file")
|
|
17
|
-
query.add_argument("--sort-file")
|
|
18
|
-
query.add_argument("--search", default="")
|
|
19
|
-
query.add_argument("--page", type=int, default=1)
|
|
20
|
-
query.add_argument("--page-size", type=int, default=100)
|
|
21
|
-
query.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
|
|
22
|
-
query.add_argument("--all", action="store_true", dest="all_pages")
|
|
23
|
-
query.add_argument("--output", choices=("json", "csv"), default="json")
|
|
24
|
-
query.set_defaults(handler=_handle_query, format_hint="data_query")
|
|
25
|
-
|
|
26
|
-
get = commands.add_parser("get", help="读取单条应用数据")
|
|
27
|
-
get.add_argument("--app-key", required=True)
|
|
28
|
-
get.add_argument("--record-id", required=True)
|
|
29
|
-
get.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
|
|
30
|
-
get.set_defaults(handler=_handle_get, format_hint="data_get")
|
|
31
|
-
|
|
32
|
-
insert = commands.add_parser("insert", help="新增应用数据")
|
|
33
|
-
insert.add_argument("--app-key", required=True)
|
|
34
|
-
insert.add_argument("--items-file", required=True)
|
|
35
|
-
insert.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
|
|
36
|
-
insert.set_defaults(handler=_handle_insert, format_hint="data_insert")
|
|
37
|
-
|
|
38
|
-
update = commands.add_parser("update", help="更新应用数据")
|
|
39
|
-
update.add_argument("--app-key", required=True)
|
|
40
|
-
update.add_argument("--record-id", required=True)
|
|
41
|
-
update.add_argument("--fields-file", required=True)
|
|
42
|
-
update.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
|
|
43
|
-
update.set_defaults(handler=_handle_update, format_hint="data_update")
|
|
44
|
-
|
|
45
|
-
delete = commands.add_parser("delete", help="删除应用数据")
|
|
46
|
-
delete.add_argument("--app-key", required=True)
|
|
47
|
-
delete.add_argument(
|
|
48
|
-
"--record-ids", action="append", default=[], metavar="ID[,ID...]",
|
|
49
|
-
help="待删除记录 ID;支持逗号分隔,也可重复传入",
|
|
50
|
-
)
|
|
51
|
-
delete.add_argument("--record-ids-file", help="附加的记录 ID JSON 数组文件")
|
|
52
|
-
delete.set_defaults(handler=_handle_delete, format_hint="data_delete")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def _handle_query(args: argparse.Namespace, context: CliContext) -> dict:
|
|
56
|
-
if args.all_pages != (args.output == "csv"):
|
|
57
|
-
raise_config_error(
|
|
58
|
-
"--all 与 --output csv 必须同时使用。",
|
|
59
|
-
fix_hint="使用 `qingflow data query ... --all --output csv`,普通分页查询不要传这两个参数。",
|
|
60
|
-
)
|
|
61
|
-
return context.data.data_query(
|
|
62
|
-
profile=args.profile,
|
|
63
|
-
app_key=args.app_key,
|
|
64
|
-
columns=load_list_arg(args.columns_file, option_name="--columns-file"),
|
|
65
|
-
where=load_list_arg(args.where_file, option_name="--where-file"),
|
|
66
|
-
sort=load_list_arg(args.sort_file, option_name="--sort-file"),
|
|
67
|
-
search=args.search,
|
|
68
|
-
page=args.page,
|
|
69
|
-
page_size=args.page_size,
|
|
70
|
-
include_schema=args.include_schema,
|
|
71
|
-
all_pages=args.all_pages,
|
|
72
|
-
output=args.output,
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
77
|
-
return context.data.data_get(
|
|
78
|
-
profile=args.profile, app_key=args.app_key, record_id=args.record_id, include_schema=args.include_schema
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _handle_insert(args: argparse.Namespace, context: CliContext) -> dict:
|
|
83
|
-
return context.data.data_insert(
|
|
84
|
-
profile=args.profile, app_key=args.app_key,
|
|
85
|
-
items=require_list_arg(args.items_file, option_name="--items-file"), include_schema=args.include_schema,
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _handle_update(args: argparse.Namespace, context: CliContext) -> dict:
|
|
90
|
-
return context.data.data_update(
|
|
91
|
-
profile=args.profile, app_key=args.app_key, record_id=args.record_id,
|
|
92
|
-
fields=require_object_arg(args.fields_file, option_name="--fields-file"), include_schema=args.include_schema,
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _handle_delete(args: argparse.Namespace, context: CliContext) -> dict:
|
|
97
|
-
record_ids = [item.strip() for group in args.record_ids for item in group.split(",") if item.strip()]
|
|
98
|
-
record_ids.extend(load_list_arg(args.record_ids_file, option_name="--record-ids-file"))
|
|
99
|
-
if not record_ids:
|
|
100
|
-
raise_config_error(
|
|
101
|
-
"至少传入一个记录 ID。",
|
|
102
|
-
fix_hint="使用 `--record-ids ID[,ID...]`;也可附加 `--record-ids-file ids.json`。",
|
|
103
|
-
)
|
|
104
|
-
return context.data.data_delete(
|
|
105
|
-
profile=args.profile, app_key=args.app_key,
|
|
106
|
-
record_ids=record_ids,
|
|
107
|
-
)
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from collections.abc import Iterator
|
|
5
|
-
from contextlib import contextmanager
|
|
6
|
-
from contextvars import ContextVar
|
|
7
|
-
from dataclasses import dataclass
|
|
8
|
-
from typing import Any
|
|
9
|
-
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
10
|
-
|
|
11
|
-
from .config import DEFAULT_WEB_BASE_URL, get_web_base_url
|
|
12
|
-
from .errors import QingflowApiError
|
|
13
|
-
from .json_types import JSONObject
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
SYSTEM_VIEW_NAMES = frozenset({"all", "initiated", "todo", "done", "cc"})
|
|
17
|
-
_ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
18
|
-
_CLI_RESOURCE_BASE_URL: ContextVar[str | None] = ContextVar(
|
|
19
|
-
"qingflow_cli_resource_base_url",
|
|
20
|
-
default=None,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass(frozen=True, slots=True)
|
|
25
|
-
class ResourcePath:
|
|
26
|
-
resource_type: str
|
|
27
|
-
ws_id: int
|
|
28
|
-
package_id: int | None = None
|
|
29
|
-
app_key: str | None = None
|
|
30
|
-
view_kind: str | None = None
|
|
31
|
-
view_key: str | None = None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@contextmanager
|
|
35
|
-
def cli_resource_base_url(value: str) -> Iterator[None]:
|
|
36
|
-
"""让一次 CLI 调用的资源 URI 使用 QINGFLOW_BASE_URL 对应的网页环境。"""
|
|
37
|
-
token = _CLI_RESOURCE_BASE_URL.set(value)
|
|
38
|
-
try:
|
|
39
|
-
yield
|
|
40
|
-
finally:
|
|
41
|
-
_CLI_RESOURCE_BASE_URL.reset(token)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def validate_resource_identity(value: object, field: str = "resource_id") -> str:
|
|
45
|
-
"""按资源 URI 单路径段合同校验并返回规范身份,不读取网页域名配置。"""
|
|
46
|
-
return _validate_identity(value, field)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def validate_positive_resource_id(value: object, field: str = "resource_id") -> int:
|
|
50
|
-
"""按资源 URI 数字路径段合同校验正整数,不接受布尔值或浮点截断。"""
|
|
51
|
-
return int(_positive_int_segment(value, field))
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def build_resource_uri(
|
|
55
|
-
*,
|
|
56
|
-
ws_id: object,
|
|
57
|
-
resource_type: str,
|
|
58
|
-
package_id: object = None,
|
|
59
|
-
app_key: object = None,
|
|
60
|
-
view_kind: str | None = None,
|
|
61
|
-
view_key: object = None,
|
|
62
|
-
web_origin: str | None = None,
|
|
63
|
-
) -> str:
|
|
64
|
-
"""按固定公共合同构造资源 URI,不接受通用目标路由。"""
|
|
65
|
-
workspace = _positive_int_segment(ws_id, "ws_id")
|
|
66
|
-
if resource_type == "package":
|
|
67
|
-
path = f"/resources/ws/{workspace}/package/{_positive_int_segment(package_id, 'package_id')}"
|
|
68
|
-
elif resource_type == "app":
|
|
69
|
-
path = f"/resources/ws/{workspace}/app/{_identity_segment(app_key, 'app_key')}"
|
|
70
|
-
elif resource_type == "view":
|
|
71
|
-
app_segment = _identity_segment(app_key, "app_key")
|
|
72
|
-
if view_kind == "custom":
|
|
73
|
-
view_segment = _identity_segment(view_key, "view_key")
|
|
74
|
-
elif view_kind == "system":
|
|
75
|
-
name = str(view_key or "").strip()
|
|
76
|
-
if name not in SYSTEM_VIEW_NAMES:
|
|
77
|
-
raise ValueError("system view name is not supported")
|
|
78
|
-
view_segment = name
|
|
79
|
-
else:
|
|
80
|
-
raise ValueError("view_kind must be custom or system")
|
|
81
|
-
path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
|
|
82
|
-
else:
|
|
83
|
-
raise ValueError("resource_type is not supported")
|
|
84
|
-
return f"{resolve_resource_web_origin(web_origin)}{path}"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def parse_resource_path(value: str) -> ResourcePath:
|
|
88
|
-
"""严格解析资源 pathname,供免登录签发前校验工作区。"""
|
|
89
|
-
try:
|
|
90
|
-
parsed = urlsplit(value)
|
|
91
|
-
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment or parsed.path != value:
|
|
92
|
-
raise ValueError
|
|
93
|
-
parts = parsed.path.split("/")
|
|
94
|
-
if len(parts) < 6 or parts[:3] != ["", "resources", "ws"]:
|
|
95
|
-
raise ValueError
|
|
96
|
-
ws_id = int(_decode_segment(parts[3], "ws_id"))
|
|
97
|
-
if ws_id <= 0:
|
|
98
|
-
raise ValueError
|
|
99
|
-
if len(parts) == 6 and parts[4] == "package":
|
|
100
|
-
package_id = int(_decode_segment(parts[5], "package_id"))
|
|
101
|
-
if package_id <= 0:
|
|
102
|
-
raise ValueError
|
|
103
|
-
return ResourcePath("package", ws_id, package_id=package_id)
|
|
104
|
-
if len(parts) == 6 and parts[4] == "app":
|
|
105
|
-
return ResourcePath("app", ws_id, app_key=_decode_segment(parts[5], "app_key"))
|
|
106
|
-
if len(parts) == 9 and parts[4] == "app" and parts[6] == "view":
|
|
107
|
-
app_key = _decode_segment(parts[5], "app_key")
|
|
108
|
-
view_kind = parts[7]
|
|
109
|
-
view_key = _decode_segment(parts[8], "view_key")
|
|
110
|
-
if view_kind == "system" and view_key not in SYSTEM_VIEW_NAMES:
|
|
111
|
-
raise ValueError
|
|
112
|
-
if view_kind not in {"custom", "system"}:
|
|
113
|
-
raise ValueError
|
|
114
|
-
return ResourcePath("view", ws_id, app_key=app_key, view_kind=view_kind, view_key=view_key)
|
|
115
|
-
except (TypeError, ValueError, UnicodeError):
|
|
116
|
-
pass
|
|
117
|
-
raise ValueError("resource path is invalid")
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def try_build_resource_uri(**kwargs: Any) -> tuple[str | None, JSONObject | None]:
|
|
121
|
-
try:
|
|
122
|
-
return build_resource_uri(**kwargs), None
|
|
123
|
-
except (QingflowApiError, ValueError, UnicodeError) as error:
|
|
124
|
-
return None, {
|
|
125
|
-
"code": "RESOURCE_URI_UNAVAILABLE",
|
|
126
|
-
"message": f"resource URI was omitted because its identity or web origin is invalid: {error}",
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _positive_int_segment(value: object, field: str) -> str:
|
|
131
|
-
if isinstance(value, bool):
|
|
132
|
-
raise ValueError(f"{field} must be a positive integer")
|
|
133
|
-
try:
|
|
134
|
-
parsed = int(value)
|
|
135
|
-
except (TypeError, ValueError):
|
|
136
|
-
raise ValueError(f"{field} must be a positive integer") from None
|
|
137
|
-
if parsed <= 0 or str(value).strip() != str(parsed):
|
|
138
|
-
raise ValueError(f"{field} must be a positive integer")
|
|
139
|
-
return str(parsed)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _normalize_web_origin(value: str) -> str:
|
|
143
|
-
try:
|
|
144
|
-
parsed = urlsplit(value.strip())
|
|
145
|
-
if (
|
|
146
|
-
parsed.scheme not in {"http", "https"}
|
|
147
|
-
or not parsed.hostname
|
|
148
|
-
or parsed.username is not None
|
|
149
|
-
or parsed.password is not None
|
|
150
|
-
or parsed.path not in {"", "/"}
|
|
151
|
-
or parsed.query
|
|
152
|
-
or parsed.fragment
|
|
153
|
-
or any(character.isspace() or ord(character) < 32 for character in value)
|
|
154
|
-
):
|
|
155
|
-
raise ValueError
|
|
156
|
-
_ = parsed.port
|
|
157
|
-
except (AttributeError, ValueError):
|
|
158
|
-
raise ValueError("web_origin must be an absolute HTTP(S) origin") from None
|
|
159
|
-
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def resolve_resource_web_origin(web_origin: str | None = None) -> str:
|
|
163
|
-
"""解析当前调用面的网页根地址;CLI 与 MCP 分别沿用各自的环境配置。"""
|
|
164
|
-
if web_origin is not None:
|
|
165
|
-
return _normalize_web_origin(web_origin)
|
|
166
|
-
cli_base_url = _CLI_RESOURCE_BASE_URL.get()
|
|
167
|
-
if cli_base_url is None:
|
|
168
|
-
return get_web_base_url()
|
|
169
|
-
return _web_origin_from_api_base_url(cli_base_url)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def _web_origin_from_api_base_url(value: str) -> str:
|
|
173
|
-
normalized = value.strip()
|
|
174
|
-
if not normalized:
|
|
175
|
-
return DEFAULT_WEB_BASE_URL
|
|
176
|
-
try:
|
|
177
|
-
parsed = urlsplit(normalized)
|
|
178
|
-
host = parsed.hostname or ""
|
|
179
|
-
valid_host = bool(host) and (":" in host or all(
|
|
180
|
-
re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
|
|
181
|
-
for label in host.rstrip(".").split(".")
|
|
182
|
-
))
|
|
183
|
-
if (
|
|
184
|
-
parsed.scheme not in {"http", "https"}
|
|
185
|
-
or not valid_host
|
|
186
|
-
or host.lower() in {"null", "undefined"}
|
|
187
|
-
or parsed.username is not None
|
|
188
|
-
or parsed.password is not None
|
|
189
|
-
or parsed.query
|
|
190
|
-
or parsed.fragment
|
|
191
|
-
or any(character.isspace() or ord(character) < 32 for character in normalized)
|
|
192
|
-
):
|
|
193
|
-
raise ValueError
|
|
194
|
-
_ = parsed.port
|
|
195
|
-
return _normalize_web_origin(urlunsplit((parsed.scheme, parsed.netloc, "", "", "")))
|
|
196
|
-
except ValueError:
|
|
197
|
-
raise QingflowApiError.config_error(
|
|
198
|
-
"QINGFLOW_BASE_URL 必须是有效的 HTTP(S) API 地址,不包含查询参数、片段或用户凭证"
|
|
199
|
-
) from None
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
def _identity_segment(value: object, field: str) -> str:
|
|
203
|
-
raw = _validate_identity(value, field)
|
|
204
|
-
return quote(raw, safe="")
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
def _decode_segment(value: str, field: str) -> str:
|
|
208
|
-
decoded = _validate_identity(unquote(value, errors="strict"), field)
|
|
209
|
-
if quote(decoded, safe="") != value:
|
|
210
|
-
raise ValueError(f"{field} must use canonical path encoding")
|
|
211
|
-
return decoded
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _validate_identity(value: object, field: str) -> str:
|
|
215
|
-
source = str(value or "")
|
|
216
|
-
if any(ord(character) < 32 or ord(character) == 127 for character in source):
|
|
217
|
-
raise ValueError(f"{field} must be one safe path segment")
|
|
218
|
-
raw = source.strip()
|
|
219
|
-
if not raw:
|
|
220
|
-
raise ValueError(f"{field} is required")
|
|
221
|
-
current = raw
|
|
222
|
-
for _ in range(8):
|
|
223
|
-
if (
|
|
224
|
-
current in {".", ".."}
|
|
225
|
-
or any(character in current for character in "/\\?#")
|
|
226
|
-
or any(ord(character) < 32 or ord(character) == 127 for character in current)
|
|
227
|
-
):
|
|
228
|
-
raise ValueError(f"{field} must be one safe path segment")
|
|
229
|
-
decoded = unquote(current, errors="strict")
|
|
230
|
-
if decoded == current:
|
|
231
|
-
return raw
|
|
232
|
-
current = decoded
|
|
233
|
-
if (
|
|
234
|
-
current in {".", ".."}
|
|
235
|
-
or any(character in current for character in "/\\?#")
|
|
236
|
-
or any(ord(character) < 32 or ord(character) == 127 for character in current)
|
|
237
|
-
):
|
|
238
|
-
raise ValueError(f"{field} must be one safe path segment")
|
|
239
|
-
if _ENCODED_OCTET.search(current):
|
|
240
|
-
raise ValueError(f"{field} has excessive encoding")
|
|
241
|
-
return raw
|