dwf-platform-cli 0.2.0__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.
- dwf_cli/__init__.py +3 -0
- dwf_cli/__main__.py +4 -0
- dwf_cli/api/__init__.py +3 -0
- dwf_cli/api/auth.py +46 -0
- dwf_cli/api/client.py +113 -0
- dwf_cli/api/datamodel.py +458 -0
- dwf_cli/api/formmodel.py +197 -0
- dwf_cli/api/funcmodel.py +436 -0
- dwf_cli/cli/__init__.py +69 -0
- dwf_cli/cli/_common.py +152 -0
- dwf_cli/cli/auth.py +197 -0
- dwf_cli/cli/config.py +200 -0
- dwf_cli/cli/datamodel.py +2270 -0
- dwf_cli/cli/formmodel.py +1007 -0
- dwf_cli/cli/funcmodel.py +2055 -0
- dwf_cli/cli/schema.py +210 -0
- dwf_cli/core/__init__.py +0 -0
- dwf_cli/core/config.py +177 -0
- dwf_cli/core/crypto.py +22 -0
- dwf_cli/core/errors.py +63 -0
- dwf_cli/core/output.py +6 -0
- dwf_cli/core/validator.py +129 -0
- dwf_cli/mcp/__init__.py +3 -0
- dwf_cli/mcp/server.py +411 -0
- dwf_cli/schemas/__init__.py +37 -0
- dwf_cli/schemas/datamodel/attribute_bind.schema.json +21 -0
- dwf_cli/schemas/datamodel/attribute_create.schema.json +24 -0
- dwf_cli/schemas/datamodel/attribute_update.schema.json +21 -0
- dwf_cli/schemas/datamodel/create.schema.json +27 -0
- dwf_cli/schemas/datamodel/excel_confirm.schema.json +65 -0
- dwf_cli/schemas/datamodel/external_create.schema.json +47 -0
- dwf_cli/schemas/datamodel/external_update.schema.json +47 -0
- dwf_cli/schemas/datamodel/object_create.schema.json +24 -0
- dwf_cli/schemas/datamodel/object_update.schema.json +17 -0
- dwf_cli/schemas/datamodel/relation_create.schema.json +34 -0
- dwf_cli/schemas/datamodel/relation_update.schema.json +34 -0
- dwf_cli/schemas/datamodel/update.schema.json +26 -0
- dwf_cli/schemas/funcmodel/app_create.schema.json +150 -0
- dwf_cli/schemas/funcmodel/app_update.schema.json +153 -0
- dwf_cli/schemas/funcmodel/language-package_create.schema.json +15 -0
- dwf_cli/schemas/funcmodel/operations_create.schema.json +77 -0
- dwf_cli/schemas/funcmodel/operations_update.schema.json +76 -0
- dwf_platform_cli-0.2.0.dist-info/METADATA +347 -0
- dwf_platform_cli-0.2.0.dist-info/RECORD +47 -0
- dwf_platform_cli-0.2.0.dist-info/WHEEL +4 -0
- dwf_platform_cli-0.2.0.dist-info/entry_points.txt +3 -0
- dwf_platform_cli-0.2.0.dist-info/licenses/LICENSE +190 -0
dwf_cli/cli/schema.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import json as json_mod
|
|
5
|
+
import typing
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from dwf_cli.cli._common import handle_error
|
|
11
|
+
from dwf_cli.core.errors import DWFError
|
|
12
|
+
from dwf_cli.schemas import lookup
|
|
13
|
+
|
|
14
|
+
_HELP_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
help="Show command schemas for agent consumption.",
|
|
18
|
+
context_settings=_HELP_CONTEXT_SETTINGS,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_SCHEMA_VERSION = "1.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback(invoke_without_command=True)
|
|
25
|
+
def schema_cmd(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
args: Annotated[
|
|
28
|
+
list[str] | None,
|
|
29
|
+
typer.Argument(help="Command path, e.g. 'datamodel create'"),
|
|
30
|
+
] = None,
|
|
31
|
+
all_flag: Annotated[
|
|
32
|
+
bool,
|
|
33
|
+
typer.Option("--all", help="Show all command schemas"),
|
|
34
|
+
] = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
try:
|
|
37
|
+
if all_flag or (not args):
|
|
38
|
+
_output_all()
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
command_path = " ".join(args)
|
|
42
|
+
_output_single(command_path)
|
|
43
|
+
except DWFError as exc:
|
|
44
|
+
handle_error(exc)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _output_all() -> None:
|
|
48
|
+
root_app = _find_root_app()
|
|
49
|
+
if root_app is None:
|
|
50
|
+
typer.echo(json_mod.dumps({"schema_version": _SCHEMA_VERSION, "commands": []}))
|
|
51
|
+
raise typer.Exit(code=0)
|
|
52
|
+
|
|
53
|
+
commands = _flatten_commands(root_app)
|
|
54
|
+
typer.echo(
|
|
55
|
+
json_mod.dumps(
|
|
56
|
+
{"schema_version": _SCHEMA_VERSION, "commands": commands},
|
|
57
|
+
ensure_ascii=False,
|
|
58
|
+
indent=2,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
raise typer.Exit(code=0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _output_single(command_path: str) -> None:
|
|
65
|
+
root_app = _find_root_app()
|
|
66
|
+
if root_app is None:
|
|
67
|
+
raise DWFError(f"Command not found: {command_path}")
|
|
68
|
+
|
|
69
|
+
info = _find_command(root_app, command_path)
|
|
70
|
+
if info is None:
|
|
71
|
+
raise DWFError(
|
|
72
|
+
f"Command not found: {command_path}. "
|
|
73
|
+
"Run 'dwf-cli schema --all' to see all available commands",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
typer.echo(json_mod.dumps(info, ensure_ascii=False, indent=2))
|
|
77
|
+
raise typer.Exit(code=0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _find_root_app() -> typer.Typer | None:
|
|
81
|
+
import dwf_cli.cli as cli_mod
|
|
82
|
+
|
|
83
|
+
return getattr(cli_mod, "app", None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _flatten_commands(typer_app: typer.Typer, prefix: str = "") -> list[dict[str, Any]]:
|
|
87
|
+
results: list[dict[str, Any]] = []
|
|
88
|
+
|
|
89
|
+
for cmd in typer_app.registered_commands:
|
|
90
|
+
cmd_name = cmd.name or cmd.callback.__name__ if cmd.callback else None
|
|
91
|
+
if cmd_name is None:
|
|
92
|
+
continue
|
|
93
|
+
full_path = f"{prefix} {cmd_name}".strip() if prefix else cmd_name
|
|
94
|
+
info = _build_command_info(cmd, full_path)
|
|
95
|
+
results.append(info)
|
|
96
|
+
|
|
97
|
+
for group in typer_app.registered_groups:
|
|
98
|
+
group_name = group.name
|
|
99
|
+
if group_name is None:
|
|
100
|
+
continue
|
|
101
|
+
sub_app = group.typer_instance
|
|
102
|
+
if sub_app is not None:
|
|
103
|
+
group_prefix = f"{prefix} {group_name}".strip() if prefix else group_name
|
|
104
|
+
results.extend(_flatten_commands(sub_app, prefix=group_prefix))
|
|
105
|
+
|
|
106
|
+
return results
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _find_command(
|
|
110
|
+
typer_app: typer.Typer,
|
|
111
|
+
command_path: str,
|
|
112
|
+
full_path: str = "",
|
|
113
|
+
) -> dict[str, Any] | None:
|
|
114
|
+
parts = command_path.split()
|
|
115
|
+
resolved = full_path or command_path
|
|
116
|
+
|
|
117
|
+
if len(parts) == 1:
|
|
118
|
+
for cmd in typer_app.registered_commands:
|
|
119
|
+
cmd_name = cmd.name or cmd.callback.__name__ if cmd.callback else None
|
|
120
|
+
if cmd_name == parts[0]:
|
|
121
|
+
return _build_command_info(cmd, resolved)
|
|
122
|
+
|
|
123
|
+
for group in typer_app.registered_groups:
|
|
124
|
+
group_name = group.name
|
|
125
|
+
if group_name is None or group_name == "schema":
|
|
126
|
+
continue
|
|
127
|
+
if parts[0] == group_name:
|
|
128
|
+
sub_app = group.typer_instance
|
|
129
|
+
if sub_app is not None:
|
|
130
|
+
remaining = " ".join(parts[1:])
|
|
131
|
+
return _find_command(sub_app, remaining, full_path=resolved)
|
|
132
|
+
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_command_info(
|
|
137
|
+
cmd: typer.models.CommandInfo,
|
|
138
|
+
full_path: str,
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
info: dict[str, Any] = {"command": full_path}
|
|
141
|
+
|
|
142
|
+
if cmd.help:
|
|
143
|
+
info["help"] = cmd.help
|
|
144
|
+
|
|
145
|
+
params = _extract_params(cmd)
|
|
146
|
+
if params:
|
|
147
|
+
info["params"] = params
|
|
148
|
+
|
|
149
|
+
body = lookup(full_path)
|
|
150
|
+
if body:
|
|
151
|
+
info["body"] = body
|
|
152
|
+
|
|
153
|
+
return info
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _extract_params(cmd: typer.models.CommandInfo) -> list[dict[str, Any]]:
|
|
157
|
+
params: list[dict[str, Any]] = []
|
|
158
|
+
if cmd.callback is None:
|
|
159
|
+
return params
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
hints = typing.get_type_hints(cmd.callback, include_extras=True)
|
|
163
|
+
except Exception:
|
|
164
|
+
hints = {}
|
|
165
|
+
sig = inspect.signature(cmd.callback)
|
|
166
|
+
|
|
167
|
+
for pname, p in sig.parameters.items():
|
|
168
|
+
hint = hints.get(pname)
|
|
169
|
+
if hint is None or not hasattr(hint, "__metadata__"):
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
for meta in hint.__metadata__:
|
|
173
|
+
if isinstance(meta, typer.models.OptionInfo):
|
|
174
|
+
pi: dict[str, Any] = {"name": pname, "kind": "option"}
|
|
175
|
+
if meta.param_decls:
|
|
176
|
+
long_names = [d for d in meta.param_decls if d.startswith("--")]
|
|
177
|
+
short_names = [
|
|
178
|
+
d
|
|
179
|
+
for d in meta.param_decls
|
|
180
|
+
if d.startswith("-") and not d.startswith("--")
|
|
181
|
+
]
|
|
182
|
+
if long_names:
|
|
183
|
+
pi["option"] = long_names[0]
|
|
184
|
+
elif meta.param_decls:
|
|
185
|
+
pi["option"] = meta.param_decls[0]
|
|
186
|
+
if short_names:
|
|
187
|
+
pi["short"] = short_names[0]
|
|
188
|
+
pi["help"] = meta.help or ""
|
|
189
|
+
has_default = p.default is not inspect.Parameter.empty
|
|
190
|
+
pi["required"] = not has_default
|
|
191
|
+
if has_default:
|
|
192
|
+
pi["default"] = p.default
|
|
193
|
+
args = getattr(hint, "__args__", None)
|
|
194
|
+
if args:
|
|
195
|
+
base = args[0]
|
|
196
|
+
if hasattr(base, "__members__"):
|
|
197
|
+
pi["enum"] = list(base.__members__.keys())
|
|
198
|
+
params.append(pi)
|
|
199
|
+
break
|
|
200
|
+
if isinstance(meta, typer.models.ArgumentInfo):
|
|
201
|
+
pi = {
|
|
202
|
+
"name": pname,
|
|
203
|
+
"kind": "argument",
|
|
204
|
+
"required": True,
|
|
205
|
+
"help": meta.help or "",
|
|
206
|
+
}
|
|
207
|
+
params.append(pi)
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
return params
|
dwf_cli/core/__init__.py
ADDED
|
File without changes
|
dwf_cli/core/config.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from dwf_cli.core.crypto import DEFAULT_AES_KEY, DEFAULT_AES_IV
|
|
12
|
+
from dwf_cli.core.errors import ConfigError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_config_js(content: str, source_url: str | None = None) -> dict[str, str]:
|
|
16
|
+
result: dict[str, str] = {}
|
|
17
|
+
|
|
18
|
+
_FIELD_MAP: dict[str, str] = {
|
|
19
|
+
"modelerApiBase": "server",
|
|
20
|
+
"appApiBase": "app_server",
|
|
21
|
+
"aesKeyB": "aes_key",
|
|
22
|
+
"aesIv": "aes_iv",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
source_parts = None
|
|
26
|
+
if source_url:
|
|
27
|
+
parsed = urlparse(source_url)
|
|
28
|
+
source_parts = {
|
|
29
|
+
"protocol": parsed.scheme,
|
|
30
|
+
"ip": parsed.hostname or "",
|
|
31
|
+
"port": str(parsed.port or ""),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for js_key, config_key in _FIELD_MAP.items():
|
|
35
|
+
pattern = rf"{js_key}\s*:\s*`([^`]*)`"
|
|
36
|
+
m = re.search(pattern, content)
|
|
37
|
+
if not m:
|
|
38
|
+
pattern2 = rf"{js_key}\s*:\s*'([^']*)'"
|
|
39
|
+
m = re.search(pattern2, content)
|
|
40
|
+
if not m:
|
|
41
|
+
continue
|
|
42
|
+
raw = m.group(1)
|
|
43
|
+
if source_parts:
|
|
44
|
+
proto = source_parts["protocol"]
|
|
45
|
+
raw = raw.replace("${protocol}", f"{proto}:")
|
|
46
|
+
raw = raw.replace("${ip}", source_parts["ip"])
|
|
47
|
+
raw = raw.replace("${port}", source_parts["port"])
|
|
48
|
+
if raw:
|
|
49
|
+
result[config_key] = raw
|
|
50
|
+
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_config_js_url(raw_input: str) -> str:
|
|
55
|
+
raw = raw_input.strip()
|
|
56
|
+
if (
|
|
57
|
+
re.match(r"^[A-Za-z]:\\", raw)
|
|
58
|
+
or raw.startswith("/")
|
|
59
|
+
or raw.startswith("./")
|
|
60
|
+
or raw.startswith("..\\")
|
|
61
|
+
or (not raw.startswith(("http://", "https://")) and Path(raw).exists())
|
|
62
|
+
):
|
|
63
|
+
return raw
|
|
64
|
+
if not raw.startswith(("http://", "https://")):
|
|
65
|
+
raw = "http://" + raw
|
|
66
|
+
if raw.endswith("/config.js"):
|
|
67
|
+
return raw
|
|
68
|
+
for suffix in ("/modeler-api", "/app-api", "/monitor-api"):
|
|
69
|
+
if raw.endswith(suffix):
|
|
70
|
+
return raw[: -len(suffix)] + "/config.js"
|
|
71
|
+
if re.search(r"/modeler-web/?$", raw):
|
|
72
|
+
return raw.rstrip("/") + "/config.js"
|
|
73
|
+
return raw.rstrip("/") + "/modeler-web/config.js"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Config:
|
|
77
|
+
_DIR: Path = Path(typer.get_app_dir("dwf-cli"))
|
|
78
|
+
_FILE: Path = _DIR / "config.json"
|
|
79
|
+
|
|
80
|
+
_URL_KEYS = {"server", "app_server"}
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
self._data: dict[str, object] = {}
|
|
84
|
+
self._DIR.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
if self._FILE.exists():
|
|
86
|
+
try:
|
|
87
|
+
self._data = json.loads(self._FILE.read_text(encoding="utf-8"))
|
|
88
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
89
|
+
raise ConfigError(f"Failed to read config: {exc}") from exc
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def server(self) -> str | None:
|
|
93
|
+
value = self._data.get("server")
|
|
94
|
+
return str(value) if value is not None else None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def app_server(self) -> str | None:
|
|
98
|
+
if "app_server" in self._data:
|
|
99
|
+
return str(self._data["app_server"])
|
|
100
|
+
return self.server
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def token(self) -> str | None:
|
|
104
|
+
value = self._data.get("token")
|
|
105
|
+
return str(value) if value is not None else None
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def aes_key(self) -> str:
|
|
109
|
+
return str(self._data.get("aes_key", DEFAULT_AES_KEY))
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def aes_iv(self) -> str:
|
|
113
|
+
return str(self._data.get("aes_iv", DEFAULT_AES_IV))
|
|
114
|
+
|
|
115
|
+
def save_token(self, token: str) -> None:
|
|
116
|
+
self._data["token"] = token
|
|
117
|
+
self._save()
|
|
118
|
+
|
|
119
|
+
def clear_token(self) -> None:
|
|
120
|
+
self._data.pop("token", None)
|
|
121
|
+
self._save()
|
|
122
|
+
|
|
123
|
+
_BLOCKED_PROTOCOLS = ("javascript:", "file:", "data:", "vbscript:")
|
|
124
|
+
|
|
125
|
+
def set(self, key: str, value: str) -> None:
|
|
126
|
+
if key in self._URL_KEYS:
|
|
127
|
+
self._validate_server_url(value)
|
|
128
|
+
self._data[key] = value
|
|
129
|
+
self._save()
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def _validate_server_url(cls, url: str) -> None:
|
|
133
|
+
lower = url.strip().lower()
|
|
134
|
+
for proto in cls._BLOCKED_PROTOCOLS:
|
|
135
|
+
if lower.startswith(proto):
|
|
136
|
+
raise ConfigError(
|
|
137
|
+
f"Blocked protocol '{proto.rstrip(':')}' in server URL",
|
|
138
|
+
detail=f"server={url}",
|
|
139
|
+
)
|
|
140
|
+
if "://" in lower and not lower.startswith(("http://", "https://")):
|
|
141
|
+
raise ConfigError(
|
|
142
|
+
"Only http/https protocols allowed for server URL",
|
|
143
|
+
detail=f"server={url}",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def get(self, key: str) -> str | None:
|
|
147
|
+
value = self._data.get(key)
|
|
148
|
+
return str(value) if value is not None else None
|
|
149
|
+
|
|
150
|
+
def bulk_set(self, items: dict[str, str]) -> list[str]:
|
|
151
|
+
updated: list[str] = []
|
|
152
|
+
for key, value in items.items():
|
|
153
|
+
if key in self._URL_KEYS:
|
|
154
|
+
self._validate_server_url(value)
|
|
155
|
+
self._data[key] = value
|
|
156
|
+
updated.append(key)
|
|
157
|
+
if updated:
|
|
158
|
+
self._save()
|
|
159
|
+
return updated
|
|
160
|
+
|
|
161
|
+
def list_all(self) -> dict[str, Any]:
|
|
162
|
+
safe: dict[str, Any] = {}
|
|
163
|
+
for k, v in self._data.items():
|
|
164
|
+
if k == "token":
|
|
165
|
+
safe[k] = "***"
|
|
166
|
+
else:
|
|
167
|
+
safe[k] = v
|
|
168
|
+
return safe
|
|
169
|
+
|
|
170
|
+
def _save(self) -> None:
|
|
171
|
+
try:
|
|
172
|
+
self._FILE.write_text(
|
|
173
|
+
json.dumps(self._data, indent=2, ensure_ascii=False),
|
|
174
|
+
encoding="utf-8",
|
|
175
|
+
)
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
raise ConfigError(f"Failed to write config: {exc}") from exc
|
dwf_cli/core/crypto.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import random
|
|
5
|
+
import string
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import AES
|
|
8
|
+
from Crypto.Util.Padding import pad
|
|
9
|
+
|
|
10
|
+
DEFAULT_AES_KEY = "vndKmU#KbOxpifI0"
|
|
11
|
+
DEFAULT_AES_IV = "KPXUGHNBRWLYKRTE"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def encrypt_password(raw_password: str, key: str, iv: str) -> str:
|
|
15
|
+
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
|
|
16
|
+
padded = pad(raw_password.encode("utf-8"), AES.block_size)
|
|
17
|
+
ciphertext = cipher.encrypt(padded)
|
|
18
|
+
b64_once = base64.b64encode(ciphertext).decode("utf-8")
|
|
19
|
+
b64_twice = base64.b64encode(b64_once.encode("utf-8")).decode("utf-8")
|
|
20
|
+
prefix = "".join(random.choices(string.ascii_letters + string.digits, k=3))
|
|
21
|
+
suffix = "".join(random.choices(string.ascii_letters + string.digits, k=5))
|
|
22
|
+
return f"{prefix}{b64_twice}{suffix}"
|
dwf_cli/core/errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
_RETRYABLE_STATUS = {408, 429, 502, 503, 504}
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExitCode:
|
|
5
|
+
SUCCESS = 0
|
|
6
|
+
GENERAL = 1
|
|
7
|
+
AUTH = 2
|
|
8
|
+
NOT_FOUND = 3
|
|
9
|
+
API = 4
|
|
10
|
+
CONFLICT = 5
|
|
11
|
+
CONFIG = 6
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DWFError(Exception):
|
|
15
|
+
exit_code: int = ExitCode.GENERAL
|
|
16
|
+
retryable: bool = False
|
|
17
|
+
suggestion: str | None = None
|
|
18
|
+
|
|
19
|
+
def __init__(self, message: str, *, detail: str | None = None) -> None:
|
|
20
|
+
self.detail = detail
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthError(DWFError):
|
|
25
|
+
exit_code = ExitCode.AUTH
|
|
26
|
+
retryable = False
|
|
27
|
+
suggestion = "检查登录状态,运行 dwf-cli auth login"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotFoundError(DWFError):
|
|
31
|
+
exit_code = ExitCode.NOT_FOUND
|
|
32
|
+
retryable = False
|
|
33
|
+
suggestion = "确认资源名称或 OID 是否正确"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ConflictError(DWFError):
|
|
37
|
+
exit_code = ExitCode.CONFLICT
|
|
38
|
+
retryable = False
|
|
39
|
+
suggestion = "资源已存在,使用 --if-not-exists 跳过"
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, *, detail: str | None = None) -> None:
|
|
42
|
+
self.detail = detail
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class APIError(DWFError):
|
|
47
|
+
exit_code = ExitCode.API
|
|
48
|
+
retryable = False
|
|
49
|
+
suggestion = "检查网络连接和服务器状态"
|
|
50
|
+
|
|
51
|
+
def __init__(self, status: int, message: str) -> None:
|
|
52
|
+
self.status = status
|
|
53
|
+
self.message = message
|
|
54
|
+
if status in _RETRYABLE_STATUS:
|
|
55
|
+
self.retryable = True
|
|
56
|
+
self.suggestion = "服务器暂时不可用,建议稍后重试"
|
|
57
|
+
super().__init__(f"API error {status}: {message}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConfigError(DWFError):
|
|
61
|
+
exit_code = ExitCode.CONFIG
|
|
62
|
+
retryable = False
|
|
63
|
+
suggestion = "运行 dwf-cli config set 检查配置项"
|
dwf_cli/core/output.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_TYPE_MAP: dict[str, type | tuple[type, ...]] = {
|
|
7
|
+
"string": str,
|
|
8
|
+
"integer": (int,),
|
|
9
|
+
"boolean": (bool,),
|
|
10
|
+
"number": (int, float),
|
|
11
|
+
"array": (list,),
|
|
12
|
+
"object": (dict,),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_body(schema: dict[str, Any], data: Any) -> list[str]:
|
|
17
|
+
errors: list[str] = []
|
|
18
|
+
|
|
19
|
+
body_type = schema.get("type", "object")
|
|
20
|
+
if body_type == "array" and not isinstance(data, list):
|
|
21
|
+
errors.append(f"Expected array, got {type(data).__name__}")
|
|
22
|
+
return errors
|
|
23
|
+
if body_type == "object" and not isinstance(data, dict):
|
|
24
|
+
errors.append(f"Expected object, got {type(data).__name__}")
|
|
25
|
+
return errors
|
|
26
|
+
|
|
27
|
+
if schema.get("dynamic"):
|
|
28
|
+
return _validate_dynamic(schema, data, errors)
|
|
29
|
+
|
|
30
|
+
fields = schema.get("fields", [])
|
|
31
|
+
if not fields:
|
|
32
|
+
return errors
|
|
33
|
+
|
|
34
|
+
if body_type == "array":
|
|
35
|
+
if not data:
|
|
36
|
+
errors.append("Array must not be empty")
|
|
37
|
+
return errors
|
|
38
|
+
for i, item in enumerate(data):
|
|
39
|
+
item_errors = _validate_fields(fields, item, prefix=f"[{i}]")
|
|
40
|
+
errors.extend(item_errors)
|
|
41
|
+
else:
|
|
42
|
+
errors.extend(_validate_fields(fields, data))
|
|
43
|
+
|
|
44
|
+
return errors
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_fields(
|
|
48
|
+
fields: list[dict[str, Any]],
|
|
49
|
+
data: dict[str, Any],
|
|
50
|
+
prefix: str = "",
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
errors: list[str] = []
|
|
53
|
+
|
|
54
|
+
for field in fields:
|
|
55
|
+
name = field["name"]
|
|
56
|
+
required = field.get("required", False)
|
|
57
|
+
path = f"{prefix}.{name}" if prefix else name
|
|
58
|
+
|
|
59
|
+
if required and name not in data:
|
|
60
|
+
errors.append(f"Missing required field: {path}")
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if name not in data:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
value = data[name]
|
|
67
|
+
value_errors = _validate_field_value(field, value, path)
|
|
68
|
+
errors.extend(value_errors)
|
|
69
|
+
|
|
70
|
+
return errors
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _validate_field_value(
|
|
74
|
+
field: dict[str, Any],
|
|
75
|
+
value: Any,
|
|
76
|
+
path: str,
|
|
77
|
+
) -> list[str]:
|
|
78
|
+
errors: list[str] = []
|
|
79
|
+
|
|
80
|
+
expected_type = field.get("type")
|
|
81
|
+
if expected_type and expected_type in _TYPE_MAP:
|
|
82
|
+
py_type = _TYPE_MAP[expected_type]
|
|
83
|
+
if expected_type == "boolean" and isinstance(value, int):
|
|
84
|
+
errors.append(f"Field {path}: expected boolean, got int")
|
|
85
|
+
elif not isinstance(value, py_type):
|
|
86
|
+
errors.append(
|
|
87
|
+
f"Field {path}: expected {expected_type}, got {type(value).__name__}"
|
|
88
|
+
)
|
|
89
|
+
return errors
|
|
90
|
+
|
|
91
|
+
enum_values = field.get("enum")
|
|
92
|
+
if enum_values and value not in enum_values:
|
|
93
|
+
errors.append(f"Field {path}: value '{value}' not in enum {enum_values}")
|
|
94
|
+
|
|
95
|
+
if "item_schema" in field and isinstance(value, list):
|
|
96
|
+
item_fields = field["item_schema"].get("fields", [])
|
|
97
|
+
for i, item in enumerate(value):
|
|
98
|
+
if isinstance(item, dict):
|
|
99
|
+
item_errors = _validate_fields(item_fields, item, prefix=f"{path}[{i}]")
|
|
100
|
+
errors.extend(item_errors)
|
|
101
|
+
|
|
102
|
+
return errors
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_dynamic(
|
|
106
|
+
schema: dict[str, Any],
|
|
107
|
+
data: Any,
|
|
108
|
+
errors: list[str],
|
|
109
|
+
) -> list[str]:
|
|
110
|
+
fields = schema.get("fields", [])
|
|
111
|
+
if not fields:
|
|
112
|
+
return errors
|
|
113
|
+
|
|
114
|
+
if isinstance(data, list):
|
|
115
|
+
for i, item in enumerate(data):
|
|
116
|
+
if isinstance(item, dict):
|
|
117
|
+
for field in fields:
|
|
118
|
+
name = field["name"]
|
|
119
|
+
required = field.get("required", False)
|
|
120
|
+
if required and name not in item:
|
|
121
|
+
errors.append(f"Missing required field: [{i}].{name}")
|
|
122
|
+
elif isinstance(data, dict):
|
|
123
|
+
for field in fields:
|
|
124
|
+
name = field["name"]
|
|
125
|
+
required = field.get("required", False)
|
|
126
|
+
if required and name not in data:
|
|
127
|
+
errors.append(f"Missing required field: {name}")
|
|
128
|
+
|
|
129
|
+
return errors
|
dwf_cli/mcp/__init__.py
ADDED