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.
- hw_cloudrobo_workspace-0.2.0/PKG-INFO +20 -0
- hw_cloudrobo_workspace-0.2.0/README.md +16 -0
- hw_cloudrobo_workspace-0.2.0/pyproject.toml +34 -0
- hw_cloudrobo_workspace-0.2.0/setup.cfg +4 -0
- hw_cloudrobo_workspace-0.2.0/src/cloudrobo_workspace/__init__.py +5 -0
- hw_cloudrobo_workspace-0.2.0/src/cloudrobo_workspace/cli.py +319 -0
- hw_cloudrobo_workspace-0.2.0/src/cloudrobo_workspace/client.py +133 -0
- hw_cloudrobo_workspace-0.2.0/src/cloudrobo_workspace/config.py +32 -0
- hw_cloudrobo_workspace-0.2.0/src/hw_cloudrobo_workspace.egg-info/PKG-INFO +20 -0
- hw_cloudrobo_workspace-0.2.0/src/hw_cloudrobo_workspace.egg-info/SOURCES.txt +13 -0
- hw_cloudrobo_workspace-0.2.0/src/hw_cloudrobo_workspace.egg-info/dependency_links.txt +1 -0
- hw_cloudrobo_workspace-0.2.0/src/hw_cloudrobo_workspace.egg-info/entry_points.txt +2 -0
- hw_cloudrobo_workspace-0.2.0/src/hw_cloudrobo_workspace.egg-info/requires.txt +2 -0
- hw_cloudrobo_workspace-0.2.0/src/hw_cloudrobo_workspace.egg-info/top_level.txt +1 -0
- hw_cloudrobo_workspace-0.2.0/tests/test_workspace_client.py +593 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cloudrobo_workspace
|
|
@@ -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
|