Skip to content

Commit c4e38e7

Browse files
author
kevin
committed
feat(cli): add shell completion, auth commands, streaming fix, and exit-code tiers
1 parent df8d898 commit c4e38e7

7 files changed

Lines changed: 401 additions & 22 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,21 @@ The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built
107107

108108
For the latest model list, visit [Bailian Model Plaza](https://bailian.console.aliyun.com/).
109109

110+
## Shell Completion
111+
112+
Run the appropriate command once, then restart your shell (or re-source your config file):
113+
114+
| Shell | Install command |
115+
|-------|-----------------|
116+
| **bash** | `dashscope --install-completion bash` |
117+
| **zsh** | `dashscope --install-completion zsh` |
118+
| **fish** | `dashscope --install-completion fish` |
119+
120+
To preview the completion script without installing:
121+
```shell
122+
dashscope --show-completion bash
123+
```
124+
110125
## Logging
111126
To output Dashscope logs, you need to configure the logger.
112127
```shell

‎dashscope/cli/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from dashscope.common.error import AuthenticationError # noqa: E402
2525
from dashscope.cli import ( # noqa: E402
2626
application,
27+
auth,
2728
deployments,
2829
embeddings,
2930
files,
@@ -370,7 +371,7 @@ def _route_to_expert(command, tui=None):
370371
app = typer.Typer(
371372
name="dashscope",
372373
help="DashScope command line tools.",
373-
add_completion=False,
374+
add_completion=True,
374375
no_args_is_help=True,
375376
rich_markup_mode="rich",
376377
)
@@ -391,6 +392,7 @@ def callback(
391392

392393

393394
# Register sub-command groups
395+
app.add_typer(auth.app)
394396
app.add_typer(generation.app)
395397
app.add_typer(fine_tunes.app, name="ft")
396398
app.add_typer(fine_tunes.app, name="fine-tunes", hidden=True)

‎dashscope/cli/auth.py‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# -*- coding: utf-8 -*-
2+
"""``auth`` sub-command group — API key management."""
3+
import os
4+
from http import HTTPStatus
5+
6+
import typer
7+
8+
import dashscope
9+
from dashscope.cli.common import console, err_console
10+
from dashscope.common.api_key import get_default_api_key, save_api_key
11+
from dashscope.common.constants import DEFAULT_DASHSCOPE_API_KEY_FILE_PATH
12+
from dashscope.common.error import AuthenticationError
13+
14+
app = typer.Typer(
15+
name="auth",
16+
help="API key management commands.",
17+
add_completion=False,
18+
invoke_without_command=True,
19+
)
20+
21+
22+
@app.callback()
23+
def callback(ctx: typer.Context):
24+
"""Show help if no subcommand is provided."""
25+
if ctx.invoked_subcommand is None:
26+
typer.echo(ctx.get_help())
27+
28+
29+
@app.command("whoami")
30+
def whoami():
31+
"""Verify the current API key and display its source.
32+
33+
Exit codes:
34+
0 key is present and accepted by the API
35+
1 no key configured
36+
2 key is configured but rejected by the API
37+
"""
38+
try:
39+
key = get_default_api_key()
40+
except AuthenticationError as exc:
41+
err_console.print("[red]Error:[/red] No API key configured.")
42+
err_console.print(
43+
"Run [bold]dashscope auth login[/bold] or set "
44+
"DASHSCOPE_API_KEY to configure one.",
45+
)
46+
raise typer.Exit(1) from exc
47+
48+
# Determine where the key came from
49+
if dashscope.api_key:
50+
source = "environment / --api-key flag"
51+
elif dashscope.api_key_file_path:
52+
source = f"file ({dashscope.api_key_file_path})"
53+
elif os.path.exists(DEFAULT_DASHSCOPE_API_KEY_FILE_PATH):
54+
source = f"file ({DEFAULT_DASHSCOPE_API_KEY_FILE_PATH})"
55+
else:
56+
source = "unknown"
57+
58+
# Validate the key against the API with a lightweight call
59+
try:
60+
dashscope.api_key = key
61+
rsp = dashscope.Models.list(page=1, page_size=1)
62+
except Exception as exc:
63+
err_console.print(f"[red]Error:[/red] API call failed: {exc}")
64+
raise typer.Exit(2)
65+
66+
if rsp.status_code == HTTPStatus.OK:
67+
masked = key[:6] + "..." + key[-4:] if len(key) > 10 else "***"
68+
info = f"key={masked} source={source}"
69+
console.print(f"[green]Authenticated[/green] {info}")
70+
raise typer.Exit(0)
71+
if rsp.status_code in (401, 403):
72+
err_console.print(
73+
f"[red]Invalid API key[/red] source={source}\n"
74+
f"code={rsp.code} message={rsp.message}",
75+
)
76+
raise typer.Exit(2)
77+
err_console.print(
78+
f"[red]Unexpected response[/red] status={rsp.status_code} "
79+
f"message={rsp.message}",
80+
)
81+
raise typer.Exit(2)
82+
83+
84+
@app.command("login")
85+
def login(
86+
key: str = typer.Option(
87+
None,
88+
"--key",
89+
"-k",
90+
help="API key to save. Prompted interactively if omitted.",
91+
),
92+
):
93+
"""Save an API key to ~/.dashscope/api_key."""
94+
if not key:
95+
key = typer.prompt("Enter your DashScope API key", hide_input=True)
96+
97+
key = key.strip()
98+
if not key:
99+
err_console.print("[red]Error:[/red] API key cannot be empty.")
100+
raise typer.Exit(1)
101+
102+
save_api_key(key)
103+
key_path = DEFAULT_DASHSCOPE_API_KEY_FILE_PATH
104+
console.print(f"[green]Saved[/green] API key to {key_path}")
105+
106+
107+
@app.command("logout")
108+
def logout():
109+
"""Remove the saved API key from ~/.dashscope/api_key."""
110+
path = DEFAULT_DASHSCOPE_API_KEY_FILE_PATH
111+
if not os.path.exists(path):
112+
console.print("No saved API key found — nothing to remove.")
113+
return
114+
115+
os.remove(path)
116+
console.print(f"[green]Removed[/green] API key file {path}")

‎dashscope/cli/common.py‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,38 @@ def print_failed_message(rsp):
4141
)
4242

4343

44+
def _exit_code_for(rsp) -> int:
45+
"""Map an API response to a structured CLI exit code.
46+
47+
0 success (never returned here)
48+
1 server error — HTTP 5xx
49+
2 auth error — HTTP 401/403 or auth-related business code
50+
3 param error — HTTP 400/422 or invalid-param business code
51+
4 rate limited — HTTP 429
52+
"""
53+
_HTTP_MAP = {429: 4, 401: 2, 403: 2, 400: 3, 422: 3}
54+
_BIZ_AUTH = ("Unauthorized", "Auth", "Forbidden", "AccessDenied")
55+
_BIZ_PARAM = ("Invalid", "Parameter", "BadRequest", "MissingParam")
56+
57+
try:
58+
sc = int(rsp.status_code)
59+
except (TypeError, ValueError):
60+
return 1
61+
62+
if sc in _HTTP_MAP:
63+
return _HTTP_MAP[sc]
64+
if sc >= 500:
65+
return 1
66+
67+
# HTTP 200 with business-level error code
68+
code = str(getattr(rsp, "code", "") or "")
69+
if any(k in code for k in _BIZ_AUTH):
70+
return 2
71+
if any(k in code for k in _BIZ_PARAM):
72+
return 3
73+
return 1
74+
75+
4476
def ensure_ok(rsp, check_business_error: bool = True):
4577
"""Return *rsp.output* when the response is OK; otherwise print the error
4678
and exit with code 1.
@@ -61,13 +93,13 @@ def ensure_ok(rsp, check_business_error: bool = True):
6193
"""
6294
if rsp.status_code != HTTPStatus.OK:
6395
print_failed_message(rsp)
64-
raise typer.Exit(1)
96+
raise typer.Exit(_exit_code_for(rsp))
6597

6698
# Check for business-level errors even when HTTP status is 200
6799
output = rsp.output
68100
if output is None:
69101
print_failed_message(rsp)
70-
raise typer.Exit(1)
102+
raise typer.Exit(_exit_code_for(rsp))
71103

72104
# Only check business-level errors if explicitly requested
73105
if check_business_error:
@@ -86,11 +118,30 @@ def ensure_ok(rsp, check_business_error: bool = True):
86118
f"code: {error_code}, "
87119
f"message: {message}",
88120
)
89-
raise typer.Exit(1)
121+
122+
# reuse rsp with the business code for exit-code mapping
123+
class _BizRsp:
124+
status_code = rsp.status_code
125+
code = error_code
126+
127+
raise typer.Exit(_exit_code_for(_BizRsp()))
90128

91129
return output
92130

93131

132+
def extract_text(output) -> str:
133+
"""Extract plain text from a GenerationOutput chunk.
134+
135+
Handles both result_format='message' (choices[].message.content)
136+
and result_format='text' (output.text).
137+
"""
138+
choices = getattr(output, "choices", None)
139+
if choices:
140+
msg = getattr(choices[0], "message", None)
141+
return (getattr(msg, "content", None) or "") if msg else ""
142+
return getattr(output, "text", None) or ""
143+
144+
94145
def success(message: str):
95146
"""Print a success message in green."""
96147
console.print(f"[green]✓[/green] {message}")

‎dashscope/cli/generation.py‎

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# -*- coding: utf-8 -*-
22
"""``generation`` sub-command group."""
3+
import json
4+
import sys
35
from http import HTTPStatus
46
from typing import Any, Dict, Optional
57

@@ -8,6 +10,7 @@
810
from dashscope.aigc import Generation
911
from dashscope.cli.common import (
1012
error,
13+
extract_text,
1114
handle_sdk_error,
1215
print_failed_message,
1316
)
@@ -35,8 +38,6 @@ def _build_generation_kwargs(
3538
result_format: Optional[str],
3639
) -> Dict[str, Any]:
3740
"""Build kwargs dictionary for Generation.call from CLI options."""
38-
import json
39-
4041
kwargs: Dict[str, Any] = {}
4142

4243
if messages is not None:
@@ -72,6 +73,49 @@ def _build_generation_kwargs(
7273
return kwargs
7374

7475

76+
def _output_stream(response):
77+
"""Handle streaming response output.
78+
79+
TTY: plain text; non-TTY: JSON lines.
80+
"""
81+
is_tty = sys.stdout.isatty()
82+
last_usage = None
83+
for rsp in response:
84+
if rsp.status_code != HTTPStatus.OK:
85+
print_failed_message(rsp)
86+
raise typer.Exit(1)
87+
last_usage = getattr(rsp, "usage", None)
88+
if is_tty:
89+
typer.echo(extract_text(rsp.output), nl=False)
90+
else:
91+
row = {"output": dict(rsp.output)}
92+
if last_usage:
93+
row["usage"] = dict(last_usage)
94+
typer.echo(json.dumps(row, ensure_ascii=False))
95+
if is_tty:
96+
typer.echo("")
97+
if last_usage:
98+
typer.echo(json.dumps(dict(last_usage), ensure_ascii=False))
99+
100+
101+
def _output_single(response):
102+
"""Handle non-streaming response output.
103+
104+
TTY: plain text; non-TTY: JSON.
105+
"""
106+
if response.status_code != HTTPStatus.OK:
107+
print_failed_message(response)
108+
raise typer.Exit(1)
109+
if sys.stdout.isatty():
110+
typer.echo(extract_text(response.output))
111+
else:
112+
row = {"output": dict(response.output)}
113+
usage = getattr(response, "usage", None)
114+
if usage:
115+
row["usage"] = dict(usage)
116+
typer.echo(json.dumps(row, ensure_ascii=False))
117+
118+
75119
@app.callback()
76120
def callback(ctx: typer.Context):
77121
"""Show help if no subcommand is provided."""
@@ -170,23 +214,9 @@ def create(
170214
response = Generation.call(model, prompt, stream=stream, **kwargs)
171215

172216
if stream:
173-
for rsp in response:
174-
if rsp.status_code == HTTPStatus.OK:
175-
typer.echo(rsp.output)
176-
usage = getattr(rsp, "usage", None)
177-
if usage:
178-
typer.echo(usage)
179-
else:
180-
print_failed_message(rsp)
217+
_output_stream(response)
181218
else:
182-
if response.status_code == HTTPStatus.OK:
183-
typer.echo(response.output)
184-
usage = getattr(response, "usage", None)
185-
if usage:
186-
typer.echo(usage)
187-
else:
188-
print_failed_message(response)
189-
raise typer.Exit(1)
219+
_output_single(response)
190220

191221

192222
# Backward compatibility alias

‎setup.cfg‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[bdist_wheel]
22

33
[flake8]
4+
max-line-length = 88
45
extend-ignore = E203
56
per-file-ignores =
67
dashscope/__init__.py:E402

0 commit comments

Comments
 (0)