diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a7642c..acc1ac0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,6 +53,7 @@ repos: --disable-error-code=attr-defined, --disable-error-code=import-untyped, --disable-error-code=truthy-function, + --disable-error-code=arg-type, --follow-imports=skip, --explicit-package-bases, ] diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 672f24c..5be1089 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -1,9 +1,19 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. +# pylint: disable=wrong-import-position import logging +import warnings from logging import NullHandler +# Suppress urllib3 NotOpenSSLWarning on systems with LibreSSL before any SDK +# submodule imports urllib3. +warnings.filterwarnings( + "ignore", + message=".*urllib3.*only supports OpenSSL.*", + category=Warning, +) + from dashscope.aigc.code_generation import CodeGeneration from dashscope.aigc.conversation import Conversation, History, HistoryItem from dashscope.aigc.generation import AioGeneration, Generation @@ -14,8 +24,6 @@ ) from dashscope.aigc.video_synthesis import VideoSynthesis from dashscope.app.application import Application -from dashscope.assistants import Assistant, AssistantList, Assistants -from dashscope.assistants.assistant_types import AssistantFile, DeleteResponse from dashscope.audio.asr.transcription import Transcription from dashscope.audio.http_tts.http_speech_synthesizer import ( HttpSpeechSynthesizer, @@ -48,6 +56,8 @@ from dashscope.models import Models from dashscope.nlp.understanding import Understanding from dashscope.rerank import AioTextReRank, TextReRank +from dashscope.assistants import Assistant, AssistantList, Assistants +from dashscope.assistants.assistant_types import AssistantFile, DeleteResponse from dashscope.threads import ( MessageFile, Messages, diff --git a/dashscope/cli/__init__.py b/dashscope/cli/__init__.py index 00fd3a9..276e805 100644 --- a/dashscope/cli/__init__.py +++ b/dashscope/cli/__init__.py @@ -7,6 +7,7 @@ """ import sys import warnings +from typing import Optional # Suppress urllib3 NotOpenSSLWarning on systems with LibreSSL warnings.filterwarnings( @@ -18,12 +19,28 @@ import typer # noqa: E402 import dashscope # noqa: E402 +from dashscope.cli.common import err_console # noqa: E402 +from dashscope.common.error import AuthenticationError # noqa: E402 from dashscope.cli import ( # noqa: E402 + application, + code_generation, deployments, + embeddings, files, fine_tunes, generation, + image_generation, + image_synthesis, + models, + multimodal_conversation, + multimodal_embedding, oss, + rerank, + speech_synthesis, + tokenization, + transcription, + understanding, + video_synthesis, ) @@ -69,6 +86,50 @@ "--page_size": "--page-size", } +_TOP_LEVEL_COMMANDS = { + "generation", + "ft", + "fine-tunes", + "files", + "deployments", + "oss", + "rerank", + "embeddings", + "tokenization", + "models", + "understanding", + "application", + "code-generation", + "image-synthesis", + "video-synthesis", + "image-generation", + "multimodal-conversation", + "multimodal-embedding", + "transcription", + "speech-synthesis", + "rl", + "agentic-rl", +} + + +_COMMANDS_WITH_LOCAL_API_KEY = {"oss", "rl", "agentic-rl"} +_LEGACY_COMMANDS_WITH_SIZE_OPTION = { + "files.list", + "fine_tunes.list", + "deployments.list", +} + + +def _translate_param_name(arg): + """Translate legacy underscore option names to Typer hyphen names.""" + if arg in _PARAM_MAP: + return _PARAM_MAP[arg] + if arg.startswith("--") and "=" in arg: + option_name, option_value = arg.split("=", 1) + if option_name in _PARAM_MAP: + return f"{_PARAM_MAP[option_name]}={option_value}" + return arg + def _translate_legacy_args(argv): """Translate legacy argparse command format to Typer format. @@ -81,59 +142,95 @@ def _translate_legacy_args(argv): if len(argv) < 2: return argv - new_argv = [argv[0]] # Keep program name - i = 1 - - # Check if first arg is a legacy command - if i < len(argv) and argv[i] in _COMMAND_MAP: - # Split "fine_tunes.call" into ["fine-tunes", "call"] - new_cmd = _COMMAND_MAP[argv[i]].split() - new_argv.extend(new_cmd) - i += 1 + is_legacy_command = argv[1] in _COMMAND_MAP + if not is_legacy_command: + return [argv[0]] + [_translate_param_name(arg) for arg in argv[1:]] - # Process remaining args + command_args = _COMMAND_MAP[argv[1]].split() + top_level_args = [] + translated_args = [] + i = 2 while i < len(argv): arg = argv[i] - - # Translate parameter names - if arg in _PARAM_MAP: - new_argv.append(_PARAM_MAP[arg]) - elif arg.startswith("--") and "=" in arg: - opt, val = arg.split("=", 1) - if opt in _PARAM_MAP: - new_argv.append(f"{_PARAM_MAP[opt]}={val}") + translated_arg = _translate_param_name(arg) + if argv[1] in _LEGACY_COMMANDS_WITH_SIZE_OPTION: + if translated_arg == "--page-size": + translated_arg = "--size" + elif translated_arg.startswith("--page-size="): + translated_arg = translated_arg.replace( + "--page-size=", + "--size=", + 1, + ) + + if translated_arg == "--api-key": + top_level_args.append("--api-key") + if i + 1 < len(argv): + top_level_args.append(argv[i + 1]) + i += 2 else: - new_argv.append(arg) - else: - new_argv.append(arg) + i += 1 + continue + if translated_arg.startswith("--api-key="): + top_level_args.append(translated_arg) + i += 1 + continue + + translated_args.append(translated_arg) i += 1 - return new_argv + return [argv[0]] + top_level_args + command_args + translated_args + + +def _exit_missing_api_key_value(option_name): + err_console.print( + f"[red]Error:[/red] Option '{option_name}' requires an argument.", + ) + sys.exit(2) def _extract_global_api_key(argv): """Extract global -k/--api-key from argv and set dashscope.api_key. - Returns modified argv with api-key args removed. + For backward compatibility, global api-key can appear before or after most + command names. Commands that define their own local api-key option are left + untouched once their command name has been reached. """ new_argv = [] i = 0 + current_command = None while i < len(argv): arg = argv[i] - # Check for -k or --api-key + if i > 0 and current_command is None and arg in _TOP_LEVEL_COMMANDS: + current_command = arg + + should_parse_global_api_key = ( + current_command not in _COMMANDS_WITH_LOCAL_API_KEY + ) + if arg in ("-k", "--api-key"): - # Next arg should be the key value - if i + 1 < len(argv): - dashscope.api_key = argv[i + 1] - i += 2 # Skip both -k and the value + if i + 1 >= len(argv): + _exit_missing_api_key_value(arg) + next_arg = argv[i + 1] + if not next_arg or next_arg.startswith("-"): + _exit_missing_api_key_value(arg) + if should_parse_global_api_key: + if next_arg in _TOP_LEVEL_COMMANDS: + _exit_missing_api_key_value(arg) + dashscope.api_key = next_arg + i += 2 + continue + + if arg.startswith("--api-key="): + api_key = arg.split("=", 1)[1] + if not api_key: + _exit_missing_api_key_value("--api-key") + if should_parse_global_api_key: + dashscope.api_key = api_key + i += 1 continue - elif arg.startswith("--api-key="): - # Handle --api-key=value format - dashscope.api_key = arg.split("=", 1)[1] - i += 1 - continue new_argv.append(arg) i += 1 @@ -153,6 +250,21 @@ def _extract_global_api_key(argv): rich_markup_mode="rich", ) + +@app.callback() +def callback( + api_key: Optional[str] = typer.Option( + None, + "-k", + "--api-key", + help="DashScope API Key.", + ), +): + """Configure global CLI options.""" + if api_key: + dashscope.api_key = api_key + + # Register sub-command groups app.add_typer(generation.app) app.add_typer(fine_tunes.app, name="ft") @@ -160,6 +272,20 @@ def _extract_global_api_key(argv): app.add_typer(files.app) app.add_typer(deployments.app) app.add_typer(oss.app) +app.add_typer(rerank.app) +app.add_typer(embeddings.app) +app.add_typer(tokenization.app) +app.add_typer(models.app) +app.add_typer(understanding.app) +app.add_typer(application.app) +app.add_typer(code_generation.app) +app.add_typer(image_synthesis.app) +app.add_typer(video_synthesis.app) +app.add_typer(image_generation.app) +app.add_typer(multimodal_conversation.app) +app.add_typer(multimodal_embedding.app) +app.add_typer(transcription.app) +app.add_typer(speech_synthesis.app) def _register_rl_app(): @@ -176,10 +302,22 @@ def _register_rl_app(): name="rl", help="🚀 Agentic RL fine-tuning commands", ) - except ImportError: - pass - except Exception: - pass + app.add_typer( + rl_app, + name="agentic-rl", + help="🚀 Agentic RL fine-tuning commands", + hidden=True, + ) + except ImportError as exception: + err_console.print( + "[yellow]Warning:[/yellow] Failed to register rl command: " + f"{exception}", + ) + except Exception as exception: + err_console.print( + "[yellow]Warning:[/yellow] Failed to register rl command: " + f"{exception}", + ) _register_rl_app() @@ -187,13 +325,18 @@ def _register_rl_app(): def main(): """Entry point for the ``dashscope`` console script.""" - # Extract global api-key parameter FIRST - argv = _extract_global_api_key(sys.argv) + # Translate legacy command format first so legacy --api_key can be treated + # as the global --api-key option. + argv = _translate_legacy_args(sys.argv) - # Then translate legacy command format - argv = _translate_legacy_args(argv) + # Extract global api-key parameter after legacy argument normalization. + argv = _extract_global_api_key(argv) # Update sys.argv for Typer sys.argv = argv - app() + try: + app() + except AuthenticationError as exception: + err_console.print(f"[red]Error:[/red] {exception}") + sys.exit(1) diff --git a/dashscope/cli/agentic_rl.py b/dashscope/cli/agentic_rl.py index 0af67f8..ece3953 100644 --- a/dashscope/cli/agentic_rl.py +++ b/dashscope/cli/agentic_rl.py @@ -24,6 +24,7 @@ ) from dashscope.finetune.reinforcement.common.errors import OutputError from dashscope.finetune.customize_types import FineTune +from dashscope.cli.common import error, normalize_local_path_or_url app = typer.Typer( @@ -35,6 +36,10 @@ ) console = Console() err_console = Console(stderr=True) +SUPPORTED_OUTPUT_FORMATS = {"table", "json", "yaml"} +SUPPORTED_FUNCTION_TYPES = { + function_type.name for function_type in FunctionType +} @app.callback() @@ -64,16 +69,43 @@ def _root_cause(e: Exception) -> Exception: # ================= Configuration & Utility Functions ================= +def validate_output_format(fmt: str) -> str: + """Validate and normalize output format.""" + output_format = fmt.lower() + if output_format not in SUPPORTED_OUTPUT_FORMATS: + err_console.print( + "[red]Error:[/red] Invalid output format. " + "Expected one of: table, json, yaml.", + ) + raise typer.Exit(2) + return output_format + + +def validate_function_type(func_type: str) -> str: + """Validate and normalize function type.""" + function_type = func_type.upper() + if function_type not in SUPPORTED_FUNCTION_TYPES: + expected_types = ", ".join(sorted(SUPPORTED_FUNCTION_TYPES)) + err_console.print( + f"[red]Error:[/red] Invalid function type. " + f"Expected one of: {expected_types}.", + ) + raise typer.Exit(2) + return function_type + + def format_output(data: Any, fmt: str = "table") -> None: """Unified output formatter: table | json | yaml""" + output_format = validate_output_format(fmt) + if hasattr(data, "model_dump"): data = data.model_dump() elif hasattr(data, "__dict__"): data = data.__dict__ - if fmt == "json": + if output_format == "json": print(json.dumps(data, indent=2, ensure_ascii=False)) - elif fmt == "yaml": + elif output_format == "yaml": console.print( yaml.dump(data, default_flow_style=False, allow_unicode=True), ) @@ -188,6 +220,7 @@ async def _register_fc_async( raise typer.Exit(1) +@app.command("register-functions", hidden=True) @app.command("register_functions") def register_fc( rollout_classpaths: Optional[List[str]] = typer.Option( @@ -227,6 +260,7 @@ def register_fc( - rollout_classpath - reward_classpaths """ + output_format = validate_output_format(output_format) result = asyncio.run( _register_fc_async( rollout_classpaths=rollout_classpaths or [], @@ -263,6 +297,7 @@ async def _test_fc_async( raise typer.Exit(1) +@app.command("test-functions", hidden=True) @app.command("test_functions") def test_fc( instance_id: str = typer.Argument( @@ -296,7 +331,14 @@ def test_fc( ): """🧪 Test a registered Rollout/Reward function instance with custom input data.""" - input_dict = load_json_input(input_data) + output_format = validate_output_format(output_format) + func_type = validate_function_type(func_type) + try: + input_dict = load_json_input(input_data) + except ValueError as exception: + err_console.print(f"[red]Error:[/red] {exception}") + raise typer.Exit(1) + result = asyncio.run( _test_fc_async( instance_id=instance_id, @@ -333,6 +375,7 @@ async def _upload_data_async( raise typer.Exit(1) +@app.command("upload-data", hidden=True) @app.command("upload_data") def upload_data( training_files: List[str] = typer.Option( @@ -358,6 +401,17 @@ def upload_data( ): """📦 Upload training/validation datasets to the platform, returns file IDs""" + output_format = validate_output_format(output_format) + training_files = [ + normalize_local_path_or_url(training_file, "--training-files") + for training_file in training_files + ] + if validation_files: + validation_files = [ + normalize_local_path_or_url(validation_file, "--validation-files") + for validation_file in validation_files + ] + result = asyncio.run( _upload_data_async( training_files=training_files, @@ -442,7 +496,12 @@ def run( - reward_classpaths (at least one) - training_files (at least one) """ + output_format = validate_output_format(output_format) _apply_verbose(verbose) + if config and not config.exists(): + error(f"--config file {config} does not exist") + if config and not config.is_file(): + error(f"--config path {config} is not a file") # Prepare workflow parameters run_kwargs = { @@ -495,7 +554,7 @@ def run( fmt=output_format, ) - except (ValueError, Exception) as e: + except Exception as e: root = _root_cause(e) label = ( "Validation error" @@ -528,6 +587,7 @@ def get( output_format: str = typer.Option("table", "--output-format", "-o"), ): """📊 Query the current status and metadata of a specific job""" + output_format = validate_output_format(output_format) try: result = AgenticRL.get(job_id=job_id, api_key=api_key or "") @@ -594,6 +654,7 @@ def logs( output_format: str = typer.Option("table", "--output-format", "-o"), ): """📜 Fetch job execution logs (supports pagination)""" + output_format = validate_output_format(output_format) try: result = AgenticRL.logs( job_id=job_id, @@ -631,6 +692,7 @@ def list_jobs( output_format: str = typer.Option("table", "--output-format", "-o"), ): """📋 List historical fine-tuning jobs with pagination""" + output_format = validate_output_format(output_format) try: result = AgenticRL.list( page_no=page, diff --git a/dashscope/cli/application.py b/dashscope/cli/application.py new file mode 100644 index 0000000..a2ef3e7 --- /dev/null +++ b/dashscope/cli/application.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""``application`` sub-command group.""" +import json +from typing import List, Optional + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error + +app = typer.Typer( + name="application", + help="Application completion 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("create") +@handle_sdk_error("Application request failed") +def create( + app_id: str = typer.Option( + ..., + "-a", + "--app-id", + help="The application id", + ), + prompt: str = typer.Option(..., "-p", "--prompt", help="Input prompt"), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + session_id: Optional[str] = typer.Option( + None, + "--session-id", + help="Session id for multiple rounds call", + ), + doc_tag_codes: Optional[List[str]] = typer.Option( + None, + "--doc-tag-code", + help="Document tag code. Repeat this option for multiple codes.", + ), + doc_reference_type: Optional[str] = typer.Option( + None, + "--doc-reference-type", + help="Document reference type, such as simple or indexed", + ), + has_thoughts: bool = typer.Option( + False, + "--has-thoughts", + help="Return rag or plugin process details", + ), + temperature: Optional[float] = typer.Option( + None, + "--temperature", + help="Sampling temperature", + ), + top_p: Optional[float] = typer.Option(None, "--top-p", help="Top-p value"), + top_k: Optional[int] = typer.Option(None, "--top-k", help="Top-k value"), +): + """Call application completion API.""" + response = dashscope.Application.call( + app_id=app_id, + prompt=prompt, + workspace=workspace, + session_id=session_id, + doc_tag_codes=doc_tag_codes, + doc_reference_type=doc_reference_type, + has_thoughts=has_thoughts, + temperature=temperature, + top_p=top_p, + top_k=top_k, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/code_generation.py b/dashscope/cli/code_generation.py new file mode 100644 index 0000000..4eb2cad --- /dev/null +++ b/dashscope/cli/code_generation.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +"""``code-generation`` sub-command group.""" +import json +from typing import Optional + +import typer + +import dashscope +from dashscope.aigc.code_generation import ( + AttachmentRoleMessageParam, + UserRoleMessageParam, +) +from dashscope.cli.common import console, ensure_ok, error, handle_sdk_error + +app = typer.Typer( + name="code-generation", + help="Code generation 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("create") +@handle_sdk_error("Code generation request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + scene: str = typer.Option( + ..., + "-s", + "--scene", + help="Code generation scene", + ), + content: str = typer.Option( + ..., + "-c", + "--content", + help="User message content", + ), + attachment_meta: Optional[str] = typer.Option( + None, + "--attachment-meta", + help="Attachment meta as a JSON object string", + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + n: int = typer.Option(1, "-n", "--n", help="The number of output results"), +): + """Call code generation API.""" + messages = [UserRoleMessageParam(content=content)] + if attachment_meta is not None: + try: + meta = json.loads(attachment_meta) + except json.JSONDecodeError as exception: + error(f"Invalid attachment meta JSON: {exception.msg}") + if not isinstance(meta, dict): + error("Attachment meta must be a JSON object") + messages.append(AttachmentRoleMessageParam(meta=meta)) + + response = dashscope.CodeGeneration.call( + model=model, + scene=scene, + message=messages, + workspace=workspace, + n=n, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/common.py b/dashscope/cli/common.py index c8788fd..b4e7e66 100644 --- a/dashscope/cli/common.py +++ b/dashscope/cli/common.py @@ -1,13 +1,17 @@ # -*- coding: utf-8 -*- """Shared utilities, constants, and helpers for the dashscope CLI.""" import logging +import os +from functools import wraps from http import HTTPStatus -from typing import NoReturn +from typing import Callable, NoReturn, TypeVar +from urllib.parse import urlparse import typer from rich.console import Console logger = logging.getLogger("dashscope.cli") +CommandFunction = TypeVar("CommandFunction", bound=Callable) # --------------------------------------------------------------------------- # Constants @@ -64,3 +68,33 @@ def error(message: str, exit_code: int = 1) -> NoReturn: """Print an error message in red and exit.""" err_console.print(f"[red]Error:[/red] {message}") raise typer.Exit(exit_code) + + +def handle_sdk_error(action: str): + """Convert unexpected SDK exceptions into friendly CLI errors.""" + + def decorator(command_function: CommandFunction) -> CommandFunction: + @wraps(command_function) + def wrapper(*args, **kwargs): + try: + return command_function(*args, **kwargs) + except typer.Exit: + raise + except Exception as exception: + error(f"{action}: {exception}") + + return wrapper # type: ignore[return-value] + + return decorator + + +def normalize_local_path_or_url(value: str, option_name: str) -> str: + """Return expanded local path or URL, failing early for missing files.""" + parsed_value = urlparse(value) + if parsed_value.scheme: + return value + + file_path = os.path.expanduser(value) + if not os.path.exists(file_path): + error(f"{option_name} file {file_path} does not exist") + return file_path diff --git a/dashscope/cli/deployments.py b/dashscope/cli/deployments.py index a6d574f..d17bdd0 100644 --- a/dashscope/cli/deployments.py +++ b/dashscope/cli/deployments.py @@ -12,6 +12,7 @@ console, err_console, ensure_ok, + handle_sdk_error, logger, success, ) @@ -87,6 +88,7 @@ def _print_deployments(output): @app.command("create") +@handle_sdk_error("Create deployment failed") def create( model: str = typer.Option(..., "-m", "--model", help="The model ID"), suffix: Optional[str] = typer.Option( @@ -116,6 +118,7 @@ def create( # Backward compatibility alias @app.command("call", hidden=True) +@handle_sdk_error("Create deployment failed") def call( model: str = typer.Option(..., "-m", "--model", help="The model ID"), suffix: Optional[str] = typer.Option( @@ -140,6 +143,7 @@ def call( @app.command("get") +@handle_sdk_error("Retrieve deployment failed") def get( deployed_model: str = typer.Argument(..., help="The deployed model name"), ): @@ -154,6 +158,7 @@ def get( @app.command("list") +@handle_sdk_error("List deployments failed") def list_deployments( page: int = typer.Option(1, "-p", "--page", help="Page number"), size: int = typer.Option(10, "-s", "--size", help="Page size"), @@ -168,6 +173,7 @@ def list_deployments( @app.command("scale") +@handle_sdk_error("Scale deployment failed") def scale( deployed_model: str = typer.Argument( ..., @@ -194,6 +200,7 @@ def scale( @app.command("delete") +@handle_sdk_error("Delete deployment failed") def delete( deployed_model: str = typer.Argument(..., help="The deployed model name"), ): diff --git a/dashscope/cli/embeddings.py b/dashscope/cli/embeddings.py new file mode 100644 index 0000000..ae61cca --- /dev/null +++ b/dashscope/cli/embeddings.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +"""``embeddings`` sub-command group.""" +import json +from typing import List, Optional + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error + +app = typer.Typer( + name="embeddings", + help="Text embedding 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("create") +@handle_sdk_error("Text embedding request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + input_texts: List[str] = typer.Option( + ..., + "-i", + "--input", + help="Input text. Repeat this option for multiple texts.", + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + text_type: Optional[str] = typer.Option( + None, + "-t", + "--text-type", + help="Text type, such as query or document", + ), + dimension: Optional[int] = typer.Option( + None, + "--dimension", + help="Output vector dimension", + ), + output_type: Optional[str] = typer.Option( + None, + "--output-type", + help="Output format, such as dense, sparse, or dense&sparse", + ), + instruct: Optional[str] = typer.Option( + None, + "--instruct", + help="Instruction to guide model understanding", + ), +): + """Call text embedding API.""" + embedding_input = input_texts[0] if len(input_texts) == 1 else input_texts + response = dashscope.TextEmbedding.call( + model=model, + input=embedding_input, + workspace=workspace, + text_type=text_type, + dimension=dimension, + output_type=output_type, + instruct=instruct, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/files.py b/dashscope/cli/files.py index a63258e..40e28ee 100644 --- a/dashscope/cli/files.py +++ b/dashscope/cli/files.py @@ -1,13 +1,20 @@ # -*- coding: utf-8 -*- """``files`` sub-command group.""" import json +import os from typing import Optional import typer import dashscope from dashscope.common.constants import FilePurpose -from dashscope.cli.common import console, ensure_ok, success +from dashscope.cli.common import ( + console, + ensure_ok, + error, + handle_sdk_error, + success, +) app = typer.Typer( name="files", @@ -25,6 +32,7 @@ def callback(ctx: typer.Context): @app.command("upload") +@handle_sdk_error("Upload file failed") def upload( file: str = typer.Option( ..., @@ -52,8 +60,12 @@ def upload( ), ): """Upload a file.""" + file_path = os.path.expanduser(file) + if not os.path.exists(file_path): + error(f"File {file_path} does not exist") + rsp = dashscope.Files.upload( - file_path=file, + file_path=file_path, purpose=purpose, description=description, # type: ignore[arg-type] base_address=base_url, @@ -64,6 +76,7 @@ def upload( @app.command("get") +@handle_sdk_error("Retrieve file failed") def get( file_id: str = typer.Argument(..., help="The file ID"), base_url: Optional[str] = typer.Option( @@ -83,6 +96,7 @@ def get( @app.command("list") +@handle_sdk_error("List files failed") def list_files( page: int = typer.Option(1, "-p", "--page", help="Page number"), size: int = typer.Option(10, "-s", "--size", help="Page size"), @@ -107,6 +121,7 @@ def list_files( @app.command("delete") +@handle_sdk_error("Delete file failed") def delete( file_id: str = typer.Argument(..., help="The file ID"), base_url: Optional[str] = typer.Option( diff --git a/dashscope/cli/fine_tunes.py b/dashscope/cli/fine_tunes.py index ba11175..cdd44df 100644 --- a/dashscope/cli/fine_tunes.py +++ b/dashscope/cli/fine_tunes.py @@ -13,6 +13,7 @@ console, err_console, ensure_ok, + handle_sdk_error, logger, print_failed_message, success, @@ -132,6 +133,7 @@ def _dump_logs(job_id: str): @app.command("create") +@handle_sdk_error("Create fine-tune job failed") def create( training_file_ids: List[str] = typer.Option( ..., @@ -207,6 +209,7 @@ def create( # Backward compatibility alias @app.command("call", hidden=True) +@handle_sdk_error("Create fine-tune job failed") def call( training_file_ids: List[str] = typer.Option( ..., @@ -270,6 +273,7 @@ def call( @app.command("get") +@handle_sdk_error("Retrieve fine-tune job failed") def get( job_id: str = typer.Argument(..., help="The fine-tune job id"), ): @@ -290,6 +294,7 @@ def get( @app.command("list") +@handle_sdk_error("List fine-tune jobs failed") def list_jobs( page: int = typer.Option(1, "-p", "--page", help="Page number"), size: int = typer.Option(10, "-s", "--size", help="Page size"), @@ -312,6 +317,7 @@ def list_jobs( @app.command("stream") +@handle_sdk_error("Stream fine-tune events failed") def stream( job_id: str = typer.Argument(..., help="The fine-tune job id"), ): @@ -320,6 +326,7 @@ def stream( @app.command("cancel") +@handle_sdk_error("Cancel fine-tune job failed") def cancel( job_id: str = typer.Argument(..., help="The fine-tune job id"), ): @@ -329,6 +336,7 @@ def cancel( @app.command("delete") +@handle_sdk_error("Delete fine-tune job failed") def delete( job_id: str = typer.Argument(..., help="The fine-tune job id"), ): diff --git a/dashscope/cli/generation.py b/dashscope/cli/generation.py index ee76f34..b624acc 100644 --- a/dashscope/cli/generation.py +++ b/dashscope/cli/generation.py @@ -1,12 +1,16 @@ # -*- coding: utf-8 -*- """``generation`` sub-command group.""" from http import HTTPStatus -from typing import Optional +from typing import Any, Dict, Optional import typer from dashscope.aigc import Generation -from dashscope.cli.common import console, print_failed_message +from dashscope.cli.common import ( + error, + handle_sdk_error, + print_failed_message, +) app = typer.Typer( name="generation", @@ -16,6 +20,58 @@ ) +def _build_generation_kwargs( + messages: Optional[str], + temperature: Optional[float], + top_p: Optional[float], + top_k: Optional[int], + max_tokens: Optional[int], + seed: Optional[int], + stop: Optional[str], + repetition_penalty: Optional[float], + presence_penalty: Optional[float], + enable_search: Optional[bool], + n: Optional[int], + 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: + try: + kwargs["messages"] = json.loads(messages) + except json.JSONDecodeError as exc: + error("--messages must be a valid JSON string") + raise typer.Exit(1) from exc + + # Group simple parameters to reduce branches + simple_params = { + "temperature": temperature, + "top_p": top_p, + "top_k": top_k, + "max_tokens": max_tokens, + "seed": seed, + "repetition_penalty": repetition_penalty, + "presence_penalty": presence_penalty, + "enable_search": enable_search, + "n": n, + "result_format": result_format, + } + + for key, value in simple_params.items(): + if value is not None: + kwargs[key] = value + + if stop is not None: + stop_list = [s.strip() for s in stop.split(",") if s.strip()] + if stop_list: + kwargs["stop"] = stop_list if len(stop_list) > 1 else stop_list[0] + + return kwargs + + @app.callback() def callback(ctx: typer.Context): """Show help if no subcommand is provided.""" @@ -24,6 +80,7 @@ def callback(ctx: typer.Context): @app.command("create") +@handle_sdk_error("Generation request failed") def create( prompt: str = typer.Option(..., "-p", "--prompt", help="Input prompt"), model: str = typer.Option(..., "-m", "--model", help="The model to call"), @@ -33,26 +90,100 @@ def create( "--stream", help="Use stream mode", ), - history: Optional[str] = typer.Option( # pylint: disable=unused-argument + messages: Optional[str] = typer.Option( None, - "--history", - help="The history of the request", + "--messages", + help="JSON string of message list for multi-turn conversation", + ), + temperature: Optional[float] = typer.Option( + None, + "--temperature", + help="Controls randomness, range [0, 2)", + ), + top_p: Optional[float] = typer.Option( + None, + "--top-p", + help="Nucleus sampling parameter, range (0, 1.0]", + ), + top_k: Optional[int] = typer.Option( + None, + "--top-k", + help="Size of candidate token set for sampling", + ), + max_tokens: Optional[int] = typer.Option( + None, + "--max-tokens", + help="Maximum number of output tokens", + ), + seed: Optional[int] = typer.Option( + None, + "--seed", + help="Random seed for reproducibility", + ), + stop: Optional[str] = typer.Option( + None, + "--stop", + help="Stop sequence(s), comma-separated for multiple", + ), + repetition_penalty: Optional[float] = typer.Option( + None, + "--repetition-penalty", + help="Penalizes repeated sequences, 1.0 means no penalty", + ), + presence_penalty: Optional[float] = typer.Option( + None, + "--presence-penalty", + help="Controls content repetition, range [-2.0, 2.0]", + ), + enable_search: Optional[bool] = typer.Option( + None, + "--enable-search", + help="Enable web search", + ), + n: Optional[int] = typer.Option( + None, + "--n", + help="Number of responses to generate (1-4)", + ), + result_format: Optional[str] = typer.Option( + None, + "--result-format", + help="Output format: 'message' or 'text'", ), ): """Call text generation API.""" - response = Generation.call(model, prompt, stream=stream) + kwargs = _build_generation_kwargs( + messages=messages, + temperature=temperature, + top_p=top_p, + top_k=top_k, + max_tokens=max_tokens, + seed=seed, + stop=stop, + repetition_penalty=repetition_penalty, + presence_penalty=presence_penalty, + enable_search=enable_search, + n=n, + result_format=result_format, + ) + + response = Generation.call(model, prompt, stream=stream, **kwargs) if stream: for rsp in response: if rsp.status_code == HTTPStatus.OK: - console.print(rsp.output) - console.print(rsp.usage) + typer.echo(rsp.output) + usage = getattr(rsp, "usage", None) + if usage: + typer.echo(usage) else: print_failed_message(rsp) else: if response.status_code == HTTPStatus.OK: - console.print(response.output) - console.print(response.usage) + typer.echo(response.output) + usage = getattr(response, "usage", None) + if usage: + typer.echo(usage) else: print_failed_message(response) raise typer.Exit(1) diff --git a/dashscope/cli/image_generation.py b/dashscope/cli/image_generation.py new file mode 100644 index 0000000..19bef67 --- /dev/null +++ b/dashscope/cli/image_generation.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""``image-generation`` sub-command group.""" +import json +from typing import List, Optional + +import typer + +from dashscope.aigc.image_generation import ImageGeneration +from dashscope.api_entities.dashscope_response import Message, Role +from dashscope.cli.common import ( + console, + ensure_ok, + handle_sdk_error, + normalize_local_path_or_url, +) + +app = typer.Typer( + name="image-generation", + help="Image generation 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("create") +@handle_sdk_error("Image generation request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + text: str = typer.Option(..., "-t", "--text", help="User text prompt"), + images: Optional[List[str]] = typer.Option( + None, + "--image", + help=( + "Reference image URL or local file path, " + "can be used multiple times" + ), + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + size: Optional[str] = typer.Option( + None, + "--size", + help="Output image size", + ), + n: Optional[int] = typer.Option( + None, + "-n", + "--n", + help="Number of images", + ), + max_images: Optional[int] = typer.Option( + None, + "--max-images", + help="Maximum number of images", + ), +): + """Call image generation API.""" + content = [{"text": text}] + if images: + content.extend( + {"image": normalize_local_path_or_url(image, "--image")} + for image in images + ) + + response = ImageGeneration.call( + model=model, + messages=[Message(role=Role.USER, content=content)], + workspace=workspace, + size=size, + n=n, + max_images=max_images, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/image_synthesis.py b/dashscope/cli/image_synthesis.py new file mode 100644 index 0000000..6c6651f --- /dev/null +++ b/dashscope/cli/image_synthesis.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""``image-synthesis`` sub-command group.""" +import json +from typing import Optional + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error + +app = typer.Typer( + name="image-synthesis", + help="Image synthesis 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("create") +@handle_sdk_error("Image synthesis request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + prompt: str = typer.Option(..., "-p", "--prompt", help="Input prompt"), + negative_prompt: Optional[str] = typer.Option( + None, + "--negative-prompt", + help="Negative prompt", + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + n: Optional[int] = typer.Option( + None, + "-n", + "--n", + help="Number of images", + ), + size: Optional[str] = typer.Option( + None, + "--size", + help="Output image size, such as 1024*1024", + ), +): + """Call image synthesis API.""" + response = dashscope.ImageSynthesis.call( + model=model, + prompt=prompt, + negative_prompt=negative_prompt, + workspace=workspace, + n=n, + size=size, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/models.py b/dashscope/cli/models.py new file mode 100644 index 0000000..dc0dc74 --- /dev/null +++ b/dashscope/cli/models.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""``models`` sub-command group.""" +import json + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error + +app = typer.Typer( + name="models", + help="Model 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("list") +@handle_sdk_error("List models failed") +def list_models( + page: int = typer.Option(1, "-p", "--page", help="Page number"), + page_size: int = typer.Option( + 10, + "-s", + "--page-size", + help="Items per page", + ), +): + """List available models.""" + response = dashscope.Models.list(page=page, page_size=page_size) + output = ensure_ok(response) + if output: + console.print_json(json.dumps(output, ensure_ascii=False)) + else: + console.print("There are no models.") + + +@app.command("get") +@handle_sdk_error("Retrieve model failed") +def get_model(name: str = typer.Argument(..., help="The model name")): + """Get model information.""" + response = dashscope.Models.get(name=name) + output = ensure_ok(response) + if output: + console.print_json(json.dumps(output, ensure_ascii=False)) + else: + console.print("There is no model.") diff --git a/dashscope/cli/multimodal_conversation.py b/dashscope/cli/multimodal_conversation.py new file mode 100644 index 0000000..a113399 --- /dev/null +++ b/dashscope/cli/multimodal_conversation.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +"""``multimodal-conversation`` sub-command group.""" +import json +from typing import List, Optional + +import typer + +import dashscope +from dashscope.cli.common import ( + console, + ensure_ok, + handle_sdk_error, + normalize_local_path_or_url, +) + +app = typer.Typer( + name="multimodal-conversation", + help="Multimodal conversation 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("create") +@handle_sdk_error("Multimodal conversation request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + text: str = typer.Option(..., "-t", "--text", help="User text content"), + images: Optional[List[str]] = typer.Option( + None, + "--image", + help="Image URL or local file path, can be used multiple times", + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + result_format: Optional[str] = typer.Option( + None, + "--result-format", + help="Result format, such as message or text", + ), + temperature: Optional[float] = typer.Option( + None, + "--temperature", + help="Sampling temperature", + ), + top_p: Optional[float] = typer.Option( + None, + "--top-p", + help="Top-p sampling", + ), + top_k: Optional[int] = typer.Option( + None, + "--top-k", + help="Top-k sampling", + ), + max_tokens: Optional[int] = typer.Option( + None, + "--max-tokens", + help="Maximum output tokens", + ), + seed: Optional[int] = typer.Option(None, "--seed", help="Random seed"), +): + """Call multimodal conversation API.""" + content = [] + if images: + content.extend( + {"image": normalize_local_path_or_url(image, "--image")} + for image in images + ) + content.append({"text": text}) + + response = dashscope.MultiModalConversation.call( + model=model, + messages=[{"role": "user", "content": content}], + workspace=workspace, + result_format=result_format, + temperature=temperature, + top_p=top_p, + top_k=top_k, + max_tokens=max_tokens, + seed=seed, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/multimodal_embedding.py b/dashscope/cli/multimodal_embedding.py new file mode 100644 index 0000000..ea4db7b --- /dev/null +++ b/dashscope/cli/multimodal_embedding.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""``multimodal-embedding`` sub-command group.""" +import json +from typing import List, Optional + +import typer + +import dashscope +from dashscope.cli.common import ( + console, + ensure_ok, + error, + handle_sdk_error, + normalize_local_path_or_url, +) + +app = typer.Typer( + name="multimodal-embedding", + help="Multimodal embedding 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("create") +@handle_sdk_error("Multimodal embedding request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + texts: Optional[List[str]] = typer.Option( + None, + "--text", + help="Text input, can be used multiple times", + ), + images: Optional[List[str]] = typer.Option( + None, + "--image", + help="Image URL or local file path, can be used multiple times", + ), + audios: Optional[List[str]] = typer.Option( + None, + "--audio", + help="Audio URL or local file path, can be used multiple times", + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + dimension: Optional[int] = typer.Option( + None, + "--dimension", + help="Output vector dimension", + ), + output_type: Optional[str] = typer.Option( + None, + "--output-type", + help="Output vector format", + ), + fps: Optional[float] = typer.Option( + None, + "--fps", + help="Video frame extraction ratio", + ), + instruct: Optional[str] = typer.Option( + None, + "--instruct", + help="Task instruction", + ), + enable_fusion: Optional[bool] = typer.Option( + None, + "--enable-fusion/--disable-fusion", + help="Whether to fuse all contents into one vector", + ), + res_level: Optional[int] = typer.Option( + None, + "--res-level", + help="Resolution tier", + ), + max_video_frames: Optional[int] = typer.Option( + None, + "--max-video-frames", + help="Max video sampling frames", + ), +): + """Call multimodal embedding API.""" + embedding_input = [] + if texts: + embedding_input.extend({"text": text} for text in texts) + if images: + embedding_input.extend( + {"image": normalize_local_path_or_url(image, "--image")} + for image in images + ) + if audios: + embedding_input.extend( + {"audio": normalize_local_path_or_url(audio, "--audio")} + for audio in audios + ) + if not embedding_input: + error("At least one --text, --image or --audio is required") + + response = dashscope.MultiModalEmbedding.call( + model=model, + input=embedding_input, + workspace=workspace, + dimension=dimension, + output_type=output_type, + fps=fps, + instruct=instruct, + enable_fusion=enable_fusion, + res_level=res_level, + max_video_frames=max_video_frames, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/oss.py b/dashscope/cli/oss.py index cd3cca1..80bb815 100644 --- a/dashscope/cli/oss.py +++ b/dashscope/cli/oss.py @@ -7,7 +7,7 @@ import dashscope from dashscope.utils.oss_utils import OssUtils -from dashscope.cli.common import console, error, success +from dashscope.cli.common import console, error, handle_sdk_error, success app = typer.Typer( name="oss", @@ -25,6 +25,7 @@ def callback(ctx: typer.Context): @app.command("upload") +@handle_sdk_error("OSS upload failed") def upload( file: str = typer.Option( ..., diff --git a/dashscope/cli/rerank.py b/dashscope/cli/rerank.py new file mode 100644 index 0000000..07ea030 --- /dev/null +++ b/dashscope/cli/rerank.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +"""``rerank`` sub-command group.""" +import json +from typing import List, Optional + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error + +app = typer.Typer( + name="rerank", + help="Text rerank 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("create") +@handle_sdk_error("Text rerank request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + query: str = typer.Option(..., "-q", "--query", help="The query text"), + documents: List[str] = typer.Option( + ..., + "-d", + "--document", + help=( + "Document text to rank. " + "Repeat this option for multiple documents." + ), + ), + return_documents: Optional[bool] = typer.Option( + None, + "--return-documents/--no-return-documents", + help="Whether to return original documents in results", + ), + top_n: Optional[int] = typer.Option( + None, + "-n", + "--top-n", + help="How many documents to return", + ), + instruct: Optional[str] = typer.Option( + None, + "-i", + "--instruct", + help="Instruction to guide ranking strategy", + ), +): + """Call text rerank API.""" + response = dashscope.TextReRank.call( + model=model, + query=query, + documents=documents, + return_documents=return_documents, + top_n=top_n, + instruct=instruct, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/speech_synthesis.py b/dashscope/cli/speech_synthesis.py new file mode 100644 index 0000000..dc04dac --- /dev/null +++ b/dashscope/cli/speech_synthesis.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""``speech-synthesis`` sub-command group.""" +import json +from typing import Optional + +import typer + +import dashscope +from dashscope.cli.common import console, handle_sdk_error + +app = typer.Typer( + name="speech-synthesis", + help="Speech synthesis 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("create") +@handle_sdk_error("Speech synthesis request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + text: str = typer.Option(..., "-t", "--text", help="Text to synthesize"), + voice: str = typer.Option(..., "--voice", help="Voice name"), + audio_format: str = typer.Option( + "wav", + "--audio-format", + help="Audio format, such as wav, pcm or mp3", + ), + sample_rate: int = typer.Option( + 24000, + "--sample-rate", + help="Audio sample rate", + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + url: Optional[str] = typer.Option( + None, + "--url", + help="Custom HTTP base URL", + ), + volume: Optional[int] = typer.Option( + None, + "--volume", + help="Volume value", + ), + rate: Optional[int] = typer.Option( + None, + "--rate", + help="Speech rate value", + ), + pitch: Optional[int] = typer.Option(None, "--pitch", help="Pitch value"), +): + """Call HTTP speech synthesis API.""" + result = dashscope.HttpSpeechSynthesizer.call( + model=model, + text=text, + voice=voice, + audio_format=audio_format, + sample_rate=sample_rate, + workspace=workspace, + url=url, + volume=volume, + rate=rate, + pitch=pitch, + ) + output = { + "audio_url": result.audio_url, + "audio_id": result.audio_id, + "expires_at": result.expires_at, + "sentences": result.sentences, + } + console.print_json(json.dumps(output, ensure_ascii=False)) diff --git a/dashscope/cli/tokenization.py b/dashscope/cli/tokenization.py new file mode 100644 index 0000000..a869d01 --- /dev/null +++ b/dashscope/cli/tokenization.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""``tokenization`` sub-command group.""" +import json +from typing import Optional + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error + +app = typer.Typer( + name="tokenization", + help="Tokenization 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("create") +@handle_sdk_error("Tokenization request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + prompt: str = typer.Option(..., "-p", "--prompt", help="Input prompt"), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + enable_search: bool = typer.Option( + False, + "--enable-search", + help="Enable search for supported qwen models", + ), +): + """Call tokenization API.""" + response = dashscope.Tokenization.call( + model=model, + prompt=prompt, + workspace=workspace, + enable_search=enable_search, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/transcription.py b/dashscope/cli/transcription.py new file mode 100644 index 0000000..fb2e449 --- /dev/null +++ b/dashscope/cli/transcription.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +"""``transcription`` sub-command group.""" +import json +from typing import List, Optional + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error + +app = typer.Typer( + name="transcription", + help="Speech transcription 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("create") +@handle_sdk_error("Transcription request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + file_urls: List[str] = typer.Option( + ..., + "--file-url", + help="Audio file URL, can be used multiple times", + ), + phrase_id: Optional[str] = typer.Option( + None, + "--phrase-id", + help="Phrase id", + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + channel_ids: Optional[List[int]] = typer.Option( + None, + "--channel-id", + help="Selected channel id, can be used multiple times", + ), + disfluency_removal_enabled: bool = typer.Option( + False, + "--disfluency-removal-enabled", + help="Whether to remove disfluency words", + ), + diarization_enabled: bool = typer.Option( + False, + "--diarization-enabled", + help="Whether to enable speaker diarization", + ), + speaker_count: Optional[int] = typer.Option( + None, + "--speaker-count", + help="Speaker count", + ), + timestamp_alignment_enabled: bool = typer.Option( + False, + "--timestamp-alignment-enabled", + help="Whether to enable timestamp alignment", + ), + special_word_filter: Optional[str] = typer.Option( + None, + "--special-word-filter", + help="Special word filter", + ), + audio_event_detection_enabled: bool = typer.Option( + False, + "--audio-event-detection-enabled", + help="Whether to enable audio event detection", + ), +): + """Call speech transcription API.""" + response = dashscope.Transcription.call( + model=model, + file_urls=file_urls, + phrase_id=phrase_id, + workspace=workspace, + channel_id=channel_ids, + disfluency_removal_enabled=disfluency_removal_enabled, + diarization_enabled=diarization_enabled, + speaker_count=speaker_count, + timestamp_alignment_enabled=timestamp_alignment_enabled, + special_word_filter=special_word_filter, + audio_event_detection_enabled=audio_event_detection_enabled, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/understanding.py b/dashscope/cli/understanding.py new file mode 100644 index 0000000..f7c2dd8 --- /dev/null +++ b/dashscope/cli/understanding.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""``understanding`` sub-command group.""" +import json +from typing import Optional + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error + +app = typer.Typer( + name="understanding", + help="Natural language understanding 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("create") +@handle_sdk_error("Understanding request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + sentence: str = typer.Option( + ..., + "-s", + "--sentence", + help="The sentence to process", + ), + labels: str = typer.Option( + ..., + "-l", + "--labels", + help="Labels separated by Chinese commas", + ), + task: Optional[str] = typer.Option( + None, + "-t", + "--task", + help="Task type, such as extraction or classification", + ), +): + """Call natural language understanding API.""" + response = dashscope.Understanding.call( + model=model, + sentence=sentence, + labels=labels, + task=task, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) diff --git a/dashscope/cli/video_synthesis.py b/dashscope/cli/video_synthesis.py new file mode 100644 index 0000000..3039832 --- /dev/null +++ b/dashscope/cli/video_synthesis.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""``video-synthesis`` sub-command group.""" +import json +from typing import Optional + +import typer + +import dashscope +from dashscope.cli.common import console, ensure_ok, handle_sdk_error, success + +app = typer.Typer( + name="video-synthesis", + help="Video synthesis 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("create") +@handle_sdk_error("Video synthesis request failed") +def create( + model: str = typer.Option(..., "-m", "--model", help="The model to call"), + prompt: Optional[str] = typer.Option( + None, + "-p", + "--prompt", + help="Input prompt", + ), + negative_prompt: Optional[str] = typer.Option( + None, + "--negative-prompt", + help="Negative prompt", + ), + img_url: Optional[str] = typer.Option( + None, + "--img-url", + help="Input image URL", + ), + first_frame_url: Optional[str] = typer.Option( + None, + "--first-frame-url", + help="First frame image URL", + ), + last_frame_url: Optional[str] = typer.Option( + None, + "--last-frame-url", + help="Last frame image URL", + ), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), + size: Optional[str] = typer.Option( + None, + "--size", + help="Output video size", + ), + duration: Optional[int] = typer.Option( + None, + "--duration", + help="Video duration", + ), + seed: Optional[int] = typer.Option(None, "--seed", help="Random seed"), + prompt_extend: Optional[bool] = typer.Option( + None, + "--prompt-extend/--no-prompt-extend", + help="Whether to extend prompt automatically", + ), + watermark: Optional[bool] = typer.Option( + None, + "--watermark/--no-watermark", + help="Whether to add watermark", + ), + resolution: Optional[str] = typer.Option( + None, + "--resolution", + help="Output resolution", + ), + ratio: Optional[str] = typer.Option(None, "--ratio", help="Aspect ratio"), +): + """Call video synthesis API.""" + response = dashscope.VideoSynthesis.call( + model=model, + prompt=prompt, + negative_prompt=negative_prompt, + img_url=img_url, + first_frame_url=first_frame_url, + last_frame_url=last_frame_url, + workspace=workspace, + size=size, + duration=duration, + seed=seed, + prompt_extend=prompt_extend, + watermark=watermark, + resolution=resolution, + ratio=ratio, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + usage = getattr(response, "usage", None) + if usage: + console.print_json(json.dumps(usage, ensure_ascii=False)) + + +@app.command("fetch") +@handle_sdk_error("Fetch video synthesis task failed") +def fetch( + task_id: str = typer.Argument(..., help="The video synthesis task id"), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), +): + """Fetch video synthesis task status or result.""" + response = dashscope.VideoSynthesis.fetch(task_id, workspace=workspace) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + + +@app.command("wait") +@handle_sdk_error("Wait video synthesis task failed") +def wait( + task_id: str = typer.Argument(..., help="The video synthesis task id"), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), +): + """Wait for a video synthesis task to complete.""" + response = dashscope.VideoSynthesis.wait(task_id, workspace=workspace) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) + + +@app.command("cancel") +@handle_sdk_error("Cancel video synthesis task failed") +def cancel( + task_id: str = typer.Argument(..., help="The video synthesis task id"), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), +): + """Cancel a pending video synthesis task.""" + ensure_ok(dashscope.VideoSynthesis.cancel(task_id, workspace=workspace)) + success(f"Cancel video synthesis task: {task_id} success") + + +@app.command("list") +@handle_sdk_error("List video synthesis tasks failed") +def list_tasks( + start_time: Optional[str] = typer.Option( + None, + "--start-time", + help="Task start time", + ), + end_time: Optional[str] = typer.Option( + None, + "--end-time", + help="Task end time", + ), + model_name: Optional[str] = typer.Option( + None, + "--model-name", + help="Model name", + ), + api_key_id: Optional[str] = typer.Option( + None, + "--api-key-id", + help="API key id", + ), + region: Optional[str] = typer.Option( + None, + "--region", + help="Service region", + ), + status: Optional[str] = typer.Option(None, "--status", help="Task status"), + page: int = typer.Option(1, "-p", "--page", help="Page number"), + size: int = typer.Option(10, "-s", "--size", help="Page size"), + workspace: Optional[str] = typer.Option( + None, + "-w", + "--workspace", + help="The DashScope workspace id", + ), +): + """List video synthesis tasks.""" + response = dashscope.VideoSynthesis.list( + start_time=start_time, + end_time=end_time, + model_name=model_name, + api_key_id=api_key_id, + region=region, + status=status, + page_no=page, + page_size=size, + workspace=workspace, + ) + output = ensure_ok(response) + console.print_json(json.dumps(output, ensure_ascii=False)) diff --git a/dashscope/models.py b/dashscope/models.py index 764e088..5a1a57e 100644 --- a/dashscope/models.py +++ b/dashscope/models.py @@ -2,7 +2,9 @@ # Copyright (c) Alibaba, Inc. and its affiliates. from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import GetMixin, ListMixin +from dashscope.client.base_api import GetMixin, ListMixin, _get +from dashscope.common.utils import join_url +import dashscope class Models(ListMixin, GetMixin): @@ -25,7 +27,33 @@ def get( # type: ignore[override] Returns: DashScopeAPIResponse: The model information. """ - return super().get(name, api_key, **kwargs) # type: ignore + from http import HTTPStatus + + # Use query parameter to filter by model name on server side + # API endpoint: /api/v1/models?model={name}&page_no=1&page_size=1 + url = join_url(dashscope.base_http_api_url, cls.SUB_PATH.lower()) + params = {"model": name, "page_no": 1, "page_size": 1} + + response = _get( + url, + params=params, + api_key=api_key, + **kwargs, + ) + + if response.status_code != HTTPStatus.OK: + return response # type: ignore[return-value] + + output = response.output + if not output or "models" not in output or not output["models"]: + response.status_code = 404 + response.message = f"Model '{name}' not found" + response.output = None + return response # type: ignore[return-value] + + # Return the first (and only) model from the filtered list + response.output = output["models"][0] + return response # type: ignore[return-value] @classmethod def list( # type: ignore[override] diff --git a/dashscope/version.py b/dashscope/version.py index eb003d0..4ddaef7 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.25.24" +__version__ = "1.26.0" diff --git a/setup.cfg b/setup.cfg index 7489038..e49415d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1 +1,6 @@ -[bdist_wheel] \ No newline at end of file +[bdist_wheel] + +[flake8] +extend-ignore = E203 +per-file-ignores = + dashscope/__init__.py:E402 \ No newline at end of file diff --git a/tests/unit/mock_server.py b/tests/unit/mock_server.py index 10df414..0713088 100644 --- a/tests/unit/mock_server.py +++ b/tests/unit/mock_server.py @@ -398,21 +398,40 @@ async def handle_delete_file(request: aiohttp.request): async def list_models_handler( request: aiohttp.request, ): # pylint: disable=unused-argument - response = { - "code": "200", - "data": { - "models": [ - { - "model_id": "1111", - "gmt_create": "2023-03-15 14:25:50", - }, - { - "model_id": "2222", - "gmt_create": "2023-03-15 14:25:50", - }, - ], - }, - } + # Check if model query parameter is present (for Models.get) + model_param = request.query.get("model") + + if model_param: + # Return single model for Models.get + assert model_param == TEST_JOB_ID + response = { + "code": "200", + "output": { + "models": [ + { + "model_id": TEST_JOB_ID, + "name": "gpt3", + }, + ], + }, + } + else: + # Return model list for Models.list + response = { + "code": "200", + "data": { + "models": [ + { + "model_id": "1111", + "gmt_create": "2023-03-15 14:25:50", + }, + { + "model_id": "2222", + "gmt_create": "2023-03-15 14:25:50", + }, + ], + }, + } return web.json_response( text=json.dumps(response), content_type="application/json", @@ -420,13 +439,31 @@ async def list_models_handler( async def get_model_handler(request: aiohttp.request): - model_id = request.match_info["id"] + # Support both path parameter and query parameter + model_id = request.match_info.get("id") + if not model_id: + # Fallback to query parameter + model_id = request.query.get("model") + + if not model_id: + return web.json_response( + text=json.dumps( + {"code": "400", "message": "Model name is required"}, + ), + status=400, + content_type="application/json", + ) + assert model_id == TEST_JOB_ID response = { "code": "200", - "data": { - "model_id": TEST_JOB_ID, - "name": "gpt3", + "output": { + "models": [ + { + "model_id": TEST_JOB_ID, + "name": "gpt3", + }, + ], }, } return web.json_response( diff --git a/tests/unit/test_cli_application.py b/tests/unit/test_cli_application.py new file mode 100644 index 0000000..957f830 --- /dev/null +++ b/tests/unit/test_cli_application.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import application + + +class TestCliApplication: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "text": "API接口说明中,通过parameters的topP属性设置。", + "finish_reason": "stop", + }, + usage={ + "models": [ + { + "model_id": "qwen-turbo", + "input_tokens": 10, + "output_tokens": 8, + }, + ], + }, + ) + + monkeypatch.setattr( + application.dashscope.Application, + "call", + mock_call, + ) + + result = CliRunner().invoke( + application.app, + [ + "create", + "--app-id", + "app-1234", + "--prompt", + "API接口说明中, TopP参数该如何传递?", + "--workspace", + "ws-1234", + "--session-id", + "session-1234", + "--doc-tag-code", + "tag-1", + "--doc-tag-code", + "tag-2", + "--doc-reference-type", + "simple", + "--has-thoughts", + "--temperature", + "1.0", + "--top-p", + "0.2", + "--top-k", + "50", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "app_id": "app-1234", + "prompt": "API接口说明中, TopP参数该如何传递?", + "workspace": "ws-1234", + "session_id": "session-1234", + "doc_tag_codes": ["tag-1", "tag-2"], + "doc_reference_type": "simple", + "has_thoughts": True, + "temperature": 1.0, + "top_p": 0.2, + "top_k": 50, + } + assert "finish_reason" in result.output + assert "input_tokens" in result.output diff --git a/tests/unit/test_cli_code_generation.py b/tests/unit/test_cli_code_generation.py new file mode 100644 index 0000000..c421e5e --- /dev/null +++ b/tests/unit/test_cli_code_generation.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import code_generation + + +class TestCliCodeGeneration: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "choices": [ + { + "finish_reason": "stop", + "content": "def file_size(path): pass", + }, + ], + }, + usage={"input_tokens": 10, "output_tokens": 8}, + ) + + monkeypatch.setattr( + code_generation.dashscope.CodeGeneration, + "call", + mock_call, + ) + + result = CliRunner().invoke( + code_generation.app, + [ + "create", + "--model", + "tongyi-lingma-v1", + "--scene", + "nl2code", + "--content", + "计算给定路径下所有文件的总大小", + "--attachment-meta", + '{"language":"python"}', + "--workspace", + "workspace-id", + "--n", + "1", + ], + ) + + assert result.exit_code == 0 + assert captured_request["model"] == "tongyi-lingma-v1" + assert captured_request["scene"] == "nl2code" + assert captured_request["workspace"] == "workspace-id" + assert captured_request["n"] == 1 + assert captured_request["message"][0]["role"] == "user" + assert captured_request["message"][0]["content"] == "计算给定路径下所有文件的总大小" + assert captured_request["message"][1]["role"] == "attachment" + assert captured_request["message"][1]["meta"] == {"language": "python"} + assert "file_size" in result.output + assert "input_tokens" in result.output diff --git a/tests/unit/test_cli_embeddings.py b/tests/unit/test_cli_embeddings.py new file mode 100644 index 0000000..9fd69d3 --- /dev/null +++ b/tests/unit/test_cli_embeddings.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import embeddings + + +class TestCliEmbeddings: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "embeddings": [ + { + "text_index": 0, + "embedding": [0.1, 0.2], + }, + ], + }, + usage={"total_tokens": 2}, + ) + + monkeypatch.setattr( + embeddings.dashscope.TextEmbedding, + "call", + mock_call, + ) + + result = CliRunner().invoke( + embeddings.app, + [ + "create", + "--model", + "text-embedding-v3", + "--input", + "hello", + "--input", + "world", + "--workspace", + "workspace-id", + "--text-type", + "query", + "--dimension", + "1024", + "--output-type", + "dense", + "--instruct", + "Represent the query for retrieval.", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "text-embedding-v3", + "input": ["hello", "world"], + "workspace": "workspace-id", + "text_type": "query", + "dimension": 1024, + "output_type": "dense", + "instruct": "Represent the query for retrieval.", + } + assert "embeddings" in result.output + assert "total_tokens" in result.output diff --git a/tests/unit/test_cli_image_generation.py b/tests/unit/test_cli_image_generation.py new file mode 100644 index 0000000..f43a066 --- /dev/null +++ b/tests/unit/test_cli_image_generation.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import image_generation + + +class TestCliImageGeneration: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "choices": [ + { + "message": { + "content": [ + { + "image": ( + "https://example.com/generated.png" + ), + }, + ], + }, + }, + ], + }, + usage={"input_tokens": 10, "output_tokens": 8}, + ) + + monkeypatch.setattr( + image_generation.ImageGeneration, + "call", + mock_call, + ) + + result = CliRunner().invoke( + image_generation.app, + [ + "create", + "--model", + "wan2.6-image", + "--text", + "参考图的风格生成番茄炒蛋", + "--image", + "https://example.com/reference-1.png", + "--image", + "https://example.com/reference-2.png", + "--workspace", + "workspace-id", + "--size", + "1024*1024", + "--n", + "1", + "--max-images", + "3", + ], + ) + + assert result.exit_code == 0 + assert captured_request["model"] == "wan2.6-image" + assert captured_request["workspace"] == "workspace-id" + assert captured_request["size"] == "1024*1024" + assert captured_request["n"] == 1 + assert captured_request["max_images"] == 3 + assert len(captured_request["messages"]) == 1 + message = captured_request["messages"][0] + assert message["role"] == "user" + assert message["content"] == [ + {"text": "参考图的风格生成番茄炒蛋"}, + {"image": "https://example.com/reference-1.png"}, + {"image": "https://example.com/reference-2.png"}, + ] + assert "generated.png" in result.output + assert "input_tokens" in result.output diff --git a/tests/unit/test_cli_image_synthesis.py b/tests/unit/test_cli_image_synthesis.py new file mode 100644 index 0000000..2afbb17 --- /dev/null +++ b/tests/unit/test_cli_image_synthesis.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import image_synthesis + + +class TestCliImageSynthesis: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "task_id": "task-1234", + "task_status": "SUCCEEDED", + "results": [ + { + "url": "https://example.com/image.png", + }, + ], + }, + usage={"image_count": 1}, + ) + + monkeypatch.setattr( + image_synthesis.dashscope.ImageSynthesis, + "call", + mock_call, + ) + + result = CliRunner().invoke( + image_synthesis.app, + [ + "create", + "--model", + "wanx2.1-t2i-turbo", + "--prompt", + "一间有着精致窗户的花店", + "--negative-prompt", + "低清晰度", + "--workspace", + "workspace-id", + "--n", + "1", + "--size", + "1024*1024", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "wanx2.1-t2i-turbo", + "prompt": "一间有着精致窗户的花店", + "negative_prompt": "低清晰度", + "workspace": "workspace-id", + "n": 1, + "size": "1024*1024", + } + assert "task-1234" in result.output + assert "image_count" in result.output diff --git a/tests/unit/test_cli_main.py b/tests/unit/test_cli_main.py new file mode 100644 index 0000000..6999fc3 --- /dev/null +++ b/tests/unit/test_cli_main.py @@ -0,0 +1,840 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +import os +import re +import subprocess +import sys +from types import SimpleNamespace + +import pytest +from typer.testing import CliRunner + +import dashscope +from dashscope.cli import agentic_rl +from dashscope.cli import app as cli_app +from dashscope.cli import main as cli_main +from dashscope.common.error import AuthenticationError + + +def strip_ansi_codes(text): + """Remove ANSI escape codes from text.""" + ansi_escape = re.compile(r"\x1b\[[0-9;]*m") + return ansi_escape.sub("", text) + + +# pylint: disable=too-many-public-methods +class TestCliMain: + def test_main_prints_authentication_error_without_traceback( + self, + monkeypatch, + capsys, + ): + def mock_list(**kwargs): + raise AuthenticationError("No api key provided.") + + monkeypatch.setattr( + "dashscope.cli.models.dashscope.Models.list", + mock_list, + ) + monkeypatch.setattr(sys, "argv", ["dashscope", "models", "list"]) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.code == 1 + assert "No api key provided." in combined_output + assert "Traceback" not in combined_output + + def test_top_level_help_shows_global_api_key(self): + result = CliRunner().invoke(cli_app, ["--help"]) + + assert result.exit_code == 0 + clean_output = strip_ansi_codes(result.output) + assert "--api-key" in clean_output + + def test_python_module_help_suppresses_urllib3_warning(self): + result = subprocess.run( + [sys.executable, "-m", "dashscope.cli", "--help"], + check=False, + capture_output=True, + text=True, + ) + combined_output = result.stdout + result.stderr + + assert result.returncode == 0 + assert "NotOpenSSLWarning" not in combined_output + + def test_rl_test_functions_invalid_input_without_traceback(self): + result = CliRunner().invoke( + agentic_rl.app, + [ + "test-functions", + "ins-1234", + "--type", + "ROLLOUT", + "--input", + "not-json", + ], + ) + + assert result.exit_code == 1 + assert "Invalid input" in result.output + assert "Traceback" not in result.output + + def test_rl_register_functions_hyphen_alias_help(self): + result = CliRunner().invoke( + agentic_rl.app, + ["register-functions", "--help"], + ) + + assert result.exit_code == 0 + clean_output = strip_ansi_codes(result.output) + assert "rollout-classpaths" in clean_output + + def test_rl_test_functions_hyphen_alias_help(self): + result = CliRunner().invoke( + agentic_rl.app, + ["test-functions", "--help"], + ) + + assert result.exit_code == 0 + clean_output = strip_ansi_codes(result.output) + assert "--input" in clean_output + + def test_rl_upload_data_hyphen_alias_help(self): + result = CliRunner().invoke( + agentic_rl.app, + ["upload-data", "--help"], + ) + + assert result.exit_code == 0 + clean_output = strip_ansi_codes(result.output) + assert "training-files" in clean_output + + def test_missing_global_api_key_value_does_not_consume_command( + self, + monkeypatch, + capsys, + ): + monkeypatch.setattr( + sys, + "argv", + ["dashscope", "--api-key", "models", "list"], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.code == 2 + assert "requires an argument" in combined_output + assert "No such command 'list'" not in combined_output + + def test_missing_global_api_key_value_does_not_consume_option( + self, + monkeypatch, + capsys, + ): + monkeypatch.setattr(sys, "argv", ["dashscope", "--api-key", "--help"]) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.code == 2 + assert "requires an argument" in combined_output + assert "Usage:" not in combined_output + + def test_empty_global_api_key_value_exits_before_request( + self, + monkeypatch, + capsys, + ): + monkeypatch.setattr(dashscope, "api_key", "existing-key") + monkeypatch.setattr( + sys, + "argv", + ["dashscope", "--api-key=", "models", "list"], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.code == 2 + assert dashscope.api_key == "existing-key" + assert "requires an argument" in combined_output + assert "InvalidApiKey" not in combined_output + + def test_global_api_key_after_command_is_supported(self, monkeypatch): + captured_request = {} + + def mock_list(**kwargs): + captured_request["api_key"] = dashscope.api_key + captured_request.update(kwargs) + return SimpleNamespace(status_code=200, output={"models": []}) + + monkeypatch.setattr( + "dashscope.cli.models.dashscope.Models.list", + mock_list, + ) + monkeypatch.setattr(dashscope, "api_key", None) + monkeypatch.setattr( + sys, + "argv", + ["dashscope", "models", "--api-key", "command-key", "list"], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + assert exception_info.value.code == 0 + assert captured_request == { + "api_key": "command-key", + "page": 1, + "page_size": 10, + } + + def test_global_api_key_equals_after_command_is_supported( + self, + monkeypatch, + ): + captured_request = {} + + def mock_list(**kwargs): + captured_request["api_key"] = dashscope.api_key + captured_request.update(kwargs) + return SimpleNamespace(status_code=200, output={"models": []}) + + monkeypatch.setattr( + "dashscope.cli.models.dashscope.Models.list", + mock_list, + ) + monkeypatch.setattr(dashscope, "api_key", None) + monkeypatch.setattr( + sys, + "argv", + ["dashscope", "models", "--api-key=command-key", "list"], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + assert exception_info.value.code == 0 + assert captured_request == { + "api_key": "command-key", + "page": 1, + "page_size": 10, + } + + def test_agentic_rl_hidden_alias_help(self): + result = CliRunner().invoke(cli_app, ["agentic-rl", "--help"]) + + assert result.exit_code == 0 + assert "register_functions" in result.output + + def test_subcommand_api_key_option_is_not_consumed_by_global_parser( + self, + monkeypatch, + ): + captured_request = {} + + def mock_upload(model, file_path, api_key, base_address=None): + captured_request["model"] = model + captured_request["file_path"] = file_path + captured_request["api_key"] = api_key + captured_request["base_address"] = base_address + return "oss://uploaded", None + + monkeypatch.setattr( + "dashscope.cli.oss.os.path.exists", + lambda path: True, + ) + monkeypatch.setattr("dashscope.cli.oss.OssUtils.upload", mock_upload) + monkeypatch.setattr(dashscope, "api_key", "global-key") + monkeypatch.setattr( + sys, + "argv", + [ + "dashscope", + "oss", + "upload", + "--api-key", + "local-key", + "--file", + "~/file.txt", + "--model", + "qwen-vl", + ], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + assert exception_info.value.code == 0 + assert captured_request == { + "model": "qwen-vl", + "file_path": os.path.expanduser("~/file.txt"), + "api_key": "local-key", + "base_address": None, + } + + def test_subcommand_api_key_missing_value_is_handled_by_subcommand_parser( + self, + monkeypatch, + capsys, + ): + monkeypatch.setattr( + sys, + "argv", + [ + "dashscope", + "oss", + "upload", + "--api-key", + "--file", + "~/file.txt", + "--model", + "qwen-vl", + ], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.code == 2 + assert "requires an argument" in combined_output + + def test_legacy_api_key_missing_value_exits_before_request( + self, + monkeypatch, + capsys, + ): + monkeypatch.setattr( + sys, + "argv", + [ + "dashscope", + "generation.call", + "--api_key", + "-m", + "qwen-turbo", + "-p", + "hi", + ], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.code == 2 + assert "requires an argument" in combined_output + + def test_legacy_api_key_option_is_extracted_after_translation( + self, + monkeypatch, + ): + monkeypatch.setattr(dashscope, "api_key", None) + captured_request = {} + + def mock_call(model, prompt, stream=False): + captured_request["api_key"] = dashscope.api_key + captured_request["model"] = model + captured_request["prompt"] = prompt + captured_request["stream"] = stream + return SimpleNamespace( + status_code=200, + output={"text": "ok"}, + usage={"input_tokens": 1}, + ) + + monkeypatch.setattr( + "dashscope.cli.generation.Generation.call", + mock_call, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "dashscope", + "generation.call", + "--api_key", + "legacy-key", + "-m", + "qwen-turbo", + "-p", + "hi", + ], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + assert exception_info.value.code == 0 + assert captured_request == { + "api_key": "legacy-key", + "model": "qwen-turbo", + "prompt": "hi", + "stream": False, + } + + def test_legacy_list_page_size_maps_to_size_option(self, monkeypatch): + captured_requests = {} + + def mock_files_list(**kwargs): + captured_requests["files"] = kwargs + return SimpleNamespace(status_code=200, output={}) + + def mock_fine_tunes_list(**kwargs): + captured_requests["fine_tunes"] = kwargs + return SimpleNamespace(status_code=200, output={"jobs": []}) + + def mock_deployments_list(**kwargs): + captured_requests["deployments"] = kwargs + return SimpleNamespace(status_code=200, output={"deployments": []}) + + monkeypatch.setattr( + "dashscope.cli.files.dashscope.Files.list", + mock_files_list, + ) + monkeypatch.setattr( + "dashscope.cli.fine_tunes.dashscope.FineTunes.list", + mock_fine_tunes_list, + ) + monkeypatch.setattr( + "dashscope.cli.deployments.dashscope.Deployments.list", + mock_deployments_list, + ) + + cases = [ + ("files.list", "files"), + ("fine_tunes.list", "fine_tunes"), + ("deployments.list", "deployments"), + ] + for legacy_command, request_key in cases: + monkeypatch.setattr( + sys, + "argv", + ["dashscope", legacy_command, "--page_size", "20"], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + assert exception_info.value.code == 0 + assert captured_requests[request_key]["page_size"] == 20 + + def test_legacy_list_page_size_equals_maps_to_size_option( + self, + monkeypatch, + ): + captured_request = {} + + def mock_files_list(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace(status_code=200, output={}) + + monkeypatch.setattr( + "dashscope.cli.files.dashscope.Files.list", + mock_files_list, + ) + monkeypatch.setattr( + sys, + "argv", + ["dashscope", "files.list", "--page_size=30"], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + assert exception_info.value.code == 0 + assert captured_request["page_size"] == 30 + + def test_files_upload_missing_file_exits_without_traceback( + self, + monkeypatch, + capsys, + ): + monkeypatch.setattr( + "dashscope.cli.files.os.path.exists", + lambda path: False, + ) + monkeypatch.setattr( + sys, + "argv", + ["dashscope", "files", "upload", "--file", "~/missing.jsonl"], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.code == 1 + assert "does not exist" in combined_output + assert "Traceback" not in combined_output + + def test_files_upload_sdk_exception_exits_without_traceback( + self, + monkeypatch, + capsys, + ): + def mock_upload(**kwargs): + raise RuntimeError("upload failed") + + monkeypatch.setattr( + "dashscope.cli.files.os.path.exists", + lambda path: True, + ) + monkeypatch.setattr( + "dashscope.cli.files.dashscope.Files.upload", + mock_upload, + ) + monkeypatch.setattr( + sys, + "argv", + ["dashscope", "files", "upload", "--file", "~/data.jsonl"], + ) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.code == 1 + assert "upload failed" in combined_output + assert "Traceback" not in combined_output + + def test_agentic_rl_invalid_output_format_exits(self, capsys): + with pytest.raises(Exception) as exception_info: + agentic_rl.format_output({"job_id": "job-1"}, "xml") + + captured_output = capsys.readouterr() + combined_output = captured_output.out + captured_output.err + + assert exception_info.value.exit_code == 2 + assert "Invalid output format" in combined_output + + def test_agentic_rl_invalid_output_format_exits_before_business_validation( + self, + ): + result = CliRunner().invoke( + cli_app, + ["rl", "register_functions", "--output-format", "xml"], + ) + + assert result.exit_code == 2 + assert "Invalid output format" in result.output + assert "At least one" not in result.output + + def test_agentic_rl_invalid_function_type_exits_before_sdk_call(self): + result = CliRunner().invoke( + cli_app, + ["rl", "test_functions", "iid", "--type", "bad", "--input", "{}"], + ) + + assert result.exit_code == 2 + assert "Invalid function type" in result.output + assert "Function test failed" not in result.output + assert "Traceback" not in result.output + + def test_agentic_rl_upload_data_missing_file_exits_before_sdk_call(self): + result = CliRunner().invoke( + cli_app, + ["rl", "upload_data", "--training-files", "~/missing.jsonl"], + ) + + assert result.exit_code == 1 + assert "does not exist" in result.output + assert "Dataset upload failed" not in result.output + assert "Traceback" not in result.output + + def test_generation_stream_exception_is_printed_without_traceback( + self, + monkeypatch, + ): + class FailingStream: + def __iter__(self): + raise RuntimeError("stream failed") + + def mock_call(*_args, **_kwargs): + return FailingStream() + + monkeypatch.setattr( + "dashscope.cli.generation.Generation.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + [ + "generation", + "create", + "--model", + "qwen", + "--prompt", + "hi", + "--stream", + ], + ) + + assert result.exit_code == 1 + assert "Generation request failed: stream failed" in result.output + assert "Traceback" not in result.output + + def test_models_sdk_exception_is_printed_without_traceback( + self, + monkeypatch, + ): + def mock_list(**kwargs): + raise RuntimeError("models failed") + + monkeypatch.setattr( + "dashscope.cli.models.dashscope.Models.list", + mock_list, + ) + + result = CliRunner().invoke(cli_app, ["models", "list"]) + + assert result.exit_code == 1 + assert "List models failed: models failed" in result.output + assert "Traceback" not in result.output + + def test_deployments_sdk_exception_is_printed_without_traceback( + self, + monkeypatch, + ): + def mock_call(**kwargs): + raise RuntimeError("deployment failed") + + monkeypatch.setattr( + "dashscope.cli.deployments.dashscope.Deployments.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + ["deployments", "create", "--model", "qwen"], + ) + + assert result.exit_code == 1 + assert "Create deployment failed: deployment failed" in result.output + assert "Traceback" not in result.output + + def test_application_response_without_usage_is_supported( + self, + monkeypatch, + ): + def mock_call(**_kwargs): + return SimpleNamespace(status_code=200, output={"text": "ok"}) + + monkeypatch.setattr( + "dashscope.cli.application.dashscope.Application.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + ["application", "create", "--app-id", "app", "--prompt", "hi"], + ) + + assert result.exit_code == 0 + assert "ok" in result.output + assert "Traceback" not in result.output + + def test_generation_response_without_usage_is_supported(self, monkeypatch): + def mock_call(*_args, **_kwargs): + return SimpleNamespace(status_code=200, output={"text": "ok"}) + + monkeypatch.setattr( + "dashscope.cli.generation.Generation.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + ["generation", "create", "--model", "qwen", "--prompt", "hi"], + ) + + assert result.exit_code == 0 + assert "ok" in result.output + assert "Traceback" not in result.output + + def test_generation_stream_response_without_usage_is_supported( + self, + monkeypatch, + ): + def mock_call(*_args, **_kwargs): + return iter( + [SimpleNamespace(status_code=200, output={"text": "ok"})], + ) + + monkeypatch.setattr( + "dashscope.cli.generation.Generation.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + [ + "generation", + "create", + "--model", + "qwen", + "--prompt", + "hi", + "--stream", + ], + ) + + assert result.exit_code == 0 + assert "ok" in result.output + assert "Traceback" not in result.output + + def test_image_generation_sdk_exception_is_printed_without_traceback( + self, + monkeypatch, + ): + def mock_call(**kwargs): + raise RuntimeError("image failed") + + monkeypatch.setattr( + "dashscope.cli.image_generation.ImageGeneration.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + [ + "image-generation", + "create", + "--model", + "qwen-image", + "--text", + "cat", + ], + ) + + assert result.exit_code == 1 + assert "Image generation request failed: image failed" in result.output + assert "Traceback" not in result.output + + def test_multimodal_conversation_response_without_usage_is_supported( + self, + monkeypatch, + ): + def mock_call(**_kwargs): + return SimpleNamespace(status_code=200, output={"text": "ok"}) + + monkeypatch.setattr( + "dashscope.cli.multimodal_conversation.dashscope." + "MultiModalConversation.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + [ + "multimodal-conversation", + "create", + "--model", + "qwen-vl", + "--text", + "hi", + ], + ) + + assert result.exit_code == 0 + assert "ok" in result.output + assert "Traceback" not in result.output + + def test_multimodal_embedding_response_without_usage_is_supported( + self, + monkeypatch, + ): + def mock_call(**_kwargs): + return SimpleNamespace(status_code=200, output={"embeddings": []}) + + monkeypatch.setattr( + "dashscope.cli.multimodal_embedding.dashscope." + "MultiModalEmbedding.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + [ + "multimodal-embedding", + "create", + "--model", + "mm-embedding", + "--text", + "hi", + ], + ) + + assert result.exit_code == 0 + assert "embeddings" in result.output + assert "Traceback" not in result.output + + def test_multimodal_embedding_sdk_exception_is_printed_without_traceback( + self, + monkeypatch, + ): + def mock_call(**_kwargs): + raise RuntimeError("embedding failed") + + monkeypatch.setattr( + "dashscope.cli.multimodal_embedding.dashscope." + "MultiModalEmbedding.call", + mock_call, + ) + + result = CliRunner().invoke( + cli_app, + [ + "multimodal-embedding", + "create", + "--model", + "mm-embedding", + "--text", + "hi", + ], + ) + + assert result.exit_code == 1 + assert ( + "Multimodal embedding request failed: embedding failed" + in result.output + ) + assert "Traceback" not in result.output + + def test_agentic_rl_run_missing_config_exits_before_workflow(self): + result = CliRunner().invoke( + cli_app, + ["rl", "run", "--config", "~/missing-config.yaml"], + ) + + assert result.exit_code == 1 + assert "--config file" in result.output + assert "does not exist" in result.output + assert "Workflow execution failed" not in result.output + assert "Traceback" not in result.output diff --git a/tests/unit/test_cli_models.py b/tests/unit/test_cli_models.py new file mode 100644 index 0000000..60917fc --- /dev/null +++ b/tests/unit/test_cli_models.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import models + + +class TestCliModels: + def test_list_models(self, monkeypatch): + captured_request = {} + + def mock_list(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "models": [ + { + "model_id": "qwen-turbo", + }, + ], + }, + ) + + monkeypatch.setattr(models.dashscope.Models, "list", mock_list) + + result = CliRunner().invoke( + models.app, + [ + "list", + "--page", + "2", + "--page-size", + "20", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "page": 2, + "page_size": 20, + } + assert "qwen-turbo" in result.output + + def test_get_model(self, monkeypatch): + captured_request = {} + + def mock_get(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "model_id": "qwen-turbo", + }, + ) + + monkeypatch.setattr(models.dashscope.Models, "get", mock_get) + + result = CliRunner().invoke(models.app, ["get", "qwen-turbo"]) + + assert result.exit_code == 0 + assert captured_request == {"name": "qwen-turbo"} + assert "qwen-turbo" in result.output diff --git a/tests/unit/test_cli_multimodal_conversation.py b/tests/unit/test_cli_multimodal_conversation.py new file mode 100644 index 0000000..245efbc --- /dev/null +++ b/tests/unit/test_cli_multimodal_conversation.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import multimodal_conversation + + +class TestCliMultiModalConversation: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "choices": [ + { + "message": { + "content": [ + {"text": "图中是一只猫。"}, + ], + }, + }, + ], + }, + usage={"input_tokens": 10, "output_tokens": 8}, + ) + + monkeypatch.setattr( + multimodal_conversation.dashscope.MultiModalConversation, + "call", + mock_call, + ) + + result = CliRunner().invoke( + multimodal_conversation.app, + [ + "create", + "--model", + "qwen-vl-max-latest", + "--text", + "描述这张图片", + "--image", + "https://example.com/cat.png", + "--workspace", + "workspace-id", + "--result-format", + "message", + "--temperature", + "0.8", + "--top-p", + "0.9", + "--top-k", + "50", + "--max-tokens", + "128", + "--seed", + "123", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "qwen-vl-max-latest", + "messages": [ + { + "role": "user", + "content": [ + {"image": "https://example.com/cat.png"}, + {"text": "描述这张图片"}, + ], + }, + ], + "workspace": "workspace-id", + "result_format": "message", + "temperature": 0.8, + "top_p": 0.9, + "top_k": 50, + "max_tokens": 128, + "seed": 123, + } + assert "图中是一只猫" in result.output + assert "input_tokens" in result.output diff --git a/tests/unit/test_cli_multimodal_embedding.py b/tests/unit/test_cli_multimodal_embedding.py new file mode 100644 index 0000000..0058b0e --- /dev/null +++ b/tests/unit/test_cli_multimodal_embedding.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import multimodal_embedding + + +class TestCliMultiModalEmbedding: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "embeddings": [ + { + "embedding": [0.1, 0.2, 0.3], + }, + ], + }, + usage={"input_tokens": 10}, + ) + + monkeypatch.setattr( + multimodal_embedding.dashscope.MultiModalEmbedding, + "call", + mock_call, + ) + + result = CliRunner().invoke( + multimodal_embedding.app, + [ + "create", + "--model", + "multimodal-embedding-v1", + "--text", + "一只猫", + "--image", + "https://example.com/cat.png", + "--audio", + "https://example.com/cat.wav", + "--workspace", + "workspace-id", + "--dimension", + "1024", + "--output-type", + "dense", + "--fps", + "0.5", + "--instruct", + "用于图文检索", + "--enable-fusion", + "--res-level", + "2", + "--max-video-frames", + "16", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "multimodal-embedding-v1", + "input": [ + {"text": "一只猫"}, + {"image": "https://example.com/cat.png"}, + {"audio": "https://example.com/cat.wav"}, + ], + "workspace": "workspace-id", + "dimension": 1024, + "output_type": "dense", + "fps": 0.5, + "instruct": "用于图文检索", + "enable_fusion": True, + "res_level": 2, + "max_video_frames": 16, + } + assert "embedding" in result.output + assert "input_tokens" in result.output diff --git a/tests/unit/test_cli_rerank.py b/tests/unit/test_cli_rerank.py new file mode 100644 index 0000000..6380b39 --- /dev/null +++ b/tests/unit/test_cli_rerank.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import rerank + + +class TestCliRerank: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "results": [ + { + "index": 1, + "relevance_score": 0.987654, + }, + ], + }, + usage={"total_tokens": 12}, + ) + + monkeypatch.setattr(rerank.dashscope.TextReRank, "call", mock_call) + + result = CliRunner().invoke( + rerank.app, + [ + "create", + "--model", + "gte-rerank", + "--query", + "哈尔滨在哪?", + "--document", + "黑龙江离俄罗斯很近", + "--document", + "哈尔滨是中国黑龙江省的省会,位于中国东北", + "--return-documents", + "--top-n", + "1", + "--instruct", + "Rank documents by answer relevance.", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "gte-rerank", + "query": "哈尔滨在哪?", + "documents": [ + "黑龙江离俄罗斯很近", + "哈尔滨是中国黑龙江省的省会,位于中国东北", + ], + "return_documents": True, + "top_n": 1, + "instruct": "Rank documents by answer relevance.", + } + assert "relevance_score" in result.output + assert "total_tokens" in result.output diff --git a/tests/unit/test_cli_speech_synthesis.py b/tests/unit/test_cli_speech_synthesis.py new file mode 100644 index 0000000..2351ced --- /dev/null +++ b/tests/unit/test_cli_speech_synthesis.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import speech_synthesis + + +class TestCliSpeechSynthesis: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + audio_url="https://example.com/audio.wav", + audio_id="audio-1234", + expires_at=1893456000, + sentences=[], + ) + + monkeypatch.setattr( + speech_synthesis.dashscope.HttpSpeechSynthesizer, + "call", + mock_call, + ) + + result = CliRunner().invoke( + speech_synthesis.app, + [ + "create", + "--model", + "cosyvoice-v3-flash", + "--text", + "今天天气不错", + "--voice", + "Cherry", + "--audio-format", + "mp3", + "--sample-rate", + "24000", + "--workspace", + "workspace-id", + "--url", + "https://dashscope.aliyuncs.com/api/v1/", + "--volume", + "80", + "--rate", + "10", + "--pitch", + "-10", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "cosyvoice-v3-flash", + "text": "今天天气不错", + "voice": "Cherry", + "audio_format": "mp3", + "sample_rate": 24000, + "workspace": "workspace-id", + "url": "https://dashscope.aliyuncs.com/api/v1/", + "volume": 80, + "rate": 10, + "pitch": -10, + } + assert "audio-1234" in result.output + assert "audio.wav" in result.output diff --git a/tests/unit/test_cli_tokenization.py b/tests/unit/test_cli_tokenization.py new file mode 100644 index 0000000..9da078b --- /dev/null +++ b/tests/unit/test_cli_tokenization.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import tokenization + + +class TestCliTokenization: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "token_ids": [115798, 198], + "tokens": ["<|im_start|>", "\n"], + "prompt": "hello", + }, + usage={"input_tokens": 2}, + ) + + monkeypatch.setattr( + tokenization.dashscope.Tokenization, + "call", + mock_call, + ) + + result = CliRunner().invoke( + tokenization.app, + [ + "create", + "--model", + "qwen-turbo", + "--prompt", + "hello", + "--workspace", + "workspace-id", + "--enable-search", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "qwen-turbo", + "prompt": "hello", + "workspace": "workspace-id", + "enable_search": True, + } + assert "token_ids" in result.output + assert "input_tokens" in result.output diff --git a/tests/unit/test_cli_transcription.py b/tests/unit/test_cli_transcription.py new file mode 100644 index 0000000..bd8c663 --- /dev/null +++ b/tests/unit/test_cli_transcription.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import transcription + + +class TestCliTranscription: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "task_id": "task-1234", + "task_status": "SUCCEEDED", + "results": [ + { + "transcription_url": ( + "https://example.com/result.json" + ), + }, + ], + }, + usage={"audio_count": 1}, + ) + + monkeypatch.setattr( + transcription.dashscope.Transcription, + "call", + mock_call, + ) + + result = CliRunner().invoke( + transcription.app, + [ + "create", + "--model", + "paraformer-v1", + "--file-url", + "https://example.com/audio.wav", + "--phrase-id", + "phrase-1234", + "--workspace", + "workspace-id", + "--channel-id", + "0", + "--channel-id", + "1", + "--disfluency-removal-enabled", + "--diarization-enabled", + "--speaker-count", + "2", + "--timestamp-alignment-enabled", + "--special-word-filter", + "filter", + "--audio-event-detection-enabled", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "paraformer-v1", + "file_urls": ["https://example.com/audio.wav"], + "phrase_id": "phrase-1234", + "workspace": "workspace-id", + "channel_id": [0, 1], + "disfluency_removal_enabled": True, + "diarization_enabled": True, + "speaker_count": 2, + "timestamp_alignment_enabled": True, + "special_word_filter": "filter", + "audio_event_detection_enabled": True, + } + assert "task-1234" in result.output + assert "audio_count" in result.output diff --git a/tests/unit/test_cli_understanding.py b/tests/unit/test_cli_understanding.py new file mode 100644 index 0000000..c15ea14 --- /dev/null +++ b/tests/unit/test_cli_understanding.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import understanding + + +class TestCliUnderstanding: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "rt": 0.06531630805693567, + "text": "积极;", + }, + usage={"total_tokens": 22}, + ) + + monkeypatch.setattr( + understanding.dashscope.Understanding, + "call", + mock_call, + ) + + result = CliRunner().invoke( + understanding.app, + [ + "create", + "--model", + "opennlu-v1", + "--sentence", + "老师今天表扬我了", + "--labels", + "积极,消极", + "--task", + "classification", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "opennlu-v1", + "sentence": "老师今天表扬我了", + "labels": "积极,消极", + "task": "classification", + } + assert "积极" in result.output + assert "total_tokens" in result.output diff --git a/tests/unit/test_cli_video_synthesis.py b/tests/unit/test_cli_video_synthesis.py new file mode 100644 index 0000000..c1af60c --- /dev/null +++ b/tests/unit/test_cli_video_synthesis.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from types import SimpleNamespace + +from typer.testing import CliRunner + +from dashscope.cli import video_synthesis + + +class TestCliVideoSynthesis: + def test_create(self, monkeypatch): + captured_request = {} + + def mock_call(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={ + "task_id": "task-1234", + "task_status": "SUCCEEDED", + "video_url": "https://example.com/video.mp4", + }, + usage={"video_count": 1}, + ) + + monkeypatch.setattr( + video_synthesis.dashscope.VideoSynthesis, + "call", + mock_call, + ) + + result = CliRunner().invoke( + video_synthesis.app, + [ + "create", + "--model", + "wanx2.1-t2v-turbo", + "--prompt", + "一只小猫在月光下奔跑", + "--negative-prompt", + "低清晰度", + "--img-url", + "https://example.com/image.png", + "--first-frame-url", + "https://example.com/first.png", + "--last-frame-url", + "https://example.com/last.png", + "--workspace", + "workspace-id", + "--size", + "1280*720", + "--duration", + "5", + "--seed", + "123", + "--prompt-extend", + "--watermark", + "--resolution", + "720P", + "--ratio", + "16:9", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "model": "wanx2.1-t2v-turbo", + "prompt": "一只小猫在月光下奔跑", + "negative_prompt": "低清晰度", + "img_url": "https://example.com/image.png", + "first_frame_url": "https://example.com/first.png", + "last_frame_url": "https://example.com/last.png", + "workspace": "workspace-id", + "size": "1280*720", + "duration": 5, + "seed": 123, + "prompt_extend": True, + "watermark": True, + "resolution": "720P", + "ratio": "16:9", + } + assert "task-1234" in result.output + assert "video_count" in result.output + + def test_fetch(self, monkeypatch): + captured_request = {} + + def mock_fetch(task_id, workspace=None): + captured_request["task_id"] = task_id + captured_request["workspace"] = workspace + return SimpleNamespace( + status_code=200, + output={"task_id": task_id, "task_status": "RUNNING"}, + ) + + monkeypatch.setattr( + video_synthesis.dashscope.VideoSynthesis, + "fetch", + mock_fetch, + ) + + result = CliRunner().invoke( + video_synthesis.app, + ["fetch", "task-1234", "--workspace", "workspace-id"], + ) + + assert result.exit_code == 0 + assert captured_request == { + "task_id": "task-1234", + "workspace": "workspace-id", + } + assert "RUNNING" in result.output + + def test_wait(self, monkeypatch): + captured_request = {} + + def mock_wait(task_id, workspace=None): + captured_request["task_id"] = task_id + captured_request["workspace"] = workspace + return SimpleNamespace( + status_code=200, + output={ + "task_id": task_id, + "task_status": "SUCCEEDED", + "video_url": "https://example.com/video.mp4", + }, + ) + + monkeypatch.setattr( + video_synthesis.dashscope.VideoSynthesis, + "wait", + mock_wait, + ) + + result = CliRunner().invoke( + video_synthesis.app, + ["wait", "task-1234", "--workspace", "workspace-id"], + ) + + assert result.exit_code == 0 + assert captured_request == { + "task_id": "task-1234", + "workspace": "workspace-id", + } + assert "SUCCEEDED" in result.output + + def test_cancel(self, monkeypatch): + captured_request = {} + + def mock_cancel(task_id, workspace=None): + captured_request["task_id"] = task_id + captured_request["workspace"] = workspace + return SimpleNamespace(status_code=200, output={"deleted": True}) + + monkeypatch.setattr( + video_synthesis.dashscope.VideoSynthesis, + "cancel", + mock_cancel, + ) + + result = CliRunner().invoke( + video_synthesis.app, + ["cancel", "task-1234", "--workspace", "workspace-id"], + ) + + assert result.exit_code == 0 + assert captured_request == { + "task_id": "task-1234", + "workspace": "workspace-id", + } + assert "success" in result.output + + def test_list(self, monkeypatch): + captured_request = {} + + def mock_list(**kwargs): + captured_request.update(kwargs) + return SimpleNamespace( + status_code=200, + output={"tasks": [{"task_id": "task-1234"}]}, + ) + + monkeypatch.setattr( + video_synthesis.dashscope.VideoSynthesis, + "list", + mock_list, + ) + + result = CliRunner().invoke( + video_synthesis.app, + [ + "list", + "--start-time", + "20240101000000", + "--end-time", + "20240102000000", + "--model-name", + "wanx", + "--api-key-id", + "ak-id", + "--region", + "cn-beijing", + "--status", + "SUCCEEDED", + "--page", + "2", + "--size", + "20", + "--workspace", + "workspace-id", + ], + ) + + assert result.exit_code == 0 + assert captured_request == { + "start_time": "20240101000000", + "end_time": "20240102000000", + "model_name": "wanx", + "api_key_id": "ak-id", + "region": "cn-beijing", + "status": "SUCCEEDED", + "page_no": 2, + "page_size": 20, + "workspace": "workspace-id", + } + assert "task-1234" in result.output