@josephyan/qingflow-mcp 0.1.0-beta.2
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 +517 -0
- package/docs/local-agent-install.md +213 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow-mcp.mjs +7 -0
- package/npm/lib/runtime.mjs +146 -0
- package/npm/scripts/postinstall.mjs +12 -0
- package/package.json +34 -0
- package/pyproject.toml +63 -0
- package/qingflow-mcp +15 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +336 -0
- package/src/qingflow_mcp/config.py +166 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/server.py +70 -0
- package/src/qingflow_mcp/session_store.py +235 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2064 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/run_store.py +221 -0
- package/src/qingflow_mcp/solution/spec_models.py +755 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/app_tools.py +239 -0
- package/src/qingflow_mcp/tools/approval_tools.py +481 -0
- package/src/qingflow_mcp/tools/auth_tools.py +496 -0
- package/src/qingflow_mcp/tools/base.py +81 -0
- package/src/qingflow_mcp/tools/directory_tools.py +476 -0
- package/src/qingflow_mcp/tools/file_tools.py +375 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
- package/src/qingflow_mcp/tools/package_tools.py +142 -0
- package/src/qingflow_mcp/tools/portal_tools.py +100 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +258 -0
- package/src/qingflow_mcp/tools/record_tools.py +4305 -0
- package/src/qingflow_mcp/tools/role_tools.py +94 -0
- package/src/qingflow_mcp/tools/solution_tools.py +1860 -0
- package/src/qingflow_mcp/tools/task_tools.py +677 -0
- package/src/qingflow_mcp/tools/view_tools.py +324 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +311 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +143 -0
package/entry_point.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
# Ensure the 'src' directory is in the Python search path
|
|
5
|
+
# PyInstaller will pick up everything in src/qingflow_mcp as the package
|
|
6
|
+
src_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "src"))
|
|
7
|
+
if src_path not in sys.path:
|
|
8
|
+
sys.path.insert(0, src_path)
|
|
9
|
+
|
|
10
|
+
from qingflow_mcp.server import main
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const WINDOWS = process.platform === "win32";
|
|
7
|
+
|
|
8
|
+
function runChecked(command, args, options = {}) {
|
|
9
|
+
const result = spawnSync(command, args, {
|
|
10
|
+
stdio: "inherit",
|
|
11
|
+
...options,
|
|
12
|
+
});
|
|
13
|
+
if (result.error) {
|
|
14
|
+
throw result.error;
|
|
15
|
+
}
|
|
16
|
+
if (result.status !== 0) {
|
|
17
|
+
throw new Error(`Command failed: ${command} ${args.join(" ")}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function commandWorks(command, args) {
|
|
22
|
+
const result = spawnSync(command, args, {
|
|
23
|
+
stdio: "ignore",
|
|
24
|
+
});
|
|
25
|
+
return result.status === 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getPackageRoot(metaUrl) {
|
|
29
|
+
return path.resolve(path.dirname(fileURLToPath(metaUrl)), "..", "..");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getVenvDir(packageRoot) {
|
|
33
|
+
return path.join(packageRoot, ".npm-python");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getVenvPython(packageRoot) {
|
|
37
|
+
return WINDOWS
|
|
38
|
+
? path.join(getVenvDir(packageRoot), "Scripts", "python.exe")
|
|
39
|
+
: path.join(getVenvDir(packageRoot), "bin", "python");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getVenvServerCommand(packageRoot) {
|
|
43
|
+
return WINDOWS
|
|
44
|
+
? path.join(getVenvDir(packageRoot), "Scripts", "qingflow-mcp.exe")
|
|
45
|
+
: path.join(getVenvDir(packageRoot), "bin", "qingflow-mcp");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getVenvPip(packageRoot) {
|
|
49
|
+
return WINDOWS
|
|
50
|
+
? path.join(getVenvDir(packageRoot), "Scripts", "pip.exe")
|
|
51
|
+
: path.join(getVenvDir(packageRoot), "bin", "pip");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function findPython() {
|
|
55
|
+
const preferred = process.env.QINGFLOW_MCP_PYTHON?.trim();
|
|
56
|
+
const candidates = preferred
|
|
57
|
+
? [{ command: preferred, args: [], label: preferred }]
|
|
58
|
+
: WINDOWS
|
|
59
|
+
? [
|
|
60
|
+
{ command: "py", args: ["-3", "-V"], label: "py -3" },
|
|
61
|
+
{ command: "python", args: ["-V"], label: "python" },
|
|
62
|
+
{ command: "python3", args: ["-V"], label: "python3" },
|
|
63
|
+
]
|
|
64
|
+
: [
|
|
65
|
+
{ command: "python3", args: ["-V"], label: "python3" },
|
|
66
|
+
{ command: "python", args: ["-V"], label: "python" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
if (commandWorks(candidate.command, candidate.args)) {
|
|
71
|
+
if (candidate.command === "py") {
|
|
72
|
+
return { command: "py", args: ["-3"], label: candidate.label };
|
|
73
|
+
}
|
|
74
|
+
return { command: candidate.command, args: [], label: candidate.label };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new Error(
|
|
79
|
+
"Python 3.11+ was not found. Set QINGFLOW_MCP_PYTHON to a Python 3 executable before running npm install."
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function ensurePythonEnv(packageRoot, { force = false } = {}) {
|
|
84
|
+
const python = findPython();
|
|
85
|
+
const venvDir = getVenvDir(packageRoot);
|
|
86
|
+
const venvPython = getVenvPython(packageRoot);
|
|
87
|
+
const serverCommand = getVenvServerCommand(packageRoot);
|
|
88
|
+
const stampPath = path.join(venvDir, ".bootstrap.json");
|
|
89
|
+
|
|
90
|
+
if (!force && fs.existsSync(serverCommand) && fs.existsSync(stampPath)) {
|
|
91
|
+
return serverCommand;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (force && fs.existsSync(venvDir)) {
|
|
95
|
+
fs.rmSync(venvDir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!fs.existsSync(venvPython)) {
|
|
99
|
+
runChecked(python.command, [...python.args, "-m", "venv", venvDir], { cwd: packageRoot });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const pip = getVenvPip(packageRoot);
|
|
103
|
+
runChecked(pip, ["install", "--disable-pip-version-check", "."], { cwd: packageRoot });
|
|
104
|
+
|
|
105
|
+
fs.writeFileSync(
|
|
106
|
+
stampPath,
|
|
107
|
+
JSON.stringify(
|
|
108
|
+
{
|
|
109
|
+
installed_at: new Date().toISOString(),
|
|
110
|
+
installer: "npm",
|
|
111
|
+
},
|
|
112
|
+
null,
|
|
113
|
+
2
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (!fs.existsSync(serverCommand)) {
|
|
118
|
+
throw new Error(`Bootstrap finished but ${serverCommand} was not created.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return serverCommand;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function spawnServer(packageRoot, args) {
|
|
125
|
+
const serverCommand = fs.existsSync(getVenvServerCommand(packageRoot))
|
|
126
|
+
? getVenvServerCommand(packageRoot)
|
|
127
|
+
: ensurePythonEnv(packageRoot);
|
|
128
|
+
|
|
129
|
+
const child = spawn(serverCommand, args, {
|
|
130
|
+
stdio: "inherit",
|
|
131
|
+
env: process.env,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
child.on("exit", (code, signal) => {
|
|
135
|
+
if (signal) {
|
|
136
|
+
process.kill(process.pid, signal);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
process.exit(code ?? 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
child.on("error", (error) => {
|
|
143
|
+
console.error(`[qingflow-mcp] Failed to start server: ${error.message}`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ensurePythonEnv, getPackageRoot } from "../lib/runtime.mjs";
|
|
2
|
+
|
|
3
|
+
const packageRoot = getPackageRoot(import.meta.url);
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
console.log("[qingflow-mcp] Bootstrapping Python runtime...");
|
|
7
|
+
ensurePythonEnv(packageRoot);
|
|
8
|
+
console.log("[qingflow-mcp] Python runtime is ready.");
|
|
9
|
+
} catch (error) {
|
|
10
|
+
console.error(`[qingflow-mcp] postinstall failed: ${error.message}`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@josephyan/qingflow-mcp",
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
4
|
+
"description": "Local-agent installer wrapper for the Qingflow MCP server",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"qingflow-mcp": "./npm/bin/qingflow-mcp.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"postinstall": "node ./npm/scripts/postinstall.mjs",
|
|
12
|
+
"pack:npm": "node -e \"require('fs').mkdirSync('dist/npm',{recursive:true})\" && npm pack --pack-destination dist/npm"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"README.md",
|
|
16
|
+
"pyproject.toml",
|
|
17
|
+
"entry_point.py",
|
|
18
|
+
"src/qingflow_mcp/**/*.py",
|
|
19
|
+
"src/qingflow_mcp/py.typed",
|
|
20
|
+
"qingflow-mcp",
|
|
21
|
+
"docs/local-agent-install.md",
|
|
22
|
+
"npm/"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=16.16.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"qingflow",
|
|
30
|
+
"agent",
|
|
31
|
+
"stdio",
|
|
32
|
+
"installer"
|
|
33
|
+
]
|
|
34
|
+
}
|
package/pyproject.toml
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qingflow-mcp"
|
|
7
|
+
version = "0.1.0b2"
|
|
8
|
+
description = "User-authenticated MCP server for Qingflow"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Qingflow", email = "support@qingflow.com" }
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mcp", "qingflow", "automation", "workflow"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"mcp>=1.9.4,<2.0.0",
|
|
27
|
+
"httpx>=0.27,<1.0",
|
|
28
|
+
"keyring>=25.5,<26.0",
|
|
29
|
+
"pydantic>=2.8,<3.0",
|
|
30
|
+
"pycryptodome>=3.20,<4.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.3,<9.0",
|
|
36
|
+
"respx>=0.22,<1.0",
|
|
37
|
+
]
|
|
38
|
+
build = [
|
|
39
|
+
"pyinstaller>=6.0,<7.0",
|
|
40
|
+
"build>=1.0,<2.0",
|
|
41
|
+
"twine>=5.0,<6.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
qingflow-mcp = "qingflow_mcp.server:main"
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/qingflow/qingflow-mcp"
|
|
49
|
+
Documentation = "https://github.com/qingflow/qingflow-mcp#readme"
|
|
50
|
+
Repository = "https://github.com/qingflow/qingflow-mcp"
|
|
51
|
+
Issues = "https://github.com/qingflow/qingflow-mcp/issues"
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-dir]
|
|
54
|
+
"" = "src"
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.packages.find]
|
|
57
|
+
where = ["src"]
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.package-data]
|
|
60
|
+
qingflow_mcp = ["py.typed"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
package/qingflow-mcp
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
4
|
+
if [[ -n "${PYTHON_BIN:-}" ]]; then
|
|
5
|
+
PYTHON="${PYTHON_BIN}"
|
|
6
|
+
elif [[ -x "$ROOT_DIR/.venv/bin/python" ]]; then
|
|
7
|
+
PYTHON="$ROOT_DIR/.venv/bin/python"
|
|
8
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
9
|
+
PYTHON="$(command -v python3)"
|
|
10
|
+
else
|
|
11
|
+
echo "qingflow-mcp: python interpreter not found. Set PYTHON_BIN or create $ROOT_DIR/.venv" >&2
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
export PYTHONPATH="$ROOT_DIR/src${PYTHONPATH:+:$PYTHONPATH}"
|
|
15
|
+
exec "$PYTHON" -m qingflow_mcp.server "$@"
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .config import DEFAULT_USER_AGENT, get_default_qf_version, get_timeout_seconds, normalize_base_url
|
|
9
|
+
from .errors import QingflowApiError
|
|
10
|
+
from .json_types import JSONObject, JSONScalar, JSONValue
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class BackendRequestContext:
|
|
15
|
+
base_url: str
|
|
16
|
+
token: str
|
|
17
|
+
ws_id: int | None
|
|
18
|
+
qf_request_id: str | None = None
|
|
19
|
+
qf_version: str | None = None
|
|
20
|
+
qf_version_source: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class BackendResponse:
|
|
25
|
+
data: JSONValue
|
|
26
|
+
headers: dict[str, str]
|
|
27
|
+
request_id: str
|
|
28
|
+
http_status: int
|
|
29
|
+
qf_response_version: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BackendClient:
|
|
33
|
+
def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
|
|
34
|
+
self._owns_client = client is None
|
|
35
|
+
self._default_qf_version = get_default_qf_version()
|
|
36
|
+
self._client = client or httpx.Client(
|
|
37
|
+
timeout=timeout or get_timeout_seconds(),
|
|
38
|
+
follow_redirects=True,
|
|
39
|
+
trust_env=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
if self._owns_client:
|
|
44
|
+
self._client.close()
|
|
45
|
+
|
|
46
|
+
def public_request(
|
|
47
|
+
self,
|
|
48
|
+
method: str,
|
|
49
|
+
base_url: str,
|
|
50
|
+
path: str,
|
|
51
|
+
*,
|
|
52
|
+
params: JSONObject | None = None,
|
|
53
|
+
json_body: JSONValue = None,
|
|
54
|
+
unwrap: bool = True,
|
|
55
|
+
qf_version: str | None = None,
|
|
56
|
+
) -> JSONValue:
|
|
57
|
+
return self.public_request_with_meta(
|
|
58
|
+
method,
|
|
59
|
+
base_url,
|
|
60
|
+
path,
|
|
61
|
+
params=params,
|
|
62
|
+
json_body=json_body,
|
|
63
|
+
unwrap=unwrap,
|
|
64
|
+
qf_version=qf_version,
|
|
65
|
+
).data
|
|
66
|
+
|
|
67
|
+
def public_request_with_meta(
|
|
68
|
+
self,
|
|
69
|
+
method: str,
|
|
70
|
+
base_url: str,
|
|
71
|
+
path: str,
|
|
72
|
+
*,
|
|
73
|
+
params: JSONObject | None = None,
|
|
74
|
+
json_body: JSONValue = None,
|
|
75
|
+
unwrap: bool = True,
|
|
76
|
+
qf_version: str | None = None,
|
|
77
|
+
) -> BackendResponse:
|
|
78
|
+
return self._request_with_meta(
|
|
79
|
+
method,
|
|
80
|
+
self._build_url(base_url, path),
|
|
81
|
+
params=params,
|
|
82
|
+
json_body=json_body,
|
|
83
|
+
headers=self._base_headers(None, None, qf_version=qf_version),
|
|
84
|
+
unwrap=unwrap,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def request(
|
|
88
|
+
self,
|
|
89
|
+
method: str,
|
|
90
|
+
context: BackendRequestContext,
|
|
91
|
+
path: str,
|
|
92
|
+
*,
|
|
93
|
+
params: JSONObject | None = None,
|
|
94
|
+
json_body: JSONValue = None,
|
|
95
|
+
unwrap: bool = True,
|
|
96
|
+
) -> JSONValue:
|
|
97
|
+
return self.request_with_meta(
|
|
98
|
+
method,
|
|
99
|
+
context,
|
|
100
|
+
path,
|
|
101
|
+
params=params,
|
|
102
|
+
json_body=json_body,
|
|
103
|
+
unwrap=unwrap,
|
|
104
|
+
).data
|
|
105
|
+
|
|
106
|
+
def request_with_meta(
|
|
107
|
+
self,
|
|
108
|
+
method: str,
|
|
109
|
+
context: BackendRequestContext,
|
|
110
|
+
path: str,
|
|
111
|
+
*,
|
|
112
|
+
params: JSONObject | None = None,
|
|
113
|
+
json_body: JSONValue = None,
|
|
114
|
+
unwrap: bool = True,
|
|
115
|
+
) -> BackendResponse:
|
|
116
|
+
return self._request_with_meta(
|
|
117
|
+
method,
|
|
118
|
+
self._build_url(context.base_url, path),
|
|
119
|
+
params=params,
|
|
120
|
+
json_body=json_body,
|
|
121
|
+
headers=self._base_headers(
|
|
122
|
+
context.token,
|
|
123
|
+
context.ws_id,
|
|
124
|
+
context.qf_request_id,
|
|
125
|
+
qf_version=context.qf_version,
|
|
126
|
+
),
|
|
127
|
+
unwrap=unwrap,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def describe_route(self, context: BackendRequestContext) -> JSONObject:
|
|
131
|
+
qf_version, source = self._resolve_qf_version(context.qf_version)
|
|
132
|
+
if context.qf_version is not None and context.qf_version_source:
|
|
133
|
+
source = context.qf_version_source
|
|
134
|
+
return {
|
|
135
|
+
"base_url": normalize_base_url(context.base_url) or context.base_url,
|
|
136
|
+
"qf_version": qf_version,
|
|
137
|
+
"qf_version_source": source,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def upload_binary(
|
|
141
|
+
self,
|
|
142
|
+
url: str,
|
|
143
|
+
content: bytes,
|
|
144
|
+
*,
|
|
145
|
+
content_type: str | None = None,
|
|
146
|
+
headers: dict[str, str] | None = None,
|
|
147
|
+
) -> JSONObject:
|
|
148
|
+
request_headers = dict(headers or {})
|
|
149
|
+
if content_type:
|
|
150
|
+
request_headers.setdefault("Content-Type", content_type)
|
|
151
|
+
try:
|
|
152
|
+
response = self._client.put(url, content=content, headers=request_headers or None)
|
|
153
|
+
except httpx.RequestError as exc:
|
|
154
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
155
|
+
if response.status_code >= 400:
|
|
156
|
+
raise QingflowApiError(
|
|
157
|
+
category="http",
|
|
158
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
159
|
+
http_status=response.status_code,
|
|
160
|
+
)
|
|
161
|
+
return {
|
|
162
|
+
"status_code": response.status_code,
|
|
163
|
+
"headers": dict(response.headers),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def upload_form_file(
|
|
167
|
+
self,
|
|
168
|
+
url: str,
|
|
169
|
+
*,
|
|
170
|
+
form_fields: dict[str, str],
|
|
171
|
+
file_field: str,
|
|
172
|
+
file_name: str,
|
|
173
|
+
content: bytes,
|
|
174
|
+
content_type: str | None = None,
|
|
175
|
+
headers: dict[str, str] | None = None,
|
|
176
|
+
) -> JSONObject:
|
|
177
|
+
try:
|
|
178
|
+
response = self._client.post(
|
|
179
|
+
url,
|
|
180
|
+
data=form_fields,
|
|
181
|
+
files={file_field: (file_name, content, content_type or "application/octet-stream")},
|
|
182
|
+
headers=headers or None,
|
|
183
|
+
)
|
|
184
|
+
except httpx.RequestError as exc:
|
|
185
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
186
|
+
if response.status_code >= 400:
|
|
187
|
+
raise QingflowApiError(
|
|
188
|
+
category="http",
|
|
189
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
190
|
+
http_status=response.status_code,
|
|
191
|
+
)
|
|
192
|
+
body: JSONValue = None
|
|
193
|
+
if response.content:
|
|
194
|
+
try:
|
|
195
|
+
body = response.json()
|
|
196
|
+
except ValueError:
|
|
197
|
+
body = response.text
|
|
198
|
+
return {
|
|
199
|
+
"status_code": response.status_code,
|
|
200
|
+
"headers": dict(response.headers),
|
|
201
|
+
"body": body,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def _request_with_meta(
|
|
205
|
+
self,
|
|
206
|
+
method: str,
|
|
207
|
+
url: str,
|
|
208
|
+
*,
|
|
209
|
+
params: JSONObject | None,
|
|
210
|
+
json_body: JSONValue,
|
|
211
|
+
headers: dict[str, str],
|
|
212
|
+
unwrap: bool,
|
|
213
|
+
) -> BackendResponse:
|
|
214
|
+
attempts = 2 if method.upper() == "GET" else 1
|
|
215
|
+
last_error: QingflowApiError | None = None
|
|
216
|
+
for _ in range(attempts):
|
|
217
|
+
try:
|
|
218
|
+
response = self._client.request(method.upper(), url, params=params, json=json_body, headers=headers)
|
|
219
|
+
parsed = self._parse_response(response, headers["Qf-Request-Id"], unwrap=unwrap)
|
|
220
|
+
return BackendResponse(
|
|
221
|
+
data=parsed,
|
|
222
|
+
headers=dict(response.headers),
|
|
223
|
+
request_id=headers["Qf-Request-Id"],
|
|
224
|
+
http_status=response.status_code,
|
|
225
|
+
qf_response_version=self._extract_response_qf_version(response.headers),
|
|
226
|
+
)
|
|
227
|
+
except httpx.RequestError as exc:
|
|
228
|
+
last_error = QingflowApiError(category="network", message=str(exc), request_id=headers["Qf-Request-Id"])
|
|
229
|
+
assert last_error is not None
|
|
230
|
+
raise last_error
|
|
231
|
+
|
|
232
|
+
def _parse_response(self, response: httpx.Response, request_id: str, *, unwrap: bool) -> JSONValue:
|
|
233
|
+
payload: JSONValue
|
|
234
|
+
try:
|
|
235
|
+
payload = response.json()
|
|
236
|
+
except ValueError:
|
|
237
|
+
payload = response.text
|
|
238
|
+
if response.status_code >= 400:
|
|
239
|
+
raise QingflowApiError(
|
|
240
|
+
category="http",
|
|
241
|
+
message=self._extract_message(payload) or f"HTTP {response.status_code}",
|
|
242
|
+
backend_code=self._extract_code(payload),
|
|
243
|
+
request_id=request_id,
|
|
244
|
+
http_status=response.status_code,
|
|
245
|
+
)
|
|
246
|
+
if not unwrap:
|
|
247
|
+
return payload
|
|
248
|
+
return self._unwrap_payload(payload, request_id, response.status_code)
|
|
249
|
+
|
|
250
|
+
def _unwrap_payload(self, payload: JSONValue, request_id: str, http_status: int) -> JSONValue:
|
|
251
|
+
if not isinstance(payload, dict):
|
|
252
|
+
return payload
|
|
253
|
+
if "success" in payload:
|
|
254
|
+
if not bool(payload.get("success")):
|
|
255
|
+
raise QingflowApiError(
|
|
256
|
+
category="backend",
|
|
257
|
+
message=self._extract_message(payload) or "Qingflow request failed",
|
|
258
|
+
backend_code=self._extract_code(payload),
|
|
259
|
+
request_id=request_id,
|
|
260
|
+
http_status=http_status,
|
|
261
|
+
)
|
|
262
|
+
return self._extract_success_data(payload)
|
|
263
|
+
code = self._extract_code(payload)
|
|
264
|
+
if code not in (None, 0, "0"):
|
|
265
|
+
raise QingflowApiError(
|
|
266
|
+
category="backend",
|
|
267
|
+
message=self._extract_message(payload) or "Qingflow request failed",
|
|
268
|
+
backend_code=code,
|
|
269
|
+
request_id=request_id,
|
|
270
|
+
http_status=http_status,
|
|
271
|
+
)
|
|
272
|
+
if code in (0, "0"):
|
|
273
|
+
return self._extract_success_data(payload)
|
|
274
|
+
return payload
|
|
275
|
+
|
|
276
|
+
def _extract_success_data(self, payload: JSONObject) -> JSONValue:
|
|
277
|
+
for key in ("result", "data", "page", "obj"):
|
|
278
|
+
if key in payload:
|
|
279
|
+
return payload[key]
|
|
280
|
+
return payload
|
|
281
|
+
|
|
282
|
+
def _extract_message(self, payload: JSONValue) -> str | None:
|
|
283
|
+
if not isinstance(payload, dict):
|
|
284
|
+
return str(payload) if payload else None
|
|
285
|
+
for key in ("message", "msg", "errMsg", "error", "detail"):
|
|
286
|
+
value = payload.get(key)
|
|
287
|
+
if value:
|
|
288
|
+
return str(value)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def _extract_code(self, payload: JSONValue) -> JSONScalar:
|
|
292
|
+
if isinstance(payload, dict):
|
|
293
|
+
return payload.get("code", payload.get("errCode"))
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
def _base_headers(
|
|
297
|
+
self,
|
|
298
|
+
token: str | None,
|
|
299
|
+
ws_id: int | None,
|
|
300
|
+
request_id: str | None = None,
|
|
301
|
+
*,
|
|
302
|
+
qf_version: str | None = None,
|
|
303
|
+
) -> dict[str, str]:
|
|
304
|
+
headers = {
|
|
305
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
306
|
+
"Qf-Request-Id": request_id or str(uuid4()),
|
|
307
|
+
}
|
|
308
|
+
resolved_qf_version, _ = self._resolve_qf_version(qf_version)
|
|
309
|
+
if resolved_qf_version:
|
|
310
|
+
headers["Cookie"] = f"qfVersion={resolved_qf_version}"
|
|
311
|
+
if token:
|
|
312
|
+
headers["token"] = token
|
|
313
|
+
if ws_id is not None:
|
|
314
|
+
headers["wsId"] = str(ws_id)
|
|
315
|
+
return headers
|
|
316
|
+
|
|
317
|
+
def _resolve_qf_version(self, explicit_qf_version: str | None) -> tuple[str | None, str]:
|
|
318
|
+
if explicit_qf_version is not None:
|
|
319
|
+
normalized = str(explicit_qf_version).strip() or None
|
|
320
|
+
return normalized, "context"
|
|
321
|
+
if self._default_qf_version:
|
|
322
|
+
return self._default_qf_version, "default_config"
|
|
323
|
+
return None, "unset"
|
|
324
|
+
|
|
325
|
+
def _extract_response_qf_version(self, headers: httpx.Headers) -> str | None:
|
|
326
|
+
value = headers.get("x-q-response-version")
|
|
327
|
+
if value is None:
|
|
328
|
+
return None
|
|
329
|
+
normalized = str(value).strip()
|
|
330
|
+
return normalized or None
|
|
331
|
+
|
|
332
|
+
def _build_url(self, base_url: str, path: str) -> str:
|
|
333
|
+
normalized = normalize_base_url(base_url)
|
|
334
|
+
if not normalized:
|
|
335
|
+
raise QingflowApiError.config_error("base_url is required")
|
|
336
|
+
return f"{normalized}/{path.lstrip('/')}"
|