hw-cloudrobo-workspace 0.2.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: hw-cloudrobo-workspace
3
+ Version: 0.2.0
4
+ Summary: CloudRobo Workspace SDK and CLI
5
+ Author-email: Huawei Cloud CloudRobo Team <hwcloudrobo@huawei.com>
6
+ License: Apache-2.0
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.8
19
+ Requires-Dist: click>=8.0
20
+ Requires-Dist: hw-cloudrobo-core>=0.1.0
@@ -0,0 +1,16 @@
1
+ # cloudrobo-workspace
2
+
3
+ 工作空间模块,提供工作空间的创建、查询、更新、删除、成员管理、概览统计及切换等功能。
4
+
5
+ ## 安装
6
+
7
+ ```bash
8
+ pip install -e packages/cloudrobo-workspace
9
+ ```
10
+
11
+ ## 文档
12
+
13
+ - [模块概览](docs/index.md)
14
+ - [CLI 命令](docs/commands.md)
15
+ - [使用示例](docs/examples.md)
16
+ - [开发指南](docs/development.md)
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools==83.0.0", "packaging==26.2", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hw-cloudrobo-workspace"
7
+ version = "0.2.0"
8
+ description = "CloudRobo Workspace SDK and CLI"
9
+ requires-python = ">=3.8"
10
+ authors = [{name = "Huawei Cloud CloudRobo Team", email = "hwcloudrobo@huawei.com"}]
11
+ license = {text = "Apache-2.0"}
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.8",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "click>=8.0",
27
+ "hw-cloudrobo-core>=0.1.0",
28
+ ]
29
+
30
+ [project.entry-points."cloudrobo.groups"]
31
+ workspace = "cloudrobo_workspace.cli:workspace"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved.
2
+
3
+ from .client import WorkspaceClient
4
+
5
+ __all__ = ["WorkspaceClient"]
@@ -0,0 +1,319 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved.
2
+
3
+ import json
4
+ import sys
5
+ import traceback
6
+ from functools import wraps
7
+
8
+ import click
9
+
10
+ from cloudrobo_core.cli.cli_utils import get_client, out
11
+ from cloudrobo_core.sdk.exceptions import (
12
+ CloudRoboError,
13
+ ResourceConflictError,
14
+ ResourceNotFoundError,
15
+ )
16
+ from .client import WorkspaceClient, WorkspaceError, is_debug_mode
17
+ from .config import load_workspace, save_workspace
18
+
19
+
20
+ def handle_error(func):
21
+ """装饰器:统一处理工作空间命令的异常"""
22
+
23
+ @wraps(func)
24
+ def wrapper(*args, **kwargs):
25
+ try:
26
+ return func(*args, **kwargs)
27
+ except WorkspaceError as e:
28
+ if is_debug_mode():
29
+ traceback.print_exc()
30
+ else:
31
+ click.echo(e.get_user_message(), err=True)
32
+ sys.exit(1)
33
+ except CloudRoboError as e:
34
+ if is_debug_mode():
35
+ traceback.print_exc()
36
+ else:
37
+ click.echo(f"错误: {e}", err=True)
38
+ sys.exit(1)
39
+ except click.exceptions.ClickException:
40
+ raise
41
+ except Exception as e:
42
+ if is_debug_mode():
43
+ traceback.print_exc()
44
+ else:
45
+ click.echo(f"执行失败: {e}", err=True)
46
+ click.echo("提示: 设置 CLOUDROBO_DEBUG=1 查看详细错误信息", err=True)
47
+ sys.exit(1)
48
+
49
+ return wrapper
50
+
51
+
52
+ def _parse_json(value, param_name):
53
+ if isinstance(value, str):
54
+ try:
55
+ return json.loads(value)
56
+ except json.JSONDecodeError as e:
57
+ raise click.BadParameter(f"{param_name} 不是合法的 JSON: {e}")
58
+ return value
59
+
60
+
61
+ @click.group()
62
+ def workspace():
63
+ """工作空间命令组"""
64
+ pass
65
+
66
+
67
+ @workspace.command("create")
68
+ @click.option("--name", required=True, help="工作空间名称")
69
+ @click.option("--description", default=None, help="工作空间描述")
70
+ @click.option("--default-obs-path", required=True, help="默认OBS路径")
71
+ @click.option("--tags", default=None, help="标签列表(逗号分隔)")
72
+ @click.option("--member-list", default=None, help="成员列表(JSON字符串)")
73
+ @click.option("--dry-run", is_flag=True)
74
+ @click.pass_context
75
+ @handle_error
76
+ def create(ctx, name, description, default_obs_path, tags, member_list, dry_run):
77
+ """创建工作空间"""
78
+ if dry_run:
79
+ click.echo(f"[DRY-RUN] create_workspace(name={name}, default_obs_path={default_obs_path})")
80
+ return
81
+ client = get_client(ctx, WorkspaceClient)
82
+ req = {"name": name, "default_obs_path": default_obs_path}
83
+ if description is not None:
84
+ req["description"] = description
85
+ if tags is not None:
86
+ req["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
87
+ if member_list is not None:
88
+ req["member_list"] = _parse_json(member_list, "--member-list")
89
+ try:
90
+ result = client.create_workspace(req)
91
+ except ResourceConflictError as e:
92
+ click.echo(f"创建工作空间失败: 名称 '{name}' 已存在", err=True)
93
+ if str(e):
94
+ click.echo(f" 服务端返回: {e}", err=True)
95
+ sys.exit(1)
96
+ ws_id = result.get("workspace_id", "") if isinstance(result, dict) else ""
97
+ if ws_id:
98
+ click.echo(f"已创建工作空间: {ws_id}")
99
+ else:
100
+ click.echo(f"已创建工作空间: {name}")
101
+
102
+
103
+ @workspace.command("list")
104
+ @click.option("--limit", type=click.IntRange(1), default=None, help="每页返回数量(>=1)")
105
+ @click.option("--offset", type=click.IntRange(0), default=None, help="偏移量(>=0)")
106
+ @click.pass_context
107
+ @handle_error
108
+ def list_ws(ctx, limit, offset):
109
+ """列出工作空间"""
110
+ client = get_client(ctx, WorkspaceClient)
111
+ params = {}
112
+ if limit is not None:
113
+ params["limit"] = limit
114
+ if offset is not None:
115
+ params["offset"] = offset
116
+ result = client.list_workspaces(**params)
117
+ out(result)
118
+
119
+
120
+ @workspace.command("show")
121
+ @click.option("--workspace-id", required=True, help="工作空间ID")
122
+ @click.pass_context
123
+ @handle_error
124
+ def show(ctx, workspace_id):
125
+ """查看工作空间详情"""
126
+ client = get_client(ctx, WorkspaceClient)
127
+ try:
128
+ result = client.show_workspace(workspace_id)
129
+ except ResourceNotFoundError:
130
+ click.echo(f"工作空间 {workspace_id} 不存在", err=True)
131
+ sys.exit(1)
132
+ out(result)
133
+
134
+
135
+ @workspace.command("update")
136
+ @click.option("--workspace-id", required=True, help="工作空间ID")
137
+ @click.option("--name", default=None, help="工作空间名称")
138
+ @click.option("--description", default=None, help="工作空间描述")
139
+ @click.option("--tags", default=None, help="标签列表(逗号分隔)")
140
+ @click.option("--owner-id", default=None, help="责任人用户ID")
141
+ @click.option("--default-obs-path", default=None, help="默认OBS路径")
142
+ @click.option("--bind-obs-policy", is_flag=True, help="仅绑定OBS桶策略,不更新其他字段")
143
+ @click.option("--dry-run", is_flag=True)
144
+ @click.pass_context
145
+ @handle_error
146
+ def update(ctx, workspace_id, name, description, tags, owner_id, default_obs_path, bind_obs_policy, dry_run):
147
+ """更新工作空间"""
148
+ if dry_run:
149
+ click.echo(f"[DRY-RUN] update_workspace(id={workspace_id})")
150
+ return
151
+ client = get_client(ctx, WorkspaceClient)
152
+ if bind_obs_policy:
153
+ req = {"bind_obs_policy": True}
154
+ else:
155
+ req = {}
156
+ if name is not None:
157
+ req["name"] = name
158
+ if description is not None:
159
+ req["description"] = description
160
+ if tags is not None:
161
+ req["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
162
+ if owner_id is not None:
163
+ req["owner_id"] = owner_id
164
+ if default_obs_path is not None:
165
+ req["default_obs_path"] = default_obs_path
166
+ if not req:
167
+ raise click.UsageError("未提供任何更新字段,请至少指定一个要更新的参数或使用 --bind-obs-policy")
168
+ try:
169
+ result = client.update_workspace(workspace_id, req)
170
+ except ResourceNotFoundError:
171
+ click.echo(f"工作空间 {workspace_id} 不存在,无法更新", err=True)
172
+ sys.exit(1)
173
+ except ResourceConflictError as e:
174
+ click.echo(f"更新工作空间失败: 名称 '{name}' 已被其他工作空间使用", err=True)
175
+ if str(e):
176
+ click.echo(f" 服务端返回: {e}", err=True)
177
+ sys.exit(1)
178
+ click.echo(f"已更新工作空间: {workspace_id}")
179
+
180
+
181
+ @workspace.command("delete")
182
+ @click.option("--workspace-id", required=True, help="工作空间ID")
183
+ @click.option("--dry-run", is_flag=True)
184
+ @click.pass_context
185
+ @handle_error
186
+ def delete(ctx, workspace_id, dry_run):
187
+ """删除工作空间"""
188
+ if dry_run:
189
+ click.echo(f"[DRY-RUN] delete_workspace(id={workspace_id})")
190
+ return
191
+ client = get_client(ctx, WorkspaceClient)
192
+ try:
193
+ result = client.delete_workspace(workspace_id)
194
+ except ResourceNotFoundError:
195
+ click.echo(f"工作空间 {workspace_id} 不存在,无法删除", err=True)
196
+ sys.exit(1)
197
+ click.echo(f"已删除工作空间: {workspace_id}")
198
+
199
+
200
+ @workspace.command("list-members")
201
+ @click.option("--workspace-id", required=True, help="工作空间ID")
202
+ @click.pass_context
203
+ @handle_error
204
+ def list_members(ctx, workspace_id):
205
+ """列出工作空间成员"""
206
+ client = get_client(ctx, WorkspaceClient)
207
+ try:
208
+ result = client.list_workspace_members(workspace_id)
209
+ except ResourceNotFoundError:
210
+ click.echo(f"工作空间 {workspace_id} 不存在", err=True)
211
+ sys.exit(1)
212
+ out(result)
213
+
214
+
215
+ @workspace.command("add-members")
216
+ @click.option("--workspace-id", required=True, help="工作空间ID")
217
+ @click.option("--member-list", required=True, help="成员列表(JSON字符串)")
218
+ @click.pass_context
219
+ @handle_error
220
+ def add_members(ctx, workspace_id, member_list):
221
+ """添加工作空间成员"""
222
+ client = get_client(ctx, WorkspaceClient)
223
+ members = _parse_json(member_list, "--member-list")
224
+ try:
225
+ result = client.add_workspace_members(workspace_id, {"member_list": members})
226
+ except ResourceNotFoundError:
227
+ click.echo(f"工作空间 {workspace_id} 不存在,无法添加成员", err=True)
228
+ sys.exit(1)
229
+ except ResourceConflictError as e:
230
+ click.echo(f"添加成员失败: 部分成员可能已存在", err=True)
231
+ if str(e):
232
+ click.echo(f" 服务端返回: {e}", err=True)
233
+ sys.exit(1)
234
+ click.echo(f"已添加 {len(members)} 个成员")
235
+
236
+
237
+ @workspace.command("update-member")
238
+ @click.option("--workspace-id", required=True, help="工作空间ID")
239
+ @click.option("--user-id", required=True, help="用户ID")
240
+ @click.option("--role-ids", required=True, help="角色ID列表(逗号分隔)")
241
+ @click.pass_context
242
+ @handle_error
243
+ def update_member(ctx, workspace_id, user_id, role_ids):
244
+ """更新工作空间成员角色"""
245
+ client = get_client(ctx, WorkspaceClient)
246
+ req = {"user_id": user_id, "role_ids": [r.strip() for r in role_ids.split(",") if r.strip()]}
247
+ try:
248
+ result = client.update_workspace_member(workspace_id, req)
249
+ except ResourceNotFoundError:
250
+ click.echo(f"工作空间 {workspace_id} 或成员 {user_id} 不存在", err=True)
251
+ sys.exit(1)
252
+ click.echo(f"已更新成员 {user_id} 的角色")
253
+
254
+
255
+ @workspace.command("delete-members")
256
+ @click.option("--workspace-id", required=True, help="工作空间ID")
257
+ @click.option("--user-ids", required=True, help="用户ID列表(逗号分隔)")
258
+ @click.pass_context
259
+ @handle_error
260
+ def delete_members(ctx, workspace_id, user_ids):
261
+ """删除工作空间成员"""
262
+ client = get_client(ctx, WorkspaceClient)
263
+ ids = [x.strip() for x in user_ids.split(",") if x.strip()]
264
+ try:
265
+ result = client.delete_workspace_members(workspace_id, ids)
266
+ except ResourceNotFoundError:
267
+ click.echo(f"工作空间 {workspace_id} 不存在,无法删除成员", err=True)
268
+ sys.exit(1)
269
+ click.echo(f"已删除 {len(ids)} 个成员")
270
+
271
+
272
+ @workspace.command("overview")
273
+ @click.pass_context
274
+ @handle_error
275
+ def overview(ctx):
276
+ """查看工作空间概览统计"""
277
+ client = get_client(ctx, WorkspaceClient)
278
+ result = client.get_workspace_overview()
279
+ out(result)
280
+
281
+
282
+ @workspace.command("use")
283
+ @click.option("--workspace-id", required=True, help="工作空间ID")
284
+ @click.pass_context
285
+ @handle_error
286
+ def use(ctx, workspace_id):
287
+ """使用指定工作空间,验证有效性并保存工作空间信息"""
288
+ client = get_client(ctx, WorkspaceClient)
289
+ try:
290
+ result = client.show_workspace(workspace_id)
291
+ except ResourceNotFoundError:
292
+ click.echo(f"工作空间 {workspace_id} 不存在", err=True)
293
+ sys.exit(1)
294
+ ws = result.get("workspace", result)
295
+ name = ws.get("name", "")
296
+ asset_catalog_id = ws.get("asset_catalog_id", "")
297
+ default_obs_path = ws.get("default_obs_path", "")
298
+
299
+ save_workspace({
300
+ "workspace_id": workspace_id,
301
+ "name": name,
302
+ "asset_catalog_id": asset_catalog_id,
303
+ "default_obs_path": default_obs_path,
304
+ })
305
+
306
+ click.echo(f"已切换到工作空间: {name} ({workspace_id})")
307
+ click.echo(f" asset_catalog_id: {asset_catalog_id}")
308
+ click.echo(f" default_obs_path: {default_obs_path}")
309
+
310
+
311
+ @workspace.command("current")
312
+ @click.pass_context
313
+ def current(ctx):
314
+ """显示当前工作空间配置"""
315
+ ws = load_workspace()
316
+ if ws:
317
+ click.echo(json.dumps(ws, ensure_ascii=False, indent=2))
318
+ else:
319
+ click.echo("未配置工作空间")
@@ -0,0 +1,133 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved.
2
+
3
+ import logging
4
+ import os
5
+ from typing import Any
6
+
7
+ from cloudrobo_core.sdk import BaseClient
8
+ from cloudrobo_core.sdk.exceptions import validate_safe_id
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def is_debug_mode() -> bool:
14
+ """检测是否处于 debug 模式"""
15
+ env_val = os.environ.get("CLOUDROBO_DEBUG", "").lower()
16
+ if env_val in ("1", "true", "yes"):
17
+ return True
18
+ return logger.isEnabledFor(logging.DEBUG)
19
+
20
+
21
+ class WorkspaceError(Exception):
22
+ """工作空间模块自定义异常"""
23
+
24
+ def __init__(self, message: str, suggestion: str = ""):
25
+ self.message = message
26
+ self.suggestion = suggestion
27
+ super().__init__(message)
28
+
29
+ def get_user_message(self) -> str:
30
+ """获取用户友好的错误消息"""
31
+ if self.suggestion:
32
+ return f"{self.message}\n\n建议: {self.suggestion}"
33
+ return self.message
34
+
35
+
36
+ class WorkspaceClient(BaseClient):
37
+ SERVICE = "cloudrobo-service"
38
+
39
+ def create_workspace(self, req: dict) -> dict:
40
+ if not isinstance(req, dict) or not req:
41
+ raise WorkspaceError("创建工作空间失败: 请求体不能为空")
42
+ name = req.get("name")
43
+ if not name or not str(name).strip():
44
+ raise WorkspaceError(
45
+ "创建工作空间失败: name 不能为空或纯空格",
46
+ "请通过 --name 指定有效的工作空间名称"
47
+ )
48
+ obs_path = req.get("default_obs_path")
49
+ if not obs_path or not str(obs_path).strip():
50
+ raise WorkspaceError(
51
+ "创建工作空间失败: default_obs_path 不能为空或纯空格",
52
+ "请通过 --default-obs-path 指定有效的OBS路径"
53
+ )
54
+ return self._client.post(self._url("/v1/workspaces"), json=req)
55
+
56
+ def list_workspaces(self, **params) -> dict:
57
+ return self._client.get(self._url("/v1/workspaces"), params=params)
58
+
59
+ def show_workspace(self, workspace_id: str) -> dict:
60
+ validate_safe_id(workspace_id, "workspace_id")
61
+ return self._client.get(self._url(f"/v1/workspaces/{workspace_id}"))
62
+
63
+ def update_workspace(self, workspace_id: str, req: dict) -> dict:
64
+ validate_safe_id(workspace_id, "workspace_id")
65
+ if not isinstance(req, dict) or not req:
66
+ raise WorkspaceError(
67
+ "更新工作空间失败: 请求体不能为空",
68
+ "请至少指定一个要更新的参数,或使用 --bind-obs-policy"
69
+ )
70
+ name = req.get("name")
71
+ if name is not None and not str(name).strip():
72
+ raise WorkspaceError(
73
+ "更新工作空间失败: name 不能为空或纯空格",
74
+ "请通过 --name 指定有效的工作空间名称"
75
+ )
76
+ return self._client.put(self._url(f"/v1/workspaces/{workspace_id}"), json=req)
77
+
78
+ def delete_workspace(self, workspace_id: str) -> Any:
79
+ validate_safe_id(workspace_id, "workspace_id")
80
+ return self._client.delete(self._url(f"/v1/workspaces/{workspace_id}"))
81
+
82
+ def add_workspace_members(self, workspace_id: str, req: dict) -> dict:
83
+ validate_safe_id(workspace_id, "workspace_id")
84
+ if not isinstance(req, dict) or not req:
85
+ raise WorkspaceError("添加成员失败: 请求体不能为空")
86
+ member_list = req.get("member_list")
87
+ if not member_list:
88
+ raise WorkspaceError(
89
+ "添加成员失败: 缺少 member_list 参数",
90
+ "请通过 --member-list 提供成员列表(JSON字符串)"
91
+ )
92
+ if not isinstance(member_list, list):
93
+ raise WorkspaceError(
94
+ "添加成员失败: member_list 必须为列表",
95
+ "请提供合法的 JSON 数组,例如: [{\"user_id\":\"u1\",\"role_ids\":[\"r1\"]}]"
96
+ )
97
+ return self._client.post(self._url(f"/v1/workspaces/{workspace_id}/members"), json=req)
98
+
99
+ def list_workspace_members(self, workspace_id: str) -> dict:
100
+ validate_safe_id(workspace_id, "workspace_id")
101
+ return self._client.get(self._url(f"/v1/workspaces/{workspace_id}/members"))
102
+
103
+ def update_workspace_member(self, workspace_id: str, req: dict) -> dict:
104
+ validate_safe_id(workspace_id, "workspace_id")
105
+ if not isinstance(req, dict) or not req:
106
+ raise WorkspaceError("更新成员失败: 请求体不能为空")
107
+ user_id = req.get("user_id")
108
+ if not user_id or not str(user_id).strip():
109
+ raise WorkspaceError(
110
+ "更新成员失败: user_id 不能为空或纯空格",
111
+ "请通过 --user-id 指定有效的用户ID"
112
+ )
113
+ if not req.get("role_ids"):
114
+ raise WorkspaceError(
115
+ "更新成员失败: 缺少 role_ids 参数",
116
+ "请通过 --role-ids 指定角色ID列表(逗号分隔)"
117
+ )
118
+ return self._client.put(self._url(f"/v1/workspaces/{workspace_id}/members"), json=req)
119
+
120
+ def delete_workspace_members(self, workspace_id: str, user_ids: list[str]) -> Any:
121
+ validate_safe_id(workspace_id, "workspace_id")
122
+ if not user_ids:
123
+ raise WorkspaceError(
124
+ "删除成员失败: user_ids 不能为空",
125
+ "请通过 --user-ids 指定要删除的用户ID列表(逗号分隔)"
126
+ )
127
+ return self._client.delete(
128
+ self._url(f"/v1/workspaces/{workspace_id}/members"),
129
+ params={"user_ids": user_ids},
130
+ )
131
+
132
+ def get_workspace_overview(self) -> dict:
133
+ return self._client.get(self._url("/v1/workspaces/statistic/overview"))
@@ -0,0 +1,32 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved.
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ WORKSPACE_CONFIG_DIR = Path.home() / ".cloudrobo"
14
+ WORKSPACE_PATH = WORKSPACE_CONFIG_DIR / "workspace.json"
15
+
16
+
17
+ def load_workspace() -> dict[str, Any]:
18
+ if WORKSPACE_PATH.exists():
19
+ try:
20
+ return json.loads(WORKSPACE_PATH.read_text(encoding="utf-8"))
21
+ except Exception as e:
22
+ logger.warning("Failed to load workspace.json: %s", e)
23
+ return {}
24
+
25
+
26
+ def save_workspace(data: dict[str, Any]) -> None:
27
+ WORKSPACE_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
28
+ WORKSPACE_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
29
+ try:
30
+ os.chmod(WORKSPACE_PATH, 0o600)
31
+ except OSError:
32
+ pass
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: hw-cloudrobo-workspace
3
+ Version: 0.2.0
4
+ Summary: CloudRobo Workspace SDK and CLI
5
+ Author-email: Huawei Cloud CloudRobo Team <hwcloudrobo@huawei.com>
6
+ License: Apache-2.0
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.8
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.8
19
+ Requires-Dist: click>=8.0
20
+ Requires-Dist: hw-cloudrobo-core>=0.1.0
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/cloudrobo_workspace/__init__.py
4
+ src/cloudrobo_workspace/cli.py
5
+ src/cloudrobo_workspace/client.py
6
+ src/cloudrobo_workspace/config.py
7
+ src/hw_cloudrobo_workspace.egg-info/PKG-INFO
8
+ src/hw_cloudrobo_workspace.egg-info/SOURCES.txt
9
+ src/hw_cloudrobo_workspace.egg-info/dependency_links.txt
10
+ src/hw_cloudrobo_workspace.egg-info/entry_points.txt
11
+ src/hw_cloudrobo_workspace.egg-info/requires.txt
12
+ src/hw_cloudrobo_workspace.egg-info/top_level.txt
13
+ tests/test_workspace_client.py
@@ -0,0 +1,2 @@
1
+ [cloudrobo.groups]
2
+ workspace = cloudrobo_workspace.cli:workspace
@@ -0,0 +1,2 @@
1
+ click>=8.0
2
+ hw-cloudrobo-core>=0.1.0
@@ -0,0 +1,593 @@
1
+ import json
2
+ import pytest
3
+ from unittest.mock import MagicMock, patch, mock_open
4
+
5
+ from cloudrobo_core.sdk import Config, HttpClient
6
+ from cloudrobo_core.sdk.exceptions import PathTraversalError
7
+ from cloudrobo_workspace.client import WorkspaceClient, WorkspaceError, is_debug_mode
8
+
9
+
10
+ def _make_mock_client():
11
+ mock = MagicMock(spec=HttpClient)
12
+ mock.config = MagicMock(spec=Config)
13
+ mock.config.get_endpoint.side_effect = lambda svc: f"https://api.example.com/{svc}"
14
+ mock.config.project_id = "proj1"
15
+ return mock
16
+
17
+
18
+ class TestWorkspaceClient:
19
+ def setup_method(self):
20
+ self.mock_http = _make_mock_client()
21
+ self.client = WorkspaceClient(self.mock_http)
22
+
23
+ def test_create_workspace(self):
24
+ self.mock_http.post.return_value = {"workspace": {"workspace_id": "ws1"}}
25
+ result = self.client.create_workspace({"name": "my-workspace", "default_obs_path": "obs://bucket/path"})
26
+ assert result["workspace"]["workspace_id"] == "ws1"
27
+ self.mock_http.post.assert_called_once()
28
+ args, kwargs = self.mock_http.post.call_args
29
+ assert args[0].endswith("/v1/workspaces")
30
+
31
+ def test_list_workspaces(self):
32
+ self.mock_http.get.return_value = {"workspaces": [], "page_info": {"total": 0}}
33
+ result = self.client.list_workspaces()
34
+ assert "workspaces" in result
35
+
36
+ def test_list_workspaces_with_pagination(self):
37
+ self.mock_http.get.return_value = {"workspaces": [], "page_info": {"total": 0}}
38
+ result = self.client.list_workspaces(limit=10, offset=0)
39
+ assert "workspaces" in result
40
+ args, kwargs = self.mock_http.get.call_args
41
+ assert kwargs.get("params", {}).get("limit") == 10
42
+ assert kwargs.get("params", {}).get("offset") == 0
43
+
44
+ def test_show_workspace(self):
45
+ self.mock_http.get.return_value = {"workspace": {"workspace_id": "ws1", "name": "my-workspace"}}
46
+ result = self.client.show_workspace("ws1")
47
+ assert result["workspace"]["workspace_id"] == "ws1"
48
+
49
+ def test_update_workspace(self):
50
+ self.mock_http.put.return_value = {"workspace": {"workspace_id": "ws1", "name": "updated"}}
51
+ result = self.client.update_workspace("ws1", {"name": "updated"})
52
+ assert result["workspace"]["name"] == "updated"
53
+ args, kwargs = self.mock_http.put.call_args
54
+ assert args[0].endswith("/v1/workspaces/ws1")
55
+
56
+ def test_delete_workspace(self):
57
+ self.mock_http.delete.return_value = ""
58
+ self.client.delete_workspace("ws1")
59
+ self.mock_http.delete.assert_called_once()
60
+ args, _ = self.mock_http.delete.call_args
61
+ assert args[0].endswith("/v1/workspaces/ws1")
62
+
63
+ def test_add_workspace_members(self):
64
+ self.mock_http.post.return_value = {"members": []}
65
+ result = self.client.add_workspace_members("ws1", {"member_list": [{"user_id": "u1", "role_ids": ["r1"]}]})
66
+ assert "members" in result
67
+ args, kwargs = self.mock_http.post.call_args
68
+ assert args[0].endswith("/v1/workspaces/ws1/members")
69
+
70
+ def test_list_workspace_members(self):
71
+ self.mock_http.get.return_value = {"members": []}
72
+ result = self.client.list_workspace_members("ws1")
73
+ assert "members" in result
74
+
75
+ def test_update_workspace_member(self):
76
+ self.mock_http.put.return_value = {"member": {"user_id": "u1"}}
77
+ result = self.client.update_workspace_member("ws1", {"user_id": "u1", "role_ids": ["r1"]})
78
+ assert result["member"]["user_id"] == "u1"
79
+ args, kwargs = self.mock_http.put.call_args
80
+ assert args[0].endswith("/v1/workspaces/ws1/members")
81
+
82
+ def test_delete_workspace_members(self):
83
+ self.mock_http.delete.return_value = ""
84
+ self.client.delete_workspace_members("ws1", ["u1", "u2"])
85
+ self.mock_http.delete.assert_called_once()
86
+ args, kwargs = self.mock_http.delete.call_args
87
+ assert args[0].endswith("/v1/workspaces/ws1/members")
88
+ assert kwargs.get("params", {}).get("user_ids") == ["u1", "u2"]
89
+
90
+ def test_get_workspace_overview(self):
91
+ self.mock_http.get.return_value = {
92
+ "workspace_capacity": 10,
93
+ "workspace_used": 3,
94
+ "workspace_available": 7,
95
+ "member_capacity": 100,
96
+ "member_count": 15,
97
+ }
98
+ result = self.client.get_workspace_overview()
99
+ assert result["workspace_capacity"] == 10
100
+ assert result["member_count"] == 15
101
+ args, _ = self.mock_http.get.call_args
102
+ assert args[0].endswith("/v1/workspaces/statistic/overview")
103
+
104
+ def test_update_workspace_with_bind_obs_policy(self):
105
+ self.mock_http.put.return_value = {"workspace": {"workspace_id": "ws1"}}
106
+ result = self.client.update_workspace("ws1", {"bind_obs_policy": True})
107
+ assert result["workspace"]["workspace_id"] == "ws1"
108
+ args, kwargs = self.mock_http.put.call_args
109
+ assert kwargs["json"] == {"bind_obs_policy": True}
110
+
111
+ def test_show_workspace_with_obs_status(self):
112
+ self.mock_http.get.return_value = {
113
+ "workspace": {"workspace_id": "ws1", "obs_status": "AVAILABLE"}
114
+ }
115
+ result = self.client.show_workspace("ws1")
116
+ assert result["workspace"]["obs_status"] == "AVAILABLE"
117
+
118
+ def test_list_workspaces_with_obs_status(self):
119
+ self.mock_http.get.return_value = {
120
+ "workspaces": [
121
+ {"workspace_id": "ws1", "obs_status": "AVAILABLE"},
122
+ {"workspace_id": "ws2", "obs_status": "NOT_EXIST"},
123
+ ],
124
+ "page_info": {"total": 2},
125
+ }
126
+ result = self.client.list_workspaces()
127
+ assert result["workspaces"][0]["obs_status"] == "AVAILABLE"
128
+ assert result["workspaces"][1]["obs_status"] == "NOT_EXIST"
129
+
130
+ def test_show_workspace_without_obs_status(self):
131
+ self.mock_http.get.return_value = {
132
+ "workspace": {"workspace_id": "ws1", "name": "my-workspace"}
133
+ }
134
+ result = self.client.show_workspace("ws1")
135
+ assert "obs_status" not in result["workspace"]
136
+ assert result["workspace"]["name"] == "my-workspace"
137
+
138
+
139
+ class TestWorkspaceConfig:
140
+ @patch("cloudrobo_workspace.config.WORKSPACE_PATH")
141
+ def test_load_workspace(self, mock_path):
142
+ from cloudrobo_workspace.config import load_workspace
143
+ mock_path.exists.return_value = True
144
+ mock_path.read_text.return_value = json.dumps({
145
+ "workspace_id": "ws1",
146
+ "name": "test",
147
+ "asset_catalog_id": "cat1",
148
+ "default_obs_path": "obs://bucket/path",
149
+ })
150
+ result = load_workspace()
151
+ assert result["workspace_id"] == "ws1"
152
+ assert result["name"] == "test"
153
+
154
+ @patch("cloudrobo_workspace.config.WORKSPACE_PATH")
155
+ def test_load_workspace_file_not_exists(self, mock_path):
156
+ from cloudrobo_workspace.config import load_workspace
157
+ mock_path.exists.return_value = False
158
+ result = load_workspace()
159
+ assert result == {}
160
+
161
+ @patch("cloudrobo_workspace.config.WORKSPACE_PATH")
162
+ def test_load_workspace_invalid_json(self, mock_path):
163
+ from cloudrobo_workspace.config import load_workspace
164
+ mock_path.exists.return_value = True
165
+ mock_path.read_text.return_value = "invalid json"
166
+ result = load_workspace()
167
+ assert result == {}
168
+
169
+ @patch("cloudrobo_workspace.config.WORKSPACE_PATH")
170
+ @patch("cloudrobo_workspace.config.WORKSPACE_CONFIG_DIR")
171
+ def test_save_workspace(self, mock_dir, mock_path):
172
+ import os
173
+ from cloudrobo_workspace.config import save_workspace
174
+ mock_dir.mkdir = MagicMock()
175
+ mock_path.write_text = MagicMock()
176
+ with patch("os.chmod"):
177
+ save_workspace({
178
+ "workspace_id": "ws1",
179
+ "name": "test",
180
+ "asset_catalog_id": "cat1",
181
+ "default_obs_path": "obs://bucket/path",
182
+ })
183
+ mock_dir.mkdir.assert_called_once_with(parents=True, exist_ok=True)
184
+ mock_path.write_text.assert_called_once()
185
+ written = mock_path.write_text.call_args[0][0]
186
+ parsed = json.loads(written)
187
+ assert parsed["workspace_id"] == "ws1"
188
+ assert parsed["name"] == "test"
189
+
190
+
191
+ class TestWorkspaceClientValidation:
192
+ def setup_method(self):
193
+ self.mock_http = _make_mock_client()
194
+ self.client = WorkspaceClient(self.mock_http)
195
+
196
+ def test_show_workspace_rejects_empty_id(self):
197
+ with pytest.raises(PathTraversalError, match="workspace_id"):
198
+ self.client.show_workspace("")
199
+
200
+ def test_show_workspace_rejects_none_id(self):
201
+ with pytest.raises(PathTraversalError, match="workspace_id"):
202
+ self.client.show_workspace(None)
203
+
204
+ def test_show_workspace_rejects_path_traversal(self):
205
+ for bad in ["../ws1", "ws/1", "ws\\1"]:
206
+ with pytest.raises(PathTraversalError, match="path traversal"):
207
+ self.client.show_workspace(bad)
208
+
209
+ def test_update_workspace_rejects_empty_id(self):
210
+ with pytest.raises(PathTraversalError):
211
+ self.client.update_workspace("", {"name": "x"})
212
+
213
+ def test_delete_workspace_rejects_path_traversal(self):
214
+ with pytest.raises(PathTraversalError):
215
+ self.client.delete_workspace("../etc")
216
+
217
+ def test_list_workspace_members_rejects_empty_id(self):
218
+ with pytest.raises(PathTraversalError, match="workspace_id"):
219
+ self.client.list_workspace_members("")
220
+
221
+ def test_add_workspace_members_rejects_empty_id(self):
222
+ with pytest.raises(PathTraversalError):
223
+ self.client.add_workspace_members("", {"member_list": []})
224
+
225
+ def test_add_workspace_members_rejects_empty_member_list(self):
226
+ with pytest.raises(WorkspaceError, match="member_list"):
227
+ self.client.add_workspace_members("ws1", {"foo": "bar"})
228
+
229
+ def test_add_workspace_members_rejects_none_req(self):
230
+ with pytest.raises(WorkspaceError, match="请求体不能为空"):
231
+ self.client.add_workspace_members("ws1", None)
232
+
233
+ def test_add_workspace_members_rejects_non_list_member_list(self):
234
+ with pytest.raises(WorkspaceError, match="member_list 必须为列表"):
235
+ self.client.add_workspace_members("ws1", {"member_list": "not-a-list"})
236
+
237
+ def test_update_workspace_member_rejects_empty_id(self):
238
+ with pytest.raises(PathTraversalError):
239
+ self.client.update_workspace_member("", {"user_id": "u1", "role_ids": ["r1"]})
240
+
241
+ def test_update_workspace_member_requires_user_id(self):
242
+ with pytest.raises(WorkspaceError, match="user_id"):
243
+ self.client.update_workspace_member("ws1", {"role_ids": ["r1"]})
244
+
245
+ def test_update_workspace_member_rejects_whitespace_user_id(self):
246
+ with pytest.raises(WorkspaceError, match="user_id 不能为空或纯空格"):
247
+ self.client.update_workspace_member("ws1", {"user_id": " ", "role_ids": ["r1"]})
248
+
249
+ def test_update_workspace_member_requires_role_ids(self):
250
+ with pytest.raises(WorkspaceError, match="role_ids"):
251
+ self.client.update_workspace_member("ws1", {"user_id": "u1"})
252
+
253
+ def test_delete_workspace_members_rejects_empty_id(self):
254
+ with pytest.raises(PathTraversalError):
255
+ self.client.delete_workspace_members("", ["u1"])
256
+
257
+ def test_delete_workspace_members_rejects_empty_list(self):
258
+ with pytest.raises(WorkspaceError, match="user_ids"):
259
+ self.client.delete_workspace_members("ws1", [])
260
+
261
+ def test_create_workspace_rejects_empty_req(self):
262
+ with pytest.raises(WorkspaceError, match="请求体不能为空"):
263
+ self.client.create_workspace({})
264
+
265
+ def test_create_workspace_rejects_none_req(self):
266
+ with pytest.raises(WorkspaceError, match="请求体不能为空"):
267
+ self.client.create_workspace(None)
268
+
269
+ def test_create_workspace_requires_name(self):
270
+ with pytest.raises(WorkspaceError, match="name"):
271
+ self.client.create_workspace({"default_obs_path": "obs://b/p"})
272
+
273
+ def test_create_workspace_requires_default_obs_path(self):
274
+ with pytest.raises(WorkspaceError, match="default_obs_path"):
275
+ self.client.create_workspace({"name": "n"})
276
+
277
+ def test_update_workspace_rejects_empty_req(self):
278
+ with pytest.raises(WorkspaceError, match="请求体不能为空"):
279
+ self.client.update_workspace("ws1", {})
280
+
281
+ def test_update_workspace_rejects_none_req(self):
282
+ with pytest.raises(WorkspaceError, match="请求体不能为空"):
283
+ self.client.update_workspace("ws1", None)
284
+
285
+ def test_valid_id_not_blocked(self):
286
+ self.mock_http.get.return_value = {"workspace": {"workspace_id": "ws-001"}}
287
+ result = self.client.show_workspace("ws-001")
288
+ assert result["workspace"]["workspace_id"] == "ws-001"
289
+ self.mock_http.get.assert_called_once()
290
+
291
+ def test_workspace_error_with_suggestion(self):
292
+ try:
293
+ self.client.create_workspace({"name": "n"})
294
+ except WorkspaceError as e:
295
+ assert e.suggestion
296
+ assert "建议" in e.get_user_message()
297
+ else:
298
+ pytest.fail("Should have raised WorkspaceError")
299
+
300
+ def test_is_debug_mode_importable(self):
301
+ assert callable(is_debug_mode)
302
+
303
+ def test_create_workspace_rejects_whitespace_name(self):
304
+ with pytest.raises(WorkspaceError, match="name 不能为空或纯空格"):
305
+ self.client.create_workspace({"name": " ", "default_obs_path": "obs://b/p"})
306
+
307
+ def test_create_workspace_rejects_whitespace_obs_path(self):
308
+ with pytest.raises(WorkspaceError, match="default_obs_path 不能为空或纯空格"):
309
+ self.client.create_workspace({"name": "n", "default_obs_path": " "})
310
+
311
+ def test_update_workspace_rejects_whitespace_name(self):
312
+ with pytest.raises(WorkspaceError, match="name 不能为空或纯空格"):
313
+ self.client.update_workspace("ws1", {"name": " "})
314
+
315
+
316
+ class TestWorkspaceCLI:
317
+ def _patch_get_client(self, monkeypatch, http_mock=None):
318
+ if http_mock is None:
319
+ http_mock = _make_mock_client()
320
+ client = WorkspaceClient(http_mock)
321
+
322
+ def _fake_get_client(ctx, cls):
323
+ return client
324
+
325
+ monkeypatch.setattr("cloudrobo_workspace.cli.get_client", _fake_get_client)
326
+ return client
327
+
328
+ def test_show_command_friendly_error_on_path_traversal(self, monkeypatch):
329
+ from click.testing import CliRunner
330
+ from cloudrobo_workspace.cli import workspace
331
+ self._patch_get_client(monkeypatch)
332
+ runner = CliRunner()
333
+ result = runner.invoke(workspace, ["show", "--workspace-id", "../etc"])
334
+ assert result.exit_code == 1
335
+ assert "path traversal" in result.output
336
+
337
+ def test_show_command_friendly_error_on_not_found(self, monkeypatch):
338
+ from click.testing import CliRunner
339
+ from cloudrobo_workspace.cli import workspace
340
+ from cloudrobo_core.sdk.exceptions import ResourceNotFoundError
341
+ client = self._patch_get_client(monkeypatch)
342
+ client._client.get.side_effect = ResourceNotFoundError("workspace not found")
343
+ runner = CliRunner()
344
+ result = runner.invoke(workspace, ["show", "--workspace-id", "ws-nope"])
345
+ assert result.exit_code == 1
346
+ assert "不存在" in result.output
347
+
348
+ def test_list_command_rejects_negative_limit(self, monkeypatch):
349
+ from click.testing import CliRunner
350
+ from cloudrobo_workspace.cli import workspace
351
+ self._patch_get_client(monkeypatch)
352
+ runner = CliRunner()
353
+ result = runner.invoke(workspace, ["list", "--limit", "-1"])
354
+ assert result.exit_code == 2
355
+
356
+ def test_list_command_rejects_negative_offset(self, monkeypatch):
357
+ from click.testing import CliRunner
358
+ from cloudrobo_workspace.cli import workspace
359
+ self._patch_get_client(monkeypatch)
360
+ runner = CliRunner()
361
+ result = runner.invoke(workspace, ["list", "--offset", "-5"])
362
+ assert result.exit_code == 2
363
+
364
+ def test_create_command_rejects_invalid_json(self, monkeypatch):
365
+ from click.testing import CliRunner
366
+ from cloudrobo_workspace.cli import workspace
367
+ self._patch_get_client(monkeypatch)
368
+ runner = CliRunner()
369
+ result = runner.invoke(
370
+ workspace,
371
+ ["create", "--name", "n", "--default-obs-path", "obs://b/p", "--member-list", "{bad json"],
372
+ )
373
+ assert result.exit_code == 2
374
+ assert "JSON" in result.output
375
+
376
+ def test_update_command_rejects_no_fields(self, monkeypatch):
377
+ from click.testing import CliRunner
378
+ from cloudrobo_workspace.cli import workspace
379
+ self._patch_get_client(monkeypatch)
380
+ runner = CliRunner()
381
+ result = runner.invoke(workspace, ["update", "--workspace-id", "ws1"])
382
+ assert result.exit_code == 2
383
+ assert "更新字段" in result.output
384
+
385
+ def test_use_command_friendly_error_on_not_found(self, monkeypatch):
386
+ from click.testing import CliRunner
387
+ from cloudrobo_workspace.cli import workspace
388
+ from cloudrobo_core.sdk.exceptions import ResourceNotFoundError
389
+ client = self._patch_get_client(monkeypatch)
390
+ client._client.get.side_effect = ResourceNotFoundError("not found")
391
+ runner = CliRunner()
392
+ result = runner.invoke(workspace, ["use", "--workspace-id", "ws-nope"])
393
+ assert result.exit_code == 1
394
+ assert "不存在" in result.output
395
+
396
+ def test_service_error_friendly_output(self, monkeypatch):
397
+ from click.testing import CliRunner
398
+ from cloudrobo_workspace.cli import workspace
399
+ from cloudrobo_core.sdk.exceptions import ServiceError
400
+ client = self._patch_get_client(monkeypatch)
401
+ client._client.get.side_effect = ServiceError("Server error 500: boom", status_code=500)
402
+ runner = CliRunner()
403
+ result = runner.invoke(workspace, ["show", "--workspace-id", "ws1"])
404
+ assert result.exit_code == 1
405
+ assert "错误" in result.output
406
+
407
+ def test_workspace_error_shows_suggestion(self, monkeypatch):
408
+ from click.testing import CliRunner
409
+ from cloudrobo_workspace.cli import workspace
410
+ self._patch_get_client(monkeypatch)
411
+ runner = CliRunner()
412
+ result = runner.invoke(workspace, ["add-members", "--workspace-id", "ws1", "--member-list", "[]"])
413
+ assert result.exit_code == 1
414
+ assert "建议" in result.output
415
+ assert "--member-list" in result.output
416
+
417
+ def test_create_command_rejects_whitespace_name(self, monkeypatch):
418
+ from click.testing import CliRunner
419
+ from cloudrobo_workspace.cli import workspace
420
+ self._patch_get_client(monkeypatch)
421
+ runner = CliRunner()
422
+ result = runner.invoke(workspace, ["create", "--name", " ", "--default-obs-path", "obs://test"])
423
+ assert result.exit_code == 1
424
+ assert "name 不能为空或纯空格" in result.output
425
+
426
+ def test_create_command_friendly_error_on_conflict(self, monkeypatch):
427
+ from click.testing import CliRunner
428
+ from cloudrobo_workspace.cli import workspace
429
+ from cloudrobo_core.sdk.exceptions import ResourceConflictError
430
+ client = self._patch_get_client(monkeypatch)
431
+ client._client.post.side_effect = ResourceConflictError("workspace name already exists")
432
+ runner = CliRunner()
433
+ result = runner.invoke(workspace, ["create", "--name", "dup", "--default-obs-path", "obs://b/p"])
434
+ assert result.exit_code == 1
435
+ assert "已存在" in result.output
436
+
437
+ def test_update_command_friendly_error_on_not_found(self, monkeypatch):
438
+ from click.testing import CliRunner
439
+ from cloudrobo_workspace.cli import workspace
440
+ from cloudrobo_core.sdk.exceptions import ResourceNotFoundError
441
+ client = self._patch_get_client(monkeypatch)
442
+ client._client.put.side_effect = ResourceNotFoundError("workspace not found")
443
+ runner = CliRunner()
444
+ result = runner.invoke(workspace, ["update", "--workspace-id", "ws-nope", "--name", "new"])
445
+ assert result.exit_code == 1
446
+ assert "不存在" in result.output
447
+
448
+ def test_update_command_friendly_error_on_conflict(self, monkeypatch):
449
+ from click.testing import CliRunner
450
+ from cloudrobo_workspace.cli import workspace
451
+ from cloudrobo_core.sdk.exceptions import ResourceConflictError
452
+ client = self._patch_get_client(monkeypatch)
453
+ client._client.put.side_effect = ResourceConflictError("name conflict")
454
+ runner = CliRunner()
455
+ result = runner.invoke(workspace, ["update", "--workspace-id", "ws1", "--name", "dup-name"])
456
+ assert result.exit_code == 1
457
+ assert "已被" in result.output
458
+
459
+ def test_delete_command_friendly_error_on_not_found(self, monkeypatch):
460
+ from click.testing import CliRunner
461
+ from cloudrobo_workspace.cli import workspace
462
+ from cloudrobo_core.sdk.exceptions import ResourceNotFoundError
463
+ client = self._patch_get_client(monkeypatch)
464
+ client._client.delete.side_effect = ResourceNotFoundError("not found")
465
+ runner = CliRunner()
466
+ result = runner.invoke(workspace, ["delete", "--workspace-id", "ws-nope"])
467
+ assert result.exit_code == 1
468
+ assert "不存在" in result.output
469
+
470
+ def test_list_members_command_friendly_error_on_not_found(self, monkeypatch):
471
+ from click.testing import CliRunner
472
+ from cloudrobo_workspace.cli import workspace
473
+ from cloudrobo_core.sdk.exceptions import ResourceNotFoundError
474
+ client = self._patch_get_client(monkeypatch)
475
+ client._client.get.side_effect = ResourceNotFoundError("not found")
476
+ runner = CliRunner()
477
+ result = runner.invoke(workspace, ["list-members", "--workspace-id", "ws-nope"])
478
+ assert result.exit_code == 1
479
+ assert "不存在" in result.output
480
+
481
+ def test_add_members_command_friendly_error_on_not_found(self, monkeypatch):
482
+ from click.testing import CliRunner
483
+ from cloudrobo_workspace.cli import workspace
484
+ from cloudrobo_core.sdk.exceptions import ResourceNotFoundError
485
+ client = self._patch_get_client(monkeypatch)
486
+ client._client.post.side_effect = ResourceNotFoundError("not found")
487
+ runner = CliRunner()
488
+ result = runner.invoke(workspace, ["add-members", "--workspace-id", "ws-nope", "--member-list", '[{"user_id":"u1","role_ids":["r1"]}]'])
489
+ assert result.exit_code == 1
490
+ assert "不存在" in result.output
491
+
492
+ def test_add_members_command_friendly_error_on_conflict(self, monkeypatch):
493
+ from click.testing import CliRunner
494
+ from cloudrobo_workspace.cli import workspace
495
+ from cloudrobo_core.sdk.exceptions import ResourceConflictError
496
+ client = self._patch_get_client(monkeypatch)
497
+ client._client.post.side_effect = ResourceConflictError("member already exists")
498
+ runner = CliRunner()
499
+ result = runner.invoke(workspace, ["add-members", "--workspace-id", "ws1", "--member-list", '[{"user_id":"u1","role_ids":["r1"]}]'])
500
+ assert result.exit_code == 1
501
+ assert "已存在" in result.output or "部分成员" in result.output
502
+
503
+ def test_update_member_command_friendly_error_on_not_found(self, monkeypatch):
504
+ from click.testing import CliRunner
505
+ from cloudrobo_workspace.cli import workspace
506
+ from cloudrobo_core.sdk.exceptions import ResourceNotFoundError
507
+ client = self._patch_get_client(monkeypatch)
508
+ client._client.put.side_effect = ResourceNotFoundError("not found")
509
+ runner = CliRunner()
510
+ result = runner.invoke(workspace, ["update-member", "--workspace-id", "ws-nope", "--user-id", "u1", "--role-ids", "r1"])
511
+ assert result.exit_code == 1
512
+ assert "不存在" in result.output
513
+
514
+ def test_delete_members_command_friendly_error_on_not_found(self, monkeypatch):
515
+ from click.testing import CliRunner
516
+ from cloudrobo_workspace.cli import workspace
517
+ from cloudrobo_core.sdk.exceptions import ResourceNotFoundError
518
+ client = self._patch_get_client(monkeypatch)
519
+ client._client.delete.side_effect = ResourceNotFoundError("not found")
520
+ runner = CliRunner()
521
+ result = runner.invoke(workspace, ["delete-members", "--workspace-id", "ws-nope", "--user-ids", "u1"])
522
+ assert result.exit_code == 1
523
+ assert "不存在" in result.output
524
+
525
+ def test_create_command_success_output(self, monkeypatch):
526
+ from click.testing import CliRunner
527
+ from cloudrobo_workspace.cli import workspace
528
+ client = self._patch_get_client(monkeypatch)
529
+ client._client.post.return_value = {"workspace_id": "ws-new-123"}
530
+ runner = CliRunner()
531
+ result = runner.invoke(workspace, ["create", "--name", "test-ws", "--default-obs-path", "obs://bucket/path"])
532
+ assert result.exit_code == 0
533
+ assert "已创建工作空间: ws-new-123" in result.output
534
+
535
+ def test_create_command_success_output_without_id(self, monkeypatch):
536
+ from click.testing import CliRunner
537
+ from cloudrobo_workspace.cli import workspace
538
+ client = self._patch_get_client(monkeypatch)
539
+ client._client.post.return_value = {}
540
+ runner = CliRunner()
541
+ result = runner.invoke(workspace, ["create", "--name", "test-ws", "--default-obs-path", "obs://bucket/path"])
542
+ assert result.exit_code == 0
543
+ assert "已创建工作空间: test-ws" in result.output
544
+
545
+ def test_update_command_success_output(self, monkeypatch):
546
+ from click.testing import CliRunner
547
+ from cloudrobo_workspace.cli import workspace
548
+ client = self._patch_get_client(monkeypatch)
549
+ client._client.put.return_value = {}
550
+ runner = CliRunner()
551
+ result = runner.invoke(workspace, ["update", "--workspace-id", "ws1", "--name", "new-name"])
552
+ assert result.exit_code == 0
553
+ assert "已更新工作空间: ws1" in result.output
554
+
555
+ def test_delete_command_success_output(self, monkeypatch):
556
+ from click.testing import CliRunner
557
+ from cloudrobo_workspace.cli import workspace
558
+ client = self._patch_get_client(monkeypatch)
559
+ client._client.delete.return_value = ""
560
+ runner = CliRunner()
561
+ result = runner.invoke(workspace, ["delete", "--workspace-id", "ws1"])
562
+ assert result.exit_code == 0
563
+ assert "已删除工作空间: ws1" in result.output
564
+
565
+ def test_add_members_command_success_output(self, monkeypatch):
566
+ from click.testing import CliRunner
567
+ from cloudrobo_workspace.cli import workspace
568
+ client = self._patch_get_client(monkeypatch)
569
+ client._client.post.return_value = {}
570
+ runner = CliRunner()
571
+ result = runner.invoke(workspace, ["add-members", "--workspace-id", "ws1", "--member-list", '[{"user_id":"u1","role_ids":["r1"]},{"user_id":"u2","role_ids":["r2"]}]'])
572
+ assert result.exit_code == 0
573
+ assert "已添加 2 个成员" in result.output
574
+
575
+ def test_update_member_command_success_output(self, monkeypatch):
576
+ from click.testing import CliRunner
577
+ from cloudrobo_workspace.cli import workspace
578
+ client = self._patch_get_client(monkeypatch)
579
+ client._client.put.return_value = {}
580
+ runner = CliRunner()
581
+ result = runner.invoke(workspace, ["update-member", "--workspace-id", "ws1", "--user-id", "u1", "--role-ids", "r1,r2"])
582
+ assert result.exit_code == 0
583
+ assert "已更新成员 u1 的角色" in result.output
584
+
585
+ def test_delete_members_command_success_output(self, monkeypatch):
586
+ from click.testing import CliRunner
587
+ from cloudrobo_workspace.cli import workspace
588
+ client = self._patch_get_client(monkeypatch)
589
+ client._client.delete.return_value = ""
590
+ runner = CliRunner()
591
+ result = runner.invoke(workspace, ["delete-members", "--workspace-id", "ws1", "--user-ids", "u1,u2,u3"])
592
+ assert result.exit_code == 0
593
+ assert "已删除 3 个成员" in result.output