compshare-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """CompShare command-line client."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from compshare_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,71 @@
1
+ """Public CompShare API actions covered by the first CLI release."""
2
+
3
+ INSTANCE_ACTIONS = frozenset(
4
+ {
5
+ "CheckCompShareNetOptimizer",
6
+ "CheckCompShareResourceCapacity",
7
+ "CreateCompShareInstance",
8
+ "DeleteCompShareStopScheduler",
9
+ "DescribeAvailableCompShareInstanceTypes",
10
+ "DescribeCompShareInstance",
11
+ "DescribeCompShareMachineTypeFamilies",
12
+ "DescribeCompShareSoftwarePort",
13
+ "DescribeCompShareSupportZone",
14
+ "DescribeModelRepositoryModels",
15
+ "GetCompShareInstanceMonitor",
16
+ "GetCompShareInstancePrice",
17
+ "GetCompShareInstanceUpgradePrice",
18
+ "GetCompShareInstanceUserPrice",
19
+ "GetCompShareRefundPrice",
20
+ "GetSoftwareURL",
21
+ "ModifyCompShareInstanceName",
22
+ "RebootCompShareInstance",
23
+ "ReinstallCompShareInstance",
24
+ "ResetCompShareInstancePassword",
25
+ "ResizeCompShareInstance",
26
+ "StartCompShareInstance",
27
+ "StopCompShareInstance",
28
+ "SwitchChargeType",
29
+ "TerminateCompShareInstance",
30
+ "UpdateCompShareInstancePorts",
31
+ "UpdateCompShareStopScheduler",
32
+ }
33
+ )
34
+
35
+ IMAGE_ACTIONS = frozenset(
36
+ {
37
+ "AddFavoriteImage",
38
+ "CreateCompShareCustomImage",
39
+ "DescribeCommunityImages",
40
+ "DescribeCompShareCustomImages",
41
+ "DescribeCompShareImages",
42
+ "DescribeCompShareImageShareAccounts",
43
+ "DescribeCompShareImageTags",
44
+ "DescribeCompShareSharingImages",
45
+ "DescribeSelfCommunityImages",
46
+ "DescribeUserCommunityImages",
47
+ "GetCompShareImageCreateProgress",
48
+ "ModifyCompShareImageShareAccount",
49
+ "PublishCompShareImage",
50
+ "RemoveFavoriteImage",
51
+ "TerminateCompShareCustomImage",
52
+ "UpdateCompShareImage",
53
+ }
54
+ )
55
+
56
+ STORAGE_ACTIONS = frozenset(
57
+ {
58
+ "AttachCompshareDisk",
59
+ "AttachUS3",
60
+ "CreateAndAttachCompshareDisk",
61
+ "DeleteCompshareDisk",
62
+ "DetachCompshareDisk",
63
+ "GetCompShareAttachedDiskUpgradePrice",
64
+ "ResizeCompShareDisk",
65
+ }
66
+ )
67
+
68
+ PUBLIC_ACTIONS = INSTANCE_ACTIONS | IMAGE_ACTIONS | STORAGE_ACTIONS
69
+
70
+ # Documented publicly but unavailable in the production API (RetCode 161).
71
+ UNAVAILABLE_ACTIONS = frozenset({"DescribeFavoriteImages"})
compshare_cli/api.py ADDED
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Callable, Dict, Iterable, Optional, Sequence, Tuple
5
+
6
+ import typer
7
+ from ucloud.core import exc as ucloud_exc
8
+
9
+ from compshare_cli.errors import CLIError
10
+ from compshare_cli.i18n import tr
11
+ from compshare_cli.output import Renderer
12
+ from compshare_cli.runtime import Runtime
13
+ from compshare_cli.sdk import CompShareSDK
14
+
15
+
16
+ def invoke(
17
+ runtime: Runtime,
18
+ action: str,
19
+ params: Dict[str, Any],
20
+ *,
21
+ list_key: Optional[str] = None,
22
+ row_builder: Optional[Callable[[Dict[str, Any]], Iterable[Dict[str, Any]]]] = None,
23
+ columns: Optional[Sequence[Tuple[str, str]]] = None,
24
+ success: Optional[str] = None,
25
+ ) -> Dict[str, Any]:
26
+ renderer = Renderer(runtime.json_output)
27
+ retries = 3 if action == "DeleteCompshareDisk" else 0
28
+ attempt = 0
29
+ try:
30
+ while True:
31
+ try:
32
+ response = CompShareSDK(runtime.profile, runtime.region).invoke(action, params)
33
+ break
34
+ except ucloud_exc.RetCodeException as error:
35
+ if error.code == 8434 and attempt < retries:
36
+ attempt += 1
37
+ if not runtime.json_output:
38
+ renderer.console.print(
39
+ tr(
40
+ "Resource is still detaching; retrying ({attempt}/{total})...",
41
+ attempt=attempt,
42
+ total=retries,
43
+ )
44
+ )
45
+ time.sleep(2 * attempt)
46
+ continue
47
+ raise
48
+ except CLIError as error:
49
+ renderer.error(str(error))
50
+ raise typer.Exit(1) from error
51
+ except ucloud_exc.RetCodeException as error:
52
+ hint = error_hint(error.action or action, error.code)
53
+ renderer.error(
54
+ _with_hint(error.message or str(error), hint),
55
+ details={
56
+ "action": error.action,
57
+ "ret_code": error.code,
58
+ "request_uuid": error.request_uuid,
59
+ "hint": hint,
60
+ },
61
+ )
62
+ raise typer.Exit(1) from error
63
+ except ucloud_exc.UCloudException as error:
64
+ renderer.error(str(error))
65
+ raise typer.Exit(1) from error
66
+ except Exception as error: # SDK transport exceptions are not all UCloudException subclasses.
67
+ renderer.error(str(error))
68
+ raise typer.Exit(1) from error
69
+
70
+ if success:
71
+ renderer.success(success, response)
72
+ elif list_key is None and row_builder is None:
73
+ renderer.data(response)
74
+ else:
75
+ rows = row_builder(response) if row_builder else response.get(list_key or "", [])
76
+ renderer.data(response, rows=rows, columns=columns)
77
+ return response
78
+
79
+
80
+ def call(runtime: Runtime, action: str, params: Dict[str, Any]) -> Dict[str, Any]:
81
+ """Invoke without rendering, for commands that combine multiple API calls."""
82
+ try:
83
+ return CompShareSDK(runtime.profile, runtime.region).invoke(action, params)
84
+ except CLIError as error:
85
+ Renderer(runtime.json_output).error(str(error))
86
+ raise typer.Exit(1) from error
87
+ except ucloud_exc.RetCodeException as error:
88
+ hint = error_hint(error.action or action, error.code)
89
+ Renderer(runtime.json_output).error(
90
+ _with_hint(error.message or str(error), hint),
91
+ details={
92
+ "action": error.action,
93
+ "ret_code": error.code,
94
+ "request_uuid": error.request_uuid,
95
+ "hint": hint,
96
+ },
97
+ )
98
+ raise typer.Exit(1) from error
99
+ except ucloud_exc.UCloudException as error:
100
+ Renderer(runtime.json_output).error(str(error))
101
+ raise typer.Exit(1) from error
102
+ except Exception as error:
103
+ Renderer(runtime.json_output).error(str(error))
104
+ raise typer.Exit(1) from error
105
+
106
+
107
+ def error_hint(action: str, code: int) -> Optional[str]:
108
+ hints = {
109
+ ("DeleteCompshareDisk", 8434): "The disk is still detaching. Wait a moment and retry.",
110
+ ("AttachUS3", 8433): "Confirm that US3 is enabled for the selected region and account.",
111
+ ("GetCompShareInstanceMonitor", 210): (
112
+ "This production endpoint currently rejects instance IDs; "
113
+ "use the console for monitoring."
114
+ ),
115
+ ("GetCompShareInstanceMonitor", 230): (
116
+ "This production endpoint is currently incompatible; use the console for monitoring."
117
+ ),
118
+ ("GetSoftwareURL", 230): (
119
+ "This production endpoint currently rejects its action; "
120
+ "use instance show or the console."
121
+ ),
122
+ }
123
+ return tr(hints[(action, code)]) if (action, code) in hints else None
124
+
125
+
126
+ def _with_hint(message: str, hint: Optional[str]) -> str:
127
+ return f"{message} {tr('Hint')}: {hint}" if hint else message
compshare_cli/cli.py ADDED
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import List, Optional
5
+
6
+ import click
7
+ import typer
8
+ from typer.main import get_command
9
+
10
+ from compshare_cli import __version__
11
+ from compshare_cli.commands import image, instance, storage
12
+ from compshare_cli.config import DEFAULT_PROFILE, ConfigStore, Profile
13
+ from compshare_cli.errors import CLIError
14
+ from compshare_cli.i18n import configured_language, localize_command, normalize_language, tr
15
+ from compshare_cli.output import Renderer
16
+ from compshare_cli.runtime import Runtime
17
+
18
+ app = typer.Typer(
19
+ name="compshare",
20
+ help="Manage CompShare GPU compute from the terminal.",
21
+ no_args_is_help=True,
22
+ add_completion=False,
23
+ context_settings={"help_option_names": ["-h", "--help"]},
24
+ )
25
+ app.add_typer(instance.app, name="instance")
26
+ app.add_typer(image.app, name="image")
27
+ app.add_typer(storage.app, name="storage")
28
+ config_app = typer.Typer(
29
+ help="Manage credential profiles.",
30
+ invoke_without_command=True,
31
+ no_args_is_help=False,
32
+ )
33
+ app.add_typer(config_app, name="config")
34
+
35
+
36
+ @app.callback()
37
+ def root(
38
+ ctx: typer.Context,
39
+ profile: Optional[str] = typer.Option(
40
+ None,
41
+ "--profile",
42
+ metavar="NAME",
43
+ help="Credential profile.",
44
+ ),
45
+ json_output: bool = typer.Option(
46
+ False,
47
+ "--json",
48
+ help="Emit machine-readable JSON.",
49
+ ),
50
+ ) -> None:
51
+ """CompShare CLI."""
52
+ ctx.obj = Runtime(
53
+ json_output=json_output,
54
+ profile_name=profile,
55
+ )
56
+
57
+
58
+ @config_app.callback()
59
+ def config(
60
+ ctx: typer.Context,
61
+ name: str = typer.Option(
62
+ DEFAULT_PROFILE,
63
+ "--name",
64
+ help="Credential profile name.",
65
+ ),
66
+ public_key: Optional[str] = typer.Option(None, "--public-key", help="API public key."),
67
+ private_key: Optional[str] = typer.Option(None, "--private-key", hidden=True),
68
+ activate: bool = typer.Option(
69
+ True,
70
+ "--activate/--no-activate",
71
+ help="Make this profile the default.",
72
+ ),
73
+ ) -> None:
74
+ """Save a CompShare API credential profile."""
75
+ if ctx.invoked_subcommand is not None:
76
+ return
77
+ public = public_key or typer.prompt(tr("Public key"))
78
+ private = private_key or typer.prompt(tr("Private key"), hide_input=True)
79
+ ConfigStore().save_profile(
80
+ name,
81
+ Profile(public_key=public, private_key=private),
82
+ activate=activate,
83
+ )
84
+ Renderer(ctx.find_root().obj.json_output).success(
85
+ tr("Saved credential profile {name}", name=name),
86
+ {"ok": True, "profile": name, "active": activate},
87
+ )
88
+
89
+
90
+ @config_app.command("set")
91
+ def config_set(
92
+ ctx: typer.Context,
93
+ name: str = typer.Option(DEFAULT_PROFILE, "--name", help="Credential profile name."),
94
+ public_key: Optional[str] = typer.Option(None, "--public-key", help="API public key."),
95
+ private_key: Optional[str] = typer.Option(None, "--private-key", hidden=True),
96
+ activate: bool = typer.Option(
97
+ True, "--activate/--no-activate", help="Make this profile the default."
98
+ ),
99
+ ) -> None:
100
+ """Create or update a credential profile."""
101
+ public = public_key or typer.prompt(tr("Public key"))
102
+ private = private_key or typer.prompt(tr("Private key"), hide_input=True)
103
+ ConfigStore().save_profile(
104
+ name,
105
+ Profile(public_key=public, private_key=private),
106
+ activate=activate,
107
+ )
108
+ Renderer(ctx.find_root().obj.json_output).success(
109
+ tr("Saved credential profile {name}", name=name),
110
+ {"ok": True, "profile": name, "active": activate},
111
+ )
112
+
113
+
114
+ @config_app.command("list")
115
+ def config_list(ctx: typer.Context) -> None:
116
+ """List credential profiles."""
117
+ store = ConfigStore()
118
+ current = store.current_profile()
119
+ profiles = [{"Profile": name, "Active": name == current} for name in store.list_profiles()]
120
+ Renderer(ctx.find_root().obj.json_output).data(
121
+ {"current_profile": current, "profiles": profiles},
122
+ rows=profiles,
123
+ columns=(("Profile", "PROFILE"), ("Active", "ACTIVE")),
124
+ )
125
+
126
+
127
+ @config_app.command("use")
128
+ def config_use(ctx: typer.Context, name: str) -> None:
129
+ """Set the default credential profile."""
130
+ ConfigStore().use_profile(name)
131
+ Renderer(ctx.find_root().obj.json_output).success(
132
+ tr("Using credential profile {name}", name=name),
133
+ {"ok": True, "profile": name},
134
+ )
135
+
136
+
137
+ @config_app.command("delete")
138
+ def config_delete(
139
+ ctx: typer.Context,
140
+ name: str,
141
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
142
+ ) -> None:
143
+ """Delete a credential profile."""
144
+ if not yes and not typer.confirm(tr("Delete credential profile {name}?", name=name)):
145
+ raise typer.Abort()
146
+ ConfigStore().delete_profile(name)
147
+ Renderer(ctx.find_root().obj.json_output).success(
148
+ tr("Deleted credential profile {name}", name=name),
149
+ {"ok": True, "profile": name},
150
+ )
151
+
152
+
153
+ @config_app.command("path")
154
+ def config_path_command(ctx: typer.Context) -> None:
155
+ """Show the configuration file path."""
156
+ path = str(ConfigStore().path)
157
+ if ctx.find_root().obj.json_output:
158
+ Renderer(True).data({"path": path})
159
+ else:
160
+ typer.echo(path)
161
+
162
+
163
+ @app.command("version")
164
+ def version(ctx: typer.Context) -> None:
165
+ """Print the CLI version."""
166
+ if ctx.find_root().obj.json_output:
167
+ Renderer(True).data({"version": __version__})
168
+ else:
169
+ typer.echo(__version__)
170
+
171
+
172
+ @app.command("lang")
173
+ def lang(
174
+ ctx: typer.Context,
175
+ language: Optional[str] = typer.Argument(None, help="Language: zh or en."),
176
+ ) -> None:
177
+ """Set or show the default help language."""
178
+ renderer = Renderer(ctx.find_root().obj.json_output)
179
+ if language is None:
180
+ current = configured_language()
181
+ message = (
182
+ f"当前默认帮助语言:{current}"
183
+ if current == "zh"
184
+ else f"Default help language: {current}"
185
+ )
186
+ renderer.success(message, {"language": current})
187
+ return
188
+
189
+ selected = normalize_language(language)
190
+ ConfigStore().save_language(selected)
191
+ message = (
192
+ "默认帮助语言已切换为中文(zh)"
193
+ if selected == "zh"
194
+ else "Default help language set to English (en)"
195
+ )
196
+ renderer.success(message, {"ok": True, "language": selected})
197
+
198
+
199
+ def main(args: Optional[List[str]] = None) -> None:
200
+ argv = list(sys.argv[1:] if args is None else args)
201
+ try:
202
+ language = configured_language()
203
+ command = localize_command(get_command(app), language)
204
+ result = command.main(args=argv, prog_name="compshare", standalone_mode=False)
205
+ except CLIError as error:
206
+ Renderer("--json" in argv).error(str(error))
207
+ raise SystemExit(2) from error
208
+ except click.ClickException as error:
209
+ Renderer("--json" in argv).error(error.format_message())
210
+ raise SystemExit(error.exit_code) from error
211
+ except click.Abort as error:
212
+ Renderer("--json" in argv).error(tr("Aborted"))
213
+ raise SystemExit(1) from error
214
+ if isinstance(result, int) and result:
215
+ raise SystemExit(result)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()
@@ -0,0 +1 @@
1
+ """CLI command groups."""
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Optional
4
+
5
+ import typer
6
+
7
+ from compshare_cli.i18n import tr
8
+ from compshare_cli.location import region_from_zone
9
+ from compshare_cli.output import Renderer
10
+ from compshare_cli.runtime import Runtime
11
+
12
+
13
+ def runtime(ctx: typer.Context) -> Runtime:
14
+ value = ctx.find_root().obj
15
+ if not isinstance(value, Runtime):
16
+ raise RuntimeError("CLI runtime is not initialized")
17
+ return value
18
+
19
+
20
+ def request(
21
+ ctx: typer.Context,
22
+ *,
23
+ zone: bool = False,
24
+ project_id: Optional[str] = None,
25
+ region_value: Optional[str] = None,
26
+ zone_value: Optional[str] = None,
27
+ ) -> Dict[str, Any]:
28
+ state = runtime(ctx)
29
+ resolved_zone = zone_value or (state.zone if zone else None)
30
+ resolved_region = region_value or (
31
+ region_from_zone(resolved_zone) if resolved_zone else state.region
32
+ )
33
+ payload: Dict[str, Any] = {"Region": resolved_region}
34
+ if zone:
35
+ payload["Zone"] = resolved_zone
36
+ if project_id:
37
+ payload["ProjectId"] = project_id
38
+ return payload
39
+
40
+
41
+ def confirm(message: str, yes: bool) -> None:
42
+ if yes:
43
+ return
44
+ if not typer.confirm(tr(message)):
45
+ raise typer.Abort()
46
+
47
+
48
+ def confirm_details(
49
+ state: Runtime,
50
+ title: str,
51
+ fields: list[tuple[str, Any]],
52
+ prompt: str,
53
+ yes: bool,
54
+ ) -> None:
55
+ if not state.json_output:
56
+ Renderer(False).details(title, fields)
57
+ confirm(prompt, yes)