diff --git a/README.md b/README.md index 49cb1e6b..11fba34e 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,21 @@ The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built For the latest model list, visit [Bailian Model Plaza](https://bailian.console.aliyun.com/). +## Shell Completion + +Run the appropriate command once, then restart your shell (or re-source your config file): + +| Shell | Install command | +|-------|-----------------| +| **bash** | `dashscope --install-completion bash` | +| **zsh** | `dashscope --install-completion zsh` | +| **fish** | `dashscope --install-completion fish` | + +To preview the completion script without installing: +```shell +dashscope --show-completion bash +``` + ## Logging To output Dashscope logs, you need to configure the logger. ```shell diff --git a/dashscope/acli/cli/constants.py b/dashscope/acli/cli/constants.py index ba8b370a..fc7bb70e 100644 --- a/dashscope/acli/cli/constants.py +++ b/dashscope/acli/cli/constants.py @@ -225,7 +225,7 @@ "/rule": ["list", "add", "remove", "edit", "clear"], "/profile": ["list", "search", "add", "remove", "clear"], "/memory": ["list", "search", "remove", "clear"], - "/session": ["new", "list", "switch", "rename", "remove"], + "/session": ["new", "list", "switch", "rename", "fork", "remove"], "/mcp": ["list", "add", "remove"], "/cron": ["add", "list", "remove", "pause", "resume"], "/feedback": ["good", "bad"], diff --git a/dashscope/acli/cli/handlers_session.py b/dashscope/acli/cli/handlers_session.py index b4d0d7b2..e879987c 100644 --- a/dashscope/acli/cli/handlers_session.py +++ b/dashscope/acli/cli/handlers_session.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Session management command handlers.""" # pylint: disable=too-many-branches,too-many-statements,unused-argument +# pylint: disable=too-many-return-statements from __future__ import annotations @@ -15,6 +16,8 @@ " /session list — list all sessions\n" " /session switch — switch to a topic\n" " /session rename — rename\n" + " /session fork [src] — fork a session (default src: " + "current)\n" " /session remove — remove session " "(default cannot be removed)\n" " /session scene — show scene memory of the current topic\n" @@ -128,6 +131,35 @@ def _handle_session_command(cmd: str, config, agent) -> None: f"or '{new_name}' already exists[/red]", ) + elif subcmd == "fork": + if len(parts) < 3: + console.print( + "[dim]Usage: /session fork [src][/dim]", + ) + return + fork_args = parts[2].split(maxsplit=1) + dst = fork_args[0] + src = ( + fork_args[1] + if len(fork_args) > 1 + else (session_mgr.get_current_topic()) + ) + # Flush the live conversation when forking the active topic so + # the fork carries the latest messages. + if src == session_mgr.get_current_topic(): + if agent.session_path and agent.messages: + agent.save_session() + if session_mgr.fork_topic(src, dst): + console.print( + f"[green]Forked session: {src} → {dst}[/green]\n" + f" [dim]Switch with: /session switch {dst}[/dim]", + ) + else: + console.print( + f"[red]Fork failed: '{src}' does not exist " + f"or '{dst}' already exists[/red]", + ) + elif subcmd == "remove": topic = parts[2] if len(parts) > 2 else "" if not topic: diff --git a/dashscope/acli/cli/mcp.py b/dashscope/acli/cli/mcp.py index 6343786b..845465c2 100644 --- a/dashscope/acli/cli/mcp.py +++ b/dashscope/acli/cli/mcp.py @@ -149,7 +149,9 @@ async def _handle_mcp_command(cmd: str, config: Config): " /mcp — list connected services\n" " /mcp list — show Bailian available services\n" " /mcp add — add an MCP service\n" - " /mcp remove — remove an MCP service[/dim]", + " /mcp remove — remove an MCP service\n" + "Stdio servers: add [[mcp_servers]] in config.toml with " + 'transport = "stdio", command and args[/dim]', ) diff --git a/dashscope/acli/commands.py b/dashscope/acli/commands.py index 111bd1d1..5817f3cf 100644 --- a/dashscope/acli/commands.py +++ b/dashscope/acli/commands.py @@ -112,7 +112,7 @@ ("/memory", "Chat history (list/search/remove /clear)"), ( "/session", - "Session management (new/list/switch/rename/remove/scene)", + "Sessions (new/list/switch/rename/fork/remove/scene)", ), ( "/summarize", diff --git a/dashscope/acli/mcp_stdio.py b/dashscope/acli/mcp_stdio.py index bd92e136..6388fdf1 100644 --- a/dashscope/acli/mcp_stdio.py +++ b/dashscope/acli/mcp_stdio.py @@ -13,11 +13,12 @@ import asyncio import json +from dashscope.acli import __version__ from dashscope.acli.platforms.bailian.mcp import MCP_PROTOCOL_VERSION, MCPError __all__ = ["StdioMCPClient", "MCPError"] -_CLIENT_INFO = {"name": "acli", "version": "0.1.0"} +_CLIENT_INFO = {"name": "acli", "version": __version__} _CLOSE_TIMEOUT = 3.0 _STDERR_TAIL = 500 diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index 98b42c41..2ec8a9a3 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -274,6 +274,8 @@ def fork_topic(self, src: str, dst: str) -> bool: if not src_dir.exists() or dst_dir.exists(): return False try: + import shutil + dst_dir.mkdir(parents=True) self._save_meta( dst, @@ -285,9 +287,12 @@ def fork_topic(self, src: str, dst: str) -> bool: ) src_events = self._events_file(src) if src_events.exists(): - import shutil - shutil.copy2(src_events, self._events_file(dst)) + # Pre-snapshot sessions keep their messages only in + # history.json; copy it too so the fork restores them. + src_history = self._history_file(src) + if src_history.exists(): + shutil.copy2(src_history, self._history_file(dst)) except OSError: return False self.event_log(dst).append( diff --git a/dashscope/acli/session_events.py b/dashscope/acli/session_events.py index 777845ee..062f7d6c 100644 --- a/dashscope/acli/session_events.py +++ b/dashscope/acli/session_events.py @@ -130,16 +130,6 @@ def read( events.append(entry) return events - def tail( - self, - n: int, - event_type: str | None = None, - ) -> list[dict[str, Any]]: - """Return the most recent ``n`` events (optionally by type).""" - if n <= 0: - return [] - return self.read(event_type)[-n:] - def read_raw(self) -> list[dict[str, Any]]: """Return every well-formed entry, any schema version. diff --git a/dashscope/acli/ui/tui.py b/dashscope/acli/ui/tui.py index 25167541..12cee847 100644 --- a/dashscope/acli/ui/tui.py +++ b/dashscope/acli/ui/tui.py @@ -13,6 +13,7 @@ import contextlib import io import os +import re import threading import time from pathlib import Path @@ -36,17 +37,6 @@ or "jediterm" in os.environ.get("TERM_PROGRAM", "").lower() ) -# Temporary instrumentation for the iTerm2 drag auto-scroll investigation. -_ACLI_DEBUG_SELECT = bool(os.environ.get("ACLI_DEBUG_SELECT")) - - -def _select_debug(message: str) -> None: - if not _ACLI_DEBUG_SELECT: - return - with open("/tmp/acli_select_debug.log", "a", encoding="utf-8") as log_file: - log_file.write(f"{time.monotonic():.3f} {message}\n") - - # Stream flush: time window + line-count threshold (the threshold only # guards against over-frequent flushes on bursty bulk output) _STREAM_FLUSH_INTERVAL = 0.8 if _IS_JEDITERM else 0.3 @@ -334,10 +324,68 @@ def get_selection(self, selection: Selection) -> tuple[str, str] | None: except (IndexError, TypeError): return None + async def _on_click(self, event: events.Click) -> None: + if event.chain >= 2 and event.button == 1: + # Textual's default double-click is a widget-wide select-all — + # on a long output log that selects the entire scrollback. Do + # word (double) / line (triple) selection at the pointer + # instead. prevent_default() is required: the pump dispatches + # the handler of every MRO class that defines one, so + # Widget._on_click (the select-all) runs anyway unless the + # default is suppressed. + event.prevent_default() + event.stop() + self._select_at_pointer(event) + return + # chain == 1: the pump dispatches Widget._on_click on its own — + # do not call super() here or it would run twice. + + def _select_at_pointer(self, event: events.Click) -> None: + try: + widget, offset = self.screen.get_widget_and_offset_at( + event.screen_x, + event.screen_y, + ) + except Exception: + return + if widget is not self or offset is None or not self.lines: + return + # render_line anchors segments in content coordinates, so the + # hit-test offset is (character, content-line). + line_idx = min(max(offset.y, 0), len(self.lines) - 1) + text = self.lines[line_idx].text + if event.chain >= 3: + start_x, end_x = 0, len(text) + else: + span = self._word_span(text, offset.x) + if span is None: + return + start_x, end_x = span + self.screen.selections = { + self: Selection( + Offset(start_x, line_idx), + Offset(end_x, line_idx), + ), + } + + @staticmethod + def _word_span(text: str, x: int) -> tuple[int, int] | None: + """Character span of the word under ``x`` in ``text``, or None.""" + if not text: + return None + x = min(max(x, 0), len(text) - 1) + char = text[x] + if ord(char) > 0x2E7F: + # CJK has no whitespace word boundaries: select the character + return x, x + 1 + if not char.isalnum() and char != "_": + return None + for match in re.finditer(r"\w+", text): + if match.start() <= x < match.end(): + return match.start(), match.end() + return None + def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: - _select_debug( - f"wheel down scroll_y={self.scroll_offset.y}/{self.max_scroll_y}", - ) # The pump dispatches this event to every MRO class defining the # handler; stop() doesn't suppress that, only prevent_default() # does — without it each wheel notch scrolls twice. @@ -351,9 +399,6 @@ def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: self._extend_selection_after_wheel(event) def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: - _select_debug( - f"wheel up scroll_y={self.scroll_offset.y}/{self.max_scroll_y}", - ) event.prevent_default() super()._on_mouse_scroll_up(event) if self.scroll_offset.y < self.max_scroll_y: @@ -405,6 +450,10 @@ class AcliScreen(Screen): _auto_scroll_pointer: Offset | None = None _auto_scroll_target: Any = None # widget our auto-scroll timer scrolls _select_state: Any # textual Screen internal + # True once any mouse event arrived: guards the pointer-position + # heuristics against terminals that never report the pointer (the + # stale 0,0 position would otherwise sit inside the output area). + _mouse_seen: bool = False def _start_auto_scroll( self, @@ -416,21 +465,14 @@ def _start_auto_scroll( # record the target afterwards. super()._start_auto_scroll(widget, direction, speed) self._auto_scroll_target = widget - _select_debug( - f"arm target={getattr(widget, 'id', widget)} " - f"direction={direction} speed={speed:.2f}", - ) def _stop_auto_scroll(self) -> None: - if self._auto_select_scroll_timer is not None: - target = self._auto_scroll_target - _select_debug( - f"stop (was target={getattr(target, 'id', target)})", - ) self._auto_scroll_target = None super()._stop_auto_scroll() def _forward_event(self, event) -> None: + if isinstance(event, events.MouseEvent): + self._mouse_seen = True if ( isinstance(event, events.MouseDown) and self.app.mouse_captured is not None @@ -441,17 +483,9 @@ def _forward_event(self, event) -> None: # captor, so a drag can never start a new selection and copy # keeps serving the old one. A fresh MouseDown always begins a # new gesture — drop the stale capture. - _select_debug( - f"down: dropping stale capture {self.app.mouse_captured}", - ) self.app.capture_mouse(None) super()._forward_event(event) if isinstance(event, events.MouseDown): - _select_debug( - f"down y={event.pointer_screen_y} " - f"selecting={self._selecting} " - f"state={self._select_state is not None}", - ) # Anchor the pointer stash to the new drag: a wheel flush firing # before this drag's first MouseMove must not extend the # selection toward the previous drag's parked position. @@ -461,7 +495,6 @@ def _forward_event(self, event) -> None: ) return if isinstance(event, events.MouseUp): - _select_debug(f"up y={event.pointer_screen_y}") self._auto_scroll_pointer = None return if not (isinstance(event, events.MouseMove) and self._selecting): @@ -505,13 +538,6 @@ def _forward_event(self, event) -> None: can_scroll = output.scroll_y < output.max_scroll_y else: return - target = self._auto_scroll_target - _select_debug( - f"move y={y} dy={event.delta_y} " - f"zone={'up' if direction < 0 else 'down'} " - f"target={getattr(target, 'id', target)} " - f"scroll_y={output.scroll_y:.0f}/{output.max_scroll_y}", - ) if self._auto_scroll_target is output: # Already scrolling the right widget (armed here or natively). return @@ -917,8 +943,6 @@ def _save_history(self, text: str) -> None: if not self.history_path: return try: - import re - # Redact API keys sanitized = re.sub(r"(/provider\s+\w+\s+)\S+", r"\1***", text) self.history_path.parent.mkdir(parents=True, exist_ok=True) @@ -975,6 +999,19 @@ def _on_key(self, event) -> None: # as wheel input, batched into a single scroll (per-key scrolling = # per-key full-area repaint, which flickers badly on PyCharm). if event.key in ("up", "down"): + if self._pointer_over_output(): + # JediTerm turns the wheel into arrow keys no matter where + # the pointer is; the pointer position is the only hint of + # where the gesture is aimed. Over the output area these + # keys are scrolls — routing isolated (non-burst) keys to + # history made the input box visibly change while the + # output scrolled. + event.prevent_default() + event.stop() + self._prev_arrow_key = event.key + self._prev_arrow_ts = time.monotonic() + self._queue_wheel_scroll(event.key) + return now = time.monotonic() is_burst = ( self._prev_arrow_key == event.key @@ -1076,6 +1113,34 @@ def _on_key(self, event) -> None: self._update_completions, ) + def _pointer_over_output(self) -> bool: + """Whether the last known pointer position is inside the output area. + + Only meaningful on JediTerm, which is the terminal that translates + the wheel into arrow keys; elsewhere arrows are real key presses. + Requires a mouse event to have been seen (a never-reported pointer + stays at 0,0, which sits inside the output area), and yields while + the completion popup is open so arrows keep navigating it. + """ + if not _IS_JEDITERM: + return False + screen = self.app.screen + if not getattr(screen, "_mouse_seen", False): + return False + try: + popup = self.app.query_one("#completion-popup", CompletionPopup) + except Exception: + popup = None + if popup is not None and popup.is_visible: + return False + try: + output = self.app.query_one("#output") + except Exception: + return False + x, y = self.app.mouse_position + region = output.region + return region.x <= x < region.right and region.y <= y < region.bottom + def _queue_wheel_scroll(self, key: str) -> None: self._wheel_pending += 1 if key == "down" else -1 self._last_wheel_ts = time.monotonic() @@ -1118,6 +1183,9 @@ def _apply_deferred_arrow(self) -> None: self._arrow_timer = None if not key or self.password_mode: return + if self._pointer_over_output(): + self._queue_wheel_scroll(key) + return # A decelerating trackpad gesture keeps emitting isolated arrows # past the 20ms burst window; while a wheel gesture is (or was # just) active, these stragglers are scrolls, not history/completion @@ -1489,8 +1557,8 @@ def action_voice_input(self) -> None: asyncio.create_task(self._handle_voice_input()) def action_copy_selection(self) -> bool: - """Cmd+C / super+c: user-initiated copy — write the output-area - selection to the system clipboard. + """Ctrl+C (with a selection) / super+c: user-initiated copy — write + the output-area selection to the system clipboard. A terminal on the alternate screen only copies the visible screen, so a multi-screen selection loses off-screen content. Explicitly @@ -3007,6 +3075,4 @@ def run_tui(config, agent, input_history_path: Path | None = None): # screen (including the input line) appears to move. Set tui_mouse = # false for terminal-native selection; bypass capture with Alt/Option # drag to copy. - mouse = getattr(config, "tui_mouse", True) - _select_debug(f"run_tui start mouse={mouse} pid={os.getpid()}") - app.run(mouse=mouse) + app.run(mouse=getattr(config, "tui_mouse", True)) diff --git a/dashscope/audio/http_tts/http_speech_synthesizer.py b/dashscope/audio/http_tts/http_speech_synthesizer.py index 285f8c2f..df10d935 100644 --- a/dashscope/audio/http_tts/http_speech_synthesizer.py +++ b/dashscope/audio/http_tts/http_speech_synthesizer.py @@ -266,6 +266,12 @@ def _handle_non_streaming_response( response, ) -> HttpSpeechSynthesisResult: """Handle non-streaming response.""" + if ( + isinstance(response, DashScopeAPIResponse) + and response.status_code != HTTPStatus.OK + ): + return HttpSpeechSynthesisResult(response=response) + output = cls._extract_output(response) audio_info = output.get("audio", {}) @@ -273,6 +279,7 @@ def _handle_non_streaming_response( audio_url=audio_info.get("url"), audio_id=audio_info.get("id"), expires_at=audio_info.get("expires_at"), + response=response, ) @classmethod @@ -302,6 +309,7 @@ def _handle_streaming_response( yield HttpSpeechSynthesisResult( audio_data=audio_bytes, sentences=sentences.copy(), + response=part, ) elif output.get("finish_reason") == "stop": @@ -318,6 +326,7 @@ def _handle_streaming_response( "expires_at", ), sentences=sentences.copy(), + response=part, ) diff --git a/dashscope/cli/__init__.py b/dashscope/cli/__init__.py index 04cb5ee5..cafa1cec 100644 --- a/dashscope/cli/__init__.py +++ b/dashscope/cli/__init__.py @@ -24,6 +24,7 @@ from dashscope.common.error import AuthenticationError # noqa: E402 from dashscope.cli import ( # noqa: E402 application, + auth, deployments, embeddings, files, @@ -370,7 +371,7 @@ def _route_to_expert(command, tui=None): app = typer.Typer( name="dashscope", help="DashScope command line tools.", - add_completion=False, + add_completion=True, no_args_is_help=True, rich_markup_mode="rich", ) @@ -391,6 +392,7 @@ def callback( # Register sub-command groups +app.add_typer(auth.app) app.add_typer(generation.app) app.add_typer(fine_tunes.app, name="ft") app.add_typer(fine_tunes.app, name="fine-tunes", hidden=True) diff --git a/dashscope/cli/auth.py b/dashscope/cli/auth.py new file mode 100644 index 00000000..89aff97c --- /dev/null +++ b/dashscope/cli/auth.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""``auth`` sub-command group — API key management.""" +import os +from http import HTTPStatus + +import typer + +import dashscope +from dashscope.cli.common import console, err_console +from dashscope.common.api_key import get_default_api_key, save_api_key +from dashscope.common.constants import DEFAULT_DASHSCOPE_API_KEY_FILE_PATH +from dashscope.common.error import AuthenticationError + +app = typer.Typer( + name="auth", + help="API key management commands.", + add_completion=False, + invoke_without_command=True, +) + + +@app.callback() +def callback(ctx: typer.Context): + """Show help if no subcommand is provided.""" + if ctx.invoked_subcommand is None: + typer.echo(ctx.get_help()) + + +@app.command("whoami") +def whoami(): + """Verify the current API key and display its source. + + Exit codes: + 0 key is present and accepted by the API + 1 no key configured + 2 key is configured but rejected by the API + """ + try: + key = get_default_api_key() + except AuthenticationError as exc: + err_console.print("[red]Error:[/red] No API key configured.") + err_console.print( + "Run [bold]dashscope auth login[/bold] or set " + "DASHSCOPE_API_KEY to configure one.", + ) + raise typer.Exit(1) from exc + + # Determine where the key came from + if dashscope.api_key: + source = "environment / --api-key flag" + elif dashscope.api_key_file_path: + source = f"file ({dashscope.api_key_file_path})" + elif os.path.exists(DEFAULT_DASHSCOPE_API_KEY_FILE_PATH): + source = f"file ({DEFAULT_DASHSCOPE_API_KEY_FILE_PATH})" + else: + source = "unknown" + + # Validate the key against the API with a lightweight call + try: + dashscope.api_key = key + rsp = dashscope.Models.list(page=1, page_size=1) + except Exception as exc: + err_console.print(f"[red]Error:[/red] API call failed: {exc}") + raise typer.Exit(2) + + if rsp.status_code == HTTPStatus.OK: + masked = key[:6] + "..." + key[-4:] if len(key) > 10 else "***" + info = f"key={masked} source={source}" + console.print(f"[green]Authenticated[/green] {info}") + raise typer.Exit(0) + if rsp.status_code in (401, 403): + err_console.print( + f"[red]Invalid API key[/red] source={source}\n" + f"code={rsp.code} message={rsp.message}", + ) + raise typer.Exit(2) + err_console.print( + f"[red]Unexpected response[/red] status={rsp.status_code} " + f"message={rsp.message}", + ) + raise typer.Exit(2) + + +@app.command("login") +def login( + key: str = typer.Option( + None, + "--key", + "-k", + help="API key to save. Prompted interactively if omitted.", + ), +): + """Save an API key to ~/.dashscope/api_key.""" + if not key: + key = typer.prompt("Enter your DashScope API key", hide_input=True) + + key = key.strip() + if not key: + err_console.print("[red]Error:[/red] API key cannot be empty.") + raise typer.Exit(1) + + save_api_key(key) + key_path = DEFAULT_DASHSCOPE_API_KEY_FILE_PATH + console.print(f"[green]Saved[/green] API key to {key_path}") + + +@app.command("logout") +def logout(): + """Remove the saved API key from ~/.dashscope/api_key.""" + path = DEFAULT_DASHSCOPE_API_KEY_FILE_PATH + if not os.path.exists(path): + console.print("No saved API key found — nothing to remove.") + return + + os.remove(path) + console.print(f"[green]Removed[/green] API key file {path}") diff --git a/dashscope/cli/common.py b/dashscope/cli/common.py index ba7fb3f6..68f0eea4 100644 --- a/dashscope/cli/common.py +++ b/dashscope/cli/common.py @@ -41,6 +41,38 @@ def print_failed_message(rsp): ) +def _exit_code_for(rsp) -> int: + """Map an API response to a structured CLI exit code. + + 0 success (never returned here) + 1 server error — HTTP 5xx + 2 auth error — HTTP 401/403 or auth-related business code + 3 param error — HTTP 400/422 or invalid-param business code + 4 rate limited — HTTP 429 + """ + _HTTP_MAP = {429: 4, 401: 2, 403: 2, 400: 3, 422: 3} + _BIZ_AUTH = ("Unauthorized", "Auth", "Forbidden", "AccessDenied") + _BIZ_PARAM = ("Invalid", "Parameter", "BadRequest", "MissingParam") + + try: + sc = int(rsp.status_code) + except (TypeError, ValueError): + return 1 + + if sc in _HTTP_MAP: + return _HTTP_MAP[sc] + if sc >= 500: + return 1 + + # HTTP 200 with business-level error code + code = str(getattr(rsp, "code", "") or "") + if any(k in code for k in _BIZ_AUTH): + return 2 + if any(k in code for k in _BIZ_PARAM): + return 3 + return 1 + + def ensure_ok(rsp, check_business_error: bool = True): """Return *rsp.output* when the response is OK; otherwise print the error and exit with code 1. @@ -61,13 +93,13 @@ def ensure_ok(rsp, check_business_error: bool = True): """ if rsp.status_code != HTTPStatus.OK: print_failed_message(rsp) - raise typer.Exit(1) + raise typer.Exit(_exit_code_for(rsp)) # Check for business-level errors even when HTTP status is 200 output = rsp.output if output is None: print_failed_message(rsp) - raise typer.Exit(1) + raise typer.Exit(_exit_code_for(rsp)) # Only check business-level errors if explicitly requested if check_business_error: @@ -86,11 +118,30 @@ def ensure_ok(rsp, check_business_error: bool = True): f"code: {error_code}, " f"message: {message}", ) - raise typer.Exit(1) + + # reuse rsp with the business code for exit-code mapping + class _BizRsp: + status_code = rsp.status_code + code = error_code + + raise typer.Exit(_exit_code_for(_BizRsp())) return output +def extract_text(output) -> str: + """Extract plain text from a GenerationOutput chunk. + + Handles both result_format='message' (choices[].message.content) + and result_format='text' (output.text). + """ + choices = getattr(output, "choices", None) + if choices: + msg = getattr(choices[0], "message", None) + return (getattr(msg, "content", None) or "") if msg else "" + return getattr(output, "text", None) or "" + + def success(message: str): """Print a success message in green.""" console.print(f"[green]✓[/green] {message}") diff --git a/dashscope/cli/generation.py b/dashscope/cli/generation.py index b624acc5..2305dae7 100644 --- a/dashscope/cli/generation.py +++ b/dashscope/cli/generation.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- """``generation`` sub-command group.""" +import json +import sys from http import HTTPStatus from typing import Any, Dict, Optional @@ -8,6 +10,7 @@ from dashscope.aigc import Generation from dashscope.cli.common import ( error, + extract_text, handle_sdk_error, print_failed_message, ) @@ -35,8 +38,6 @@ def _build_generation_kwargs( result_format: Optional[str], ) -> Dict[str, Any]: """Build kwargs dictionary for Generation.call from CLI options.""" - import json - kwargs: Dict[str, Any] = {} if messages is not None: @@ -72,6 +73,49 @@ def _build_generation_kwargs( return kwargs +def _output_stream(response): + """Handle streaming response output. + + TTY: plain text; non-TTY: JSON lines. + """ + is_tty = sys.stdout.isatty() + last_usage = None + for rsp in response: + if rsp.status_code != HTTPStatus.OK: + print_failed_message(rsp) + raise typer.Exit(1) + last_usage = getattr(rsp, "usage", None) + if is_tty: + typer.echo(extract_text(rsp.output), nl=False) + else: + row = {"output": dict(rsp.output)} + if last_usage: + row["usage"] = dict(last_usage) + typer.echo(json.dumps(row, ensure_ascii=False)) + if is_tty: + typer.echo("") + if last_usage: + typer.echo(json.dumps(dict(last_usage), ensure_ascii=False)) + + +def _output_single(response): + """Handle non-streaming response output. + + TTY: plain text; non-TTY: JSON. + """ + if response.status_code != HTTPStatus.OK: + print_failed_message(response) + raise typer.Exit(1) + if sys.stdout.isatty(): + typer.echo(extract_text(response.output)) + else: + row = {"output": dict(response.output)} + usage = getattr(response, "usage", None) + if usage: + row["usage"] = dict(usage) + typer.echo(json.dumps(row, ensure_ascii=False)) + + @app.callback() def callback(ctx: typer.Context): """Show help if no subcommand is provided.""" @@ -170,23 +214,9 @@ def create( response = Generation.call(model, prompt, stream=stream, **kwargs) if stream: - for rsp in response: - if rsp.status_code == HTTPStatus.OK: - typer.echo(rsp.output) - usage = getattr(rsp, "usage", None) - if usage: - typer.echo(usage) - else: - print_failed_message(rsp) + _output_stream(response) else: - if response.status_code == HTTPStatus.OK: - typer.echo(response.output) - usage = getattr(response, "usage", None) - if usage: - typer.echo(usage) - else: - print_failed_message(response) - raise typer.Exit(1) + _output_single(response) # Backward compatibility alias diff --git a/dashscope/version.py b/dashscope/version.py index e8bac9f6..b97afa77 100644 --- a/dashscope/version.py +++ b/dashscope/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -__version__ = "1.27.1" +__version__ = "1.27.2" diff --git a/setup.cfg b/setup.cfg index e49415de..a3141547 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,7 @@ [bdist_wheel] [flake8] +max-line-length = 88 extend-ignore = E203 per-file-ignores = dashscope/__init__.py:E402 \ No newline at end of file diff --git a/tests/unit/test_cli_common.py b/tests/unit/test_cli_common.py new file mode 100644 index 00000000..f28f7375 --- /dev/null +++ b/tests/unit/test_cli_common.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""Unit tests for CLI exit-code classification in dashscope.cli.common.""" +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli.common import _exit_code_for +from dashscope.cli import embeddings + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _rsp(status_code, code="", message="", output=None, request_id="req-test"): + return SimpleNamespace( + status_code=status_code, + code=code, + message=message, + output=output, + request_id=request_id, + usage=None, + ) + + +def _ok_rsp(output=None): + return _rsp(200, output=output or {"embeddings": []}) + + +# --------------------------------------------------------------------------- +# _exit_code_for — pure unit tests +# --------------------------------------------------------------------------- + + +class TestExitCodeFor: + def test_server_error_500(self): + assert _exit_code_for(_rsp(500)) == 1 + + def test_server_error_503(self): + assert _exit_code_for(_rsp(503)) == 1 + + def test_auth_401(self): + assert _exit_code_for(_rsp(401)) == 2 + + def test_auth_403(self): + assert _exit_code_for(_rsp(403)) == 2 + + def test_param_400(self): + assert _exit_code_for(_rsp(400)) == 3 + + def test_param_422(self): + assert _exit_code_for(_rsp(422)) == 3 + + def test_rate_limit_429(self): + assert _exit_code_for(_rsp(429)) == 4 + + def test_business_auth_unauthorized(self): + assert _exit_code_for(_rsp(200, code="Unauthorized")) == 2 + + def test_business_auth_access_denied(self): + assert _exit_code_for(_rsp(200, code="AccessDenied")) == 2 + + def test_business_auth_failure(self): + assert _exit_code_for(_rsp(200, code="AuthFailure")) == 2 + + def test_business_param_invalid(self): + assert _exit_code_for(_rsp(200, code="InvalidParameter")) == 3 + + def test_business_param_bad_request(self): + assert _exit_code_for(_rsp(200, code="BadRequest")) == 3 + + def test_fallback_unknown(self): + assert _exit_code_for(_rsp(200, code="SomeUnknownError")) == 1 + + +# --------------------------------------------------------------------------- +# ensure_ok — via embeddings CLI app (uses ensure_ok internally) +# --------------------------------------------------------------------------- + +_EMBED_ARGS = [ + "create", + "--model", + "text-embedding-v3", + "--input", + "hello", +] + + +class TestEnsureOkViaCli: + runner = CliRunner() + + def _invoke(self, mock_rsp, monkeypatch): + monkeypatch.setattr( + embeddings.dashscope.TextEmbedding, + "call", + lambda **_: mock_rsp, + ) + return self.runner.invoke(embeddings.app, _EMBED_ARGS) + + # exit 0 — success + def test_exit_0_success(self, monkeypatch): + out = {"embeddings": [{"text_index": 0, "embedding": [0.1]}]} + rsp = _rsp(200, output=out) + r = self._invoke(rsp, monkeypatch) + assert r.exit_code == 0 + + # exit 1 — server error + def test_exit_1_http_500(self, monkeypatch): + r = self._invoke(_rsp(500), monkeypatch) + assert r.exit_code == 1 + + def test_exit_1_null_output(self, monkeypatch): + r = self._invoke(_rsp(200, output=None), monkeypatch) + assert r.exit_code == 1 + + # exit 2 — auth error + def test_exit_2_http_401(self, monkeypatch): + r = self._invoke(_rsp(401), monkeypatch) + assert r.exit_code == 2 + + def test_exit_2_http_403(self, monkeypatch): + r = self._invoke(_rsp(403), monkeypatch) + assert r.exit_code == 2 + + def test_exit_2_business_unauthorized(self, monkeypatch): + rsp = _rsp( + 200, + code="Unauthorized", + output={ + "embeddings": [], + "code": "Unauthorized", + "message": "bad key", + }, + ) + r = self._invoke(rsp, monkeypatch) + assert r.exit_code == 2 + + # exit 3 — param error + def test_exit_3_http_400(self, monkeypatch): + r = self._invoke(_rsp(400), monkeypatch) + assert r.exit_code == 3 + + def test_exit_3_http_422(self, monkeypatch): + r = self._invoke(_rsp(422), monkeypatch) + assert r.exit_code == 3 + + def test_exit_3_business_invalid_param(self, monkeypatch): + rsp = _rsp( + 200, + code="InvalidParameter", + output={ + "embeddings": [], + "code": "InvalidParameter", + "message": "bad param", + }, + ) + r = self._invoke(rsp, monkeypatch) + assert r.exit_code == 3 + + # exit 4 — rate limit + def test_exit_4_http_429(self, monkeypatch): + r = self._invoke(_rsp(429), monkeypatch) + assert r.exit_code == 4 diff --git a/tests/unit/test_http_speech_synthesizer.py b/tests/unit/test_http_speech_synthesizer.py new file mode 100644 index 00000000..d59410df --- /dev/null +++ b/tests/unit/test_http_speech_synthesizer.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +# pylint: disable=protected-access + +import base64 +from http import HTTPStatus + +from dashscope.api_entities.dashscope_response import DashScopeAPIResponse +from dashscope.audio.http_tts.http_speech_synthesizer import ( + HttpSpeechSynthesizer, +) + + +def _ok_response(output, request_id="req-1"): + return DashScopeAPIResponse( + status_code=HTTPStatus.OK, + request_id=request_id, + output=output, + ) + + +class TestHandleNonStreamingResponse: + def test_success_proxies_status_code(self): + response = _ok_response( + { + "audio": { + "url": "https://example.com/a.wav", + "id": "audio-1", + "expires_at": 1893456000, + }, + }, + ) + + result = HttpSpeechSynthesizer._handle_non_streaming_response( + response, + ) + + assert result.status_code == HTTPStatus.OK + assert result.request_id == "req-1" + assert result.audio_url == "https://example.com/a.wav" + assert result.audio_id == "audio-1" + assert result.expires_at == 1893456000 + + def test_failed_response_is_surfaced_not_raised(self): + response = DashScopeAPIResponse( + status_code=HTTPStatus.BAD_REQUEST, + request_id="req-err", + code="InvalidParameter", + message="bad voice", + ) + + result = HttpSpeechSynthesizer._handle_non_streaming_response( + response, + ) + + assert result.status_code == HTTPStatus.BAD_REQUEST + assert result.request_id == "req-err" + assert result.code == "InvalidParameter" + assert result.message == "bad voice" + assert result.audio_url is None + + +class TestHandleStreamingResponse: + def test_chunks_and_final_result_proxy_status_code(self): + audio_bytes = b"fake-audio" + chunks = [ + _ok_response( + { + "type": "sentence-begin", + "sentence": {"index": 1}, + "audio": {"data": base64.b64encode(audio_bytes)}, + }, + request_id="req-s1", + ), + _ok_response( + { + "finish_reason": "stop", + "audio": { + "url": "https://example.com/a.wav", + "id": "audio-2", + "expires_at": 1893456000, + }, + }, + request_id="req-s2", + ), + ] + + results = list( + HttpSpeechSynthesizer._handle_streaming_response(iter(chunks)), + ) + + assert len(results) == 2 + assert results[0].audio_data == audio_bytes + assert results[0].status_code == HTTPStatus.OK + assert results[0].request_id == "req-s1" + assert results[1].audio_data == audio_bytes + assert results[1].audio_url == "https://example.com/a.wav" + assert results[1].request_id == "req-s2" + + +class TestCall: + def test_call_returns_result_with_real_status(self, monkeypatch): + captured = {} + + def mock_http_call(cls, **kwargs): # pylint: disable=unused-argument + captured.update(kwargs) + return _ok_response( + {"audio": {"url": "https://example.com/a.wav"}}, + ) + + monkeypatch.setattr( + HttpSpeechSynthesizer, + "_http_call", + classmethod(mock_http_call), + ) + + result = HttpSpeechSynthesizer.call( + model="cosyvoice-v2", + text="你好世界", + voice="longxiaochun_v2", + volume=None, + rate=None, + pitch=None, + ) + + assert captured["body"]["input"] == { + "text": "你好世界", + "voice": "longxiaochun_v2", + "format": "wav", + "sample_rate": 24000, + } + assert result.status_code == HTTPStatus.OK + assert result.audio_url == "https://example.com/a.wav"