From 356d8fd1567303a794e52639c69f37cdeee37226 Mon Sep 17 00:00:00 2001 From: Adam Ladachowski Date: Sun, 15 Feb 2026 06:21:35 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=AC=20Commit=20message:=20Update=20202?= =?UTF-8?q?6-02-15=2006:21:35,=207=20files,=201559=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 📁 Files changed: 7 📝 Lines changed: 1559 • .coverage • cli.py • __init__.py • conftest.py • test_client.py • test_generate.py • test_server.py --- .coverage | Bin 114688 -> 122880 bytes tensors/cli.py | 620 ++----------------------------------- tensors/server/__init__.py | 45 +-- tests/conftest.py | 20 -- tests/test_client.py | 481 ---------------------------- tests/test_generate.py | 303 ------------------ tests/test_server.py | 90 +----- 7 files changed, 52 insertions(+), 1507 deletions(-) delete mode 100644 tests/test_client.py delete mode 100644 tests/test_generate.py diff --git a/.coverage b/.coverage index c3b6e90db690ba3bc88d92e72969404b731b7536..3e5f05ac0632519bd3cae1b1646709d6b8665b5f 100644 GIT binary patch literal 122880 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 i6!2caPx=+sl diff --git a/tensors/cli.py b/tensors/cli.py index acead72..f6681b5 100644 --- a/tensors/cli.py +++ b/tensors/cli.py @@ -20,20 +20,15 @@ from tensors.api import ( fetch_civitai_model_version, search_civitai, ) -from tensors.client import TsrClient, TsrClientError from tensors.config import ( CONFIG_FILE, BaseModel, ModelType, SortOrder, get_default_output_path, - get_remotes, load_api_key, load_config, - resolve_remote, save_config, - save_remote, - set_default_remote, ) from tensors.db import DB_PATH, Database from tensors.display import ( @@ -49,18 +44,6 @@ from tensors.safetensor import compute_sha256, get_base_name, read_safetensor_me # Key masking threshold MIN_KEY_LENGTH_FOR_MASKING = 8 -# Size threshold for GB display -_MB_PER_GB = 1024 - - -def _format_size_mb(size_mb: float | None) -> str: - """Format size in MB to human-readable string.""" - if not size_mb: - return "" - if size_mb >= _MB_PER_GB: - return f"{size_mb / _MB_PER_GB:.1f} GB" - return f"{size_mb:.0f} MB" - def _version_callback(value: bool) -> None: if value: @@ -329,74 +312,41 @@ def download( output: Annotated[Path | None, typer.Option("-o", "--output", help="Output directory")] = None, no_resume: Annotated[bool, typer.Option("--no-resume", help="Don't resume partial downloads")] = False, api_key: Annotated[str | None, typer.Option("--api-key", help="CivitAI API key")] = None, - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON (remote mode)")] = False, ) -> None: - """Download a model from CivitAI (locally or to remote server).""" - # Check if remote is specified or configured - remote_url = resolve_remote(remote) + """Download a model from CivitAI.""" + key = api_key or load_api_key() - if remote_url: - # Remote mode: use TsrClient API - if not version_id and not model_id and not hash_val: + resolved_version_id = _resolve_version_id(version_id, hash_val, model_id, key) + if not resolved_version_id: + if not version_id and not hash_val and not model_id: console.print("[red]Error: Must specify --version-id, --model-id, or --hash[/red]") - raise typer.Exit(1) + raise typer.Exit(1) - try: - with TsrClient(remote_url) as client: - console.print(f"[cyan]Starting download on {remote_url}...[/cyan]") - result = client.start_download( - version_id=version_id, - model_id=model_id, - hash_val=hash_val, - output_dir=str(output) if output else None, - ) - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e + console.print(f"[cyan]Fetching version info for {resolved_version_id}...[/cyan]") + version_info = fetch_civitai_model_version(resolved_version_id, key, console) + if not version_info: + console.print("[red]Error: Could not fetch model version info.[/red]") + raise typer.Exit(1) - if json_output: - console.print_json(data=result) - return + model_type_str: str | None = version_info.get("model", {}).get("type") + output_dir = _prepare_download_dir(output, model_type_str) + if not output_dir: + raise typer.Exit(1) - download_id = result.get("download_id") - console.print(f"[green]Download started:[/green] {download_id}") - console.print(f"[dim]Check status with: tsr images download-status {download_id} --remote {remote or 'default'}[/dim]") - else: - # Local mode: direct download - key = api_key or load_api_key() + files: list[dict[str, Any]] = version_info.get("files", []) + primary_file = next((f for f in files if f.get("primary")), files[0] if files else None) + if not primary_file: + console.print("[red]Error: No files found for this version.[/red]") + raise typer.Exit(1) - resolved_version_id = _resolve_version_id(version_id, hash_val, model_id, key) - if not resolved_version_id: - if not version_id and not hash_val and not model_id: - console.print("[red]Error: Must specify --version-id, --model-id, or --hash[/red]") - raise typer.Exit(1) + filename = primary_file.get("name", f"model-{resolved_version_id}.safetensors") + dest_path = output_dir / filename - console.print(f"[cyan]Fetching version info for {resolved_version_id}...[/cyan]") - version_info = fetch_civitai_model_version(resolved_version_id, key, console) - if not version_info: - console.print("[red]Error: Could not fetch model version info.[/red]") - raise typer.Exit(1) + _display_download_info(version_info, filename, primary_file, dest_path) - model_type_str: str | None = version_info.get("model", {}).get("type") - output_dir = _prepare_download_dir(output, model_type_str) - if not output_dir: - raise typer.Exit(1) - - files: list[dict[str, Any]] = version_info.get("files", []) - primary_file = next((f for f in files if f.get("primary")), files[0] if files else None) - if not primary_file: - console.print("[red]Error: No files found for this version.[/red]") - raise typer.Exit(1) - - filename = primary_file.get("name", f"model-{resolved_version_id}.safetensors") - dest_path = output_dir / filename - - _display_download_info(version_info, filename, primary_file, dest_path) - - success = download_model(resolved_version_id, dest_path, key, console, resume=not no_resume) - if not success: - raise typer.Exit(1) + success = download_model(resolved_version_id, dest_path, key, console, resume=not no_resume) + if not success: + raise typer.Exit(1) def _display_download_info( @@ -449,167 +399,13 @@ def config( console.print("[dim]Set API key with: tsr config --set-key YOUR_KEY[/dim]") -@app.command() -def generate( - prompt: Annotated[str, typer.Argument(help="Text prompt for image generation.")], - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - model: Annotated[str | None, typer.Option("-m", "--model", help="Checkpoint model (remote mode only).")] = None, - host: Annotated[str, typer.Option(help="sd-server address (local mode).")] = "127.0.0.1", - port: Annotated[int, typer.Option(help="sd-server port (local mode).")] = 8080, - output: Annotated[str, typer.Option("-o", help="Output directory (local mode).")] = ".", - negative_prompt: Annotated[str, typer.Option("-n", help="Negative prompt.")] = "", - width: Annotated[int, typer.Option("-W", help="Image width.")] = 512, - height: Annotated[int, typer.Option("-H", help="Image height.")] = 512, - steps: Annotated[int, typer.Option(help="Sampling steps.")] = 20, - cfg_scale: Annotated[float, typer.Option(help="CFG scale.")] = 7.0, - seed: Annotated[int, typer.Option("-s", help="RNG seed (-1 for random).")] = -1, - sampler: Annotated[str, typer.Option(help="Sampler name.")] = "", - scheduler: Annotated[str, typer.Option(help="Scheduler name.")] = "", - batch_size: Annotated[int, typer.Option("-b", help="Number of images.")] = 1, - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON (remote mode)")] = False, -) -> None: - """Generate images using sd-server (local or remote).""" - # Check if remote is specified or configured - remote_url = resolve_remote(remote) - - if remote_url: - # Remote mode: use TsrClient API - try: - with TsrClient(remote_url) as client: - # Switch model if specified - if model: - console.print(f"[cyan]Switching to model: {model}[/cyan]") - client.switch_model(model) - - console.print(f"[cyan]Generating {batch_size} image(s) on {remote_url}...[/cyan]") - result = client.generate( - prompt=prompt, - negative_prompt=negative_prompt, - width=width, - height=height, - steps=steps, - cfg_scale=cfg_scale, - seed=seed, - sampler_name=sampler, - scheduler=scheduler, - batch_size=batch_size, - ) - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - if json_output: - console.print_json(data=result) - return - - images = result.get("images", []) - for img in images: - console.print(f"[green]Generated:[/green] {img.get('id', 'unknown')}") - else: - # Local mode: direct sd-server connection - if model: - console.print("[yellow]Warning: --model ignored in local mode (sd-server loads model at startup)[/yellow]") - - from tensors.generate import SDClient, Txt2ImgParams, save_images # noqa: PLC0415 - - params = Txt2ImgParams( - prompt=prompt, - negative_prompt=negative_prompt, - width=width, - height=height, - steps=steps, - cfg_scale=cfg_scale, - seed=seed, - batch_size=batch_size, - sampler_name=sampler, - scheduler=scheduler, - ) - - with SDClient(host=host, port=port) as client: - console.print(f"[cyan]Generating {batch_size} image(s)...[/cyan]") - images = client.generate.txt2img(params) - paths = save_images(images, output) - for p in paths: - console.print(f"[green]Saved:[/green] {p}") - - -@app.command() -def status( - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - host: Annotated[str, typer.Option(help="Wrapper API host (local mode).")] = "127.0.0.1", - port: Annotated[int, typer.Option(help="Wrapper API port (local mode).")] = 8080, - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False, -) -> None: - """Show sd-server wrapper status.""" - # Check if remote is specified or configured - remote_url = resolve_remote(remote) - - if remote_url: - # Remote mode: use TsrClient API - try: - with TsrClient(remote_url) as client: - data = client.status() - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - else: - # Local mode: direct HTTP call - import httpx # noqa: PLC0415 - - url = f"http://{host}:{port}/status" - try: - resp = httpx.get(url, timeout=10) - resp.raise_for_status() - data = resp.json() - except httpx.HTTPError as e: - console.print(f"[red]Error: Could not reach wrapper at {url}: {e}[/red]") - raise typer.Exit(1) from e - - if json_output: - console.print_json(data=data) - return - - table = Table(title="Server Status", show_header=True, header_style="bold magenta") - table.add_column("Property", style="cyan") - table.add_column("Value", style="green") - for key, value in data.items(): - table.add_row(key, str(value)) - console.print(table) - - -@app.command() -def reload( - model: Annotated[str, typer.Option(help="Path to model file for sd-server.")], - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - host: Annotated[str, typer.Option(help="Wrapper API host (local mode).")] = "127.0.0.1", - port: Annotated[int, typer.Option(help="Wrapper API port (local mode).")] = 8080, -) -> None: - """Reload sd-server with a new model.""" - import httpx # noqa: PLC0415 - - remote_url = resolve_remote(remote) - url = f"{remote_url.rstrip('/')}/reload" if remote_url else f"http://{host}:{port}/reload" - - console.print(f"[cyan]Reloading model: {model}[/cyan]") - try: - resp = httpx.post(url, json={"model": model}, timeout=300) - resp.raise_for_status() - data = resp.json() - except httpx.HTTPError as e: - console.print(f"[red]Error: Reload failed at {url}: {e}[/red]") - raise typer.Exit(1) from e - - console.print(f"[green]{data.get('status', 'OK')}[/green]") - - @app.command() def serve( - host: Annotated[str, typer.Option(help="Wrapper API listen address.")] = "127.0.0.1", - port: Annotated[int, typer.Option(help="Wrapper API listen port.")] = 8080, - sd_server: Annotated[str | None, typer.Option(help="sd-server URL to proxy to.")] = None, + host: Annotated[str, typer.Option(help="Listen address.")] = "127.0.0.1", + port: Annotated[int, typer.Option(help="Listen port.")] = 8080, log_level: Annotated[str, typer.Option(help="Log level.")] = "info", ) -> None: - """Start the sd-server wrapper API (proxies to external sd-server).""" + """Start the tensors server (gallery and CivitAI management).""" try: import uvicorn # noqa: PLC0415 @@ -619,7 +415,7 @@ def serve( console.print(" pip install tensors[server]") raise typer.Exit(1) from None - uvicorn.run(create_app(sd_server_url=sd_server), host=host, port=port, log_level=log_level) + uvicorn.run(create_app(), host=host, port=port, log_level=log_level) # ============================================================================= @@ -858,356 +654,6 @@ def db_stats( console.print(table) -# ============================================================================= -# Images Commands (Remote) -# ============================================================================= - -images_app = typer.Typer( - name="images", - help="Manage images in remote gallery.", - no_args_is_help=True, -) -app.add_typer(images_app, name="images") - - -def _get_client(remote: str | None) -> TsrClient: - """Get TsrClient for remote or raise error.""" - url = resolve_remote(remote) - if not url: - console.print("[red]Error: No remote specified. Use --remote or set default_remote in config.[/red]") - raise typer.Exit(1) - return TsrClient(url) - - -@images_app.command("list") -def images_list( - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - limit: Annotated[int, typer.Option("-n", "--limit", help="Max results")] = 50, - offset: Annotated[int, typer.Option("--offset", help="Offset for pagination")] = 0, - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False, -) -> None: - """List images in remote gallery.""" - try: - with _get_client(remote) as client: - result = client.list_images(limit=limit, offset=offset) - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - images = result.get("images", []) - total = result.get("total", len(images)) - - if json_output: - console.print_json(data=result) - return - - if not images: - console.print("[yellow]No images in gallery.[/yellow]") - return - - table = Table(title=f"Gallery Images ({len(images)}/{total})", show_header=True, header_style="bold magenta") - table.add_column("ID", style="cyan") - table.add_column("Filename", style="green") - table.add_column("Size", style="white") - table.add_column("Created", style="dim") - - for img in images: - size = f"{img.get('width', '?')}x{img.get('height', '?')}" - created = img.get("created_at", "") - if isinstance(created, (int, float)): - from datetime import datetime # noqa: PLC0415 - - created = datetime.fromtimestamp(created).strftime("%Y-%m-%d %H:%M") - table.add_row(img.get("id", ""), img.get("filename", ""), size, str(created)) - - console.print(table) - - -@images_app.command("show") -def images_show( - image_id: Annotated[str, typer.Argument(help="Image ID to show")], - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False, -) -> None: - """Show image metadata.""" - try: - with _get_client(remote) as client: - meta = client.get_image_meta(image_id) - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - if json_output: - console.print_json(data=meta) - return - - table = Table(title=f"Image: {image_id}", show_header=True, header_style="bold magenta") - table.add_column("Property", style="cyan") - table.add_column("Value", style="green") - - for key, value in meta.items(): - display_value = json.dumps(value, indent=2) if isinstance(value, dict) else str(value) - table.add_row(key, display_value) - - console.print(table) - - -@images_app.command("delete") -def images_delete( - image_id: Annotated[str, typer.Argument(help="Image ID to delete")], - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - force: Annotated[bool, typer.Option("-f", "--force", help="Skip confirmation")] = False, -) -> None: - """Delete an image from the gallery.""" - if not force: - confirm = typer.confirm(f"Delete image {image_id}?") - if not confirm: - console.print("[yellow]Cancelled.[/yellow]") - raise typer.Exit(0) - - try: - with _get_client(remote) as client: - client.delete_image(image_id) - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - console.print(f"[green]Deleted image: {image_id}[/green]") - - -@images_app.command("download") -def images_download( - image_id: Annotated[str, typer.Argument(help="Image ID to download")], - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - output: Annotated[Path | None, typer.Option("-o", "--output", help="Output file or directory")] = None, -) -> None: - """Download an image from the remote gallery.""" - try: - with _get_client(remote) as client: - content = client.download_image(image_id) - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - # Determine output path - if output is None: - dest = Path(f"{image_id}.png") - elif output.is_dir(): - dest = output / f"{image_id}.png" - else: - dest = output - - dest.write_bytes(content) - console.print(f"[green]Saved:[/green] {dest}") - - -# ============================================================================= -# Models Commands (Remote) -# ============================================================================= - -models_app = typer.Typer( - name="models", - help="Manage models on remote server.", - no_args_is_help=True, -) -app.add_typer(models_app, name="models") - - -@models_app.command("list") -def models_list( - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False, -) -> None: - """List available models on remote server.""" - try: - with _get_client(remote) as client: - result = client.list_models() - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - if json_output: - console.print_json(data=result) - return - - models = result.get("models", []) - active = result.get("active", "") - - if not models: - console.print("[yellow]No models found.[/yellow]") - return - - table = Table(title="Available Models", show_header=True, header_style="bold magenta") - table.add_column("ID", style="dim", width=8) - table.add_column("Name", style="cyan") - table.add_column("File", style="white") - table.add_column("Size", style="green", justify="right") - - for model in models: - path = model.get("path", "") - name = model.get("name", Path(path).stem if path else "") - is_active = active in {path, name} - - civitai_id = model.get("civitai_model_id") - id_str = str(civitai_id) if civitai_id else "" - display_name = model.get("display_name", name) - if is_active: - display_name = f"[green]✓[/green] {display_name}" - filename = model.get("filename", Path(path).name if path else "") - size_str = _format_size_mb(model.get("size_mb")) - - table.add_row(id_str, display_name, filename, size_str) - - console.print(table) - - -@models_app.command("active") -def models_active( - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False, -) -> None: - """Show currently active model.""" - try: - with _get_client(remote) as client: - result = client.get_active_model() - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - if json_output: - console.print_json(data=result) - return - - model = result.get("model", "None") - console.print(f"[bold]Active model:[/bold] {model}") - - -@models_app.command("switch") -def models_switch( - model: Annotated[str, typer.Argument(help="Model path or name to switch to")], - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, -) -> None: - """Switch to a different model on the remote server.""" - console.print(f"[cyan]Switching to model: {model}[/cyan]") - try: - with _get_client(remote) as client: - result = client.switch_model(model) - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - console.print(f"[green]{result.get('status', 'OK')}[/green]") - - -@models_app.command("loras") -def models_loras( - remote: Annotated[str | None, typer.Option("-r", "--remote", help="Remote server name or URL")] = None, - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False, -) -> None: - """List available LoRAs on remote server.""" - try: - with _get_client(remote) as client: - result = client.list_loras() - except TsrClientError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - if json_output: - console.print_json(data=result) - return - - loras = result.get("loras", []) - if not loras: - console.print("[yellow]No LoRAs found.[/yellow]") - return - - table = Table(title="Available LoRAs", show_header=True, header_style="bold magenta") - table.add_column("ID", style="dim", width=8) - table.add_column("Name", style="cyan") - table.add_column("File", style="white") - table.add_column("Size", style="green", justify="right") - - for lora in loras: - path = lora.get("path", "") - name = lora.get("name", Path(path).stem if path else "") - - civitai_id = lora.get("civitai_model_id") - id_str = str(civitai_id) if civitai_id else "" - display_name = lora.get("display_name", name) - filename = lora.get("filename", Path(path).name if path else "") - size_str = _format_size_mb(lora.get("size_mb")) - - table.add_row(id_str, display_name, filename, size_str) - - console.print(table) - - -# ============================================================================= -# Remote Configuration Commands -# ============================================================================= - -remote_app = typer.Typer( - name="remote", - help="Manage remote server configuration.", - no_args_is_help=True, -) -app.add_typer(remote_app, name="remote") - - -@remote_app.command("list") -def remote_list( - json_output: Annotated[bool, typer.Option("--json", "-j", help="Output as JSON")] = False, -) -> None: - """List configured remotes.""" - from tensors.config import get_default_remote # noqa: PLC0415 - - remotes = get_remotes() - default = get_default_remote() - - if json_output: - console.print_json(data={"remotes": remotes, "default": default}) - return - - if not remotes: - console.print("[yellow]No remotes configured.[/yellow]") - console.print("[dim]Add one with: tsr remote add NAME URL[/dim]") - return - - table = Table(title="Configured Remotes", show_header=True, header_style="bold magenta") - table.add_column("Default", style="dim", width=3) - table.add_column("Name", style="cyan") - table.add_column("URL", style="green") - - for name, url in remotes.items(): - is_default = name == default - status = "[green]✓[/green]" if is_default else "" - table.add_row(status, name, url) - - console.print(table) - - -@remote_app.command("add") -def remote_add( - name: Annotated[str, typer.Argument(help="Remote name")], - url: Annotated[str, typer.Argument(help="Remote URL (e.g., http://host:8080)")], -) -> None: - """Add a remote server.""" - save_remote(name, url) - console.print(f"[green]Added remote:[/green] {name} → {url}") - - -@remote_app.command("default") -def remote_default( - name: Annotated[str | None, typer.Argument(help="Remote name to set as default (omit to clear)")] = None, -) -> None: - """Set or clear the default remote.""" - set_default_remote(name) - if name: - console.print(f"[green]Default remote set to:[/green] {name}") - else: - console.print("[green]Default remote cleared.[/green]") - - # ============================================================================= # ComfyUI Commands # ============================================================================= @@ -1424,14 +870,8 @@ def main() -> int: "dl", "download", "config", - "generate", "serve", - "status", - "reload", "db", - "images", - "models", - "remote", "comfy", ) if len(sys.argv) > 1 and not sys.argv[1].startswith("-"): diff --git a/tensors/server/__init__.py b/tensors/server/__init__.py index c67a23b..1c8ac6e 100644 --- a/tensors/server/__init__.py +++ b/tensors/server/__init__.py @@ -1,4 +1,4 @@ -"""sd-server wrapper — FastAPI app for proxying to an external sd-server.""" +"""Tensors server — FastAPI app for gallery and CivitAI management.""" from __future__ import annotations @@ -7,19 +7,14 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import TYPE_CHECKING -import httpx from fastapi import FastAPI from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from tensors.config import get_sd_server_api_key, get_sd_server_url from tensors.server.civitai_routes import create_civitai_router from tensors.server.db_routes import create_db_router from tensors.server.download_routes import create_download_router from tensors.server.gallery_routes import create_gallery_router -from tensors.server.generate_routes import create_generate_router -from tensors.server.models_routes import create_models_router -from tensors.server.routes import create_router if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -29,28 +24,15 @@ __all__ = ["app", "create_app"] logger = logging.getLogger(__name__) -def create_app(sd_server_url: str | None = None) -> FastAPI: - """Build the FastAPI application that proxies to an external sd-server. - - Args: - sd_server_url: URL of the sd-server to proxy to. If None, uses - get_sd_server_url() to resolve from env/config. - """ - backend_url = sd_server_url or get_sd_server_url() - api_key = get_sd_server_api_key() +def create_app() -> FastAPI: + """Build the FastAPI application for gallery and model management.""" @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - _app.state.sd_server_url = backend_url - _app.state.sd_server_api_key = api_key - logger.info(f"Proxying to sd-server at: {backend_url}") - if api_key: - logger.info("Using API key authentication for sd-server") - async with httpx.AsyncClient(timeout=300) as client: - _app.state.client = client - yield + logger.info("Tensors server starting") + yield - app = FastAPI(title="sd-server wrapper", lifespan=lifespan) + app = FastAPI(title="tensors", lifespan=lifespan) # Serve Vue UI static files static_dir = Path(__file__).parent / "static" @@ -66,13 +48,14 @@ def create_app(sd_server_url: str | None = None) -> FastAPI: async def vite_icon() -> FileResponse: return FileResponse(static_dir / "vite.svg") - app.include_router(create_civitai_router()) # Must be before catch-all proxy - app.include_router(create_db_router()) # Must be before catch-all proxy - app.include_router(create_gallery_router()) # Must be before catch-all proxy - app.include_router(create_models_router()) # Must be before catch-all proxy - app.include_router(create_download_router()) # Must be before catch-all proxy - app.include_router(create_generate_router()) # Must be before catch-all proxy - app.include_router(create_router()) + @app.get("/status") + async def status() -> dict[str, str]: + return {"status": "ok"} + + app.include_router(create_civitai_router()) + app.include_router(create_db_router()) + app.include_router(create_gallery_router()) + app.include_router(create_download_router()) return app diff --git a/tests/conftest.py b/tests/conftest.py index 6cfc14e..6e27fc4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,11 +7,6 @@ import json import struct import pytest -import respx - -from tensors.generate import SDClient - -BASE_URL = "http://127.0.0.1:1234" # 1x1 red PNG for image response stubs TINY_PNG = ( @@ -43,18 +38,3 @@ def temp_safetensor(tmp_path): f.write(header_bytes) return file_path - - -@pytest.fixture() -def mock_api(): - """Activate respx mock for the sd-server base URL.""" - with respx.mock(base_url=BASE_URL, assert_all_called=False) as rsps: - yield rsps - - -@pytest.fixture() -def client(mock_api: respx.MockRouter) -> SDClient: # noqa: ARG001 - """SDClient wired to the mocked transport.""" - c = SDClient() - yield c # type: ignore[misc] - c.close() diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index ba2ff49..0000000 --- a/tests/test_client.py +++ /dev/null @@ -1,481 +0,0 @@ -"""Tests for the TsrClient HTTP client module.""" - -from __future__ import annotations - -import pytest -import respx -from httpx import Response - -from tensors.client import TsrClient, TsrClientError - -BASE_URL = "http://test-server:8080" - - -@pytest.fixture -def mock_server(): - """Activate respx mock for the test server.""" - with respx.mock(base_url=BASE_URL, assert_all_called=False) as rsps: - yield rsps - - -@pytest.fixture -def client(mock_server) -> TsrClient: # noqa: ARG001 - mock_server activates respx - """TsrClient connected to mock server.""" - return TsrClient(BASE_URL) - - -# ============================================================================= -# Status Tests -# ============================================================================= - - -class TestStatus: - """Tests for server status endpoint.""" - - def test_status_success(self, client: TsrClient, mock_server) -> None: - """Test getting server status.""" - mock_server.get("/status").mock(return_value=Response(200, json={"running": True, "pid": 12345, "model": "/test.gguf"})) - - with client: - result = client.status() - - assert result["running"] is True - assert result["pid"] == 12345 - - def test_status_error(self, client: TsrClient, mock_server) -> None: - """Test handling status error.""" - mock_server.get("/status").mock(return_value=Response(503, text="Service unavailable")) - - with client, pytest.raises(TsrClientError, match="HTTP 503"): - client.status() - - -# ============================================================================= -# Gallery Tests -# ============================================================================= - - -class TestGalleryImages: - """Tests for gallery image operations.""" - - def test_list_images(self, client: TsrClient, mock_server) -> None: - """Test listing gallery images.""" - mock_server.get("/api/images").mock( - return_value=Response( - 200, - json={ - "images": [ - {"id": "123_0", "filename": "123_0.png", "width": 512, "height": 512}, - {"id": "124_1", "filename": "124_1.png", "width": 1024, "height": 1024}, - ], - "total": 2, - }, - ) - ) - - with client: - result = client.list_images() - - assert len(result["images"]) == 2 - assert result["total"] == 2 - - def test_list_images_with_pagination(self, client: TsrClient, mock_server) -> None: - """Test listing images with pagination.""" - mock_server.get("/api/images", params={"limit": 10, "offset": 5}).mock( - return_value=Response(200, json={"images": [], "total": 100}) - ) - - with client: - result = client.list_images(limit=10, offset=5) - - assert result["total"] == 100 - - def test_get_image_meta(self, client: TsrClient, mock_server) -> None: - """Test getting image metadata.""" - mock_server.get("/api/images/123_0/meta").mock( - return_value=Response( - 200, - json={ - "id": "123_0", - "path": "/gallery/123_0.png", - "metadata": {"prompt": "test prompt", "seed": 42}, - }, - ) - ) - - with client: - result = client.get_image_meta("123_0") - - assert result["id"] == "123_0" - assert result["metadata"]["prompt"] == "test prompt" - - def test_delete_image(self, client: TsrClient, mock_server) -> None: - """Test deleting an image.""" - mock_server.delete("/api/images/123_0").mock(return_value=Response(200, json={"deleted": True, "id": "123_0"})) - - with client: - result = client.delete_image("123_0") - - assert result["deleted"] is True - - def test_edit_image(self, client: TsrClient, mock_server) -> None: - """Test editing image metadata.""" - mock_server.post("/api/images/123_0/edit").mock( - return_value=Response(200, json={"id": "123_0", "metadata": {"tags": ["favorite"], "rating": 5}}) - ) - - with client: - result = client.edit_image("123_0", {"tags": ["favorite"], "rating": 5}) - - assert result["metadata"]["tags"] == ["favorite"] - - def test_download_image(self, client: TsrClient, mock_server) -> None: - """Test downloading image bytes.""" - image_bytes = b"\x89PNG test image data" - mock_server.get("/api/images/123_0").mock(return_value=Response(200, content=image_bytes)) - - with client: - result = client.download_image("123_0") - - assert result == image_bytes - - -# ============================================================================= -# Models Tests -# ============================================================================= - - -class TestModels: - """Tests for model management operations.""" - - def test_list_models(self, client: TsrClient, mock_server) -> None: - """Test listing available models.""" - mock_server.get("/api/models").mock( - return_value=Response( - 200, - json={ - "models": [ - {"name": "sdxl_base", "path": "/models/sdxl_base.safetensors"}, - {"name": "pony_v6", "path": "/models/pony_v6.safetensors"}, - ], - "active": "/models/sdxl_base.safetensors", - }, - ) - ) - - with client: - result = client.list_models() - - assert len(result["models"]) == 2 - assert result["active"] == "/models/sdxl_base.safetensors" - - def test_get_active_model(self, client: TsrClient, mock_server) -> None: - """Test getting active model.""" - mock_server.get("/api/models/active").mock(return_value=Response(200, json={"model": "/models/sdxl_base.safetensors"})) - - with client: - result = client.get_active_model() - - assert result["model"] == "/models/sdxl_base.safetensors" - - def test_switch_model(self, client: TsrClient, mock_server) -> None: - """Test switching model.""" - mock_server.post("/api/models/switch").mock( - return_value=Response(200, json={"status": "ok", "model": "/models/pony_v6.safetensors"}) - ) - - with client: - result = client.switch_model("/models/pony_v6.safetensors") - - assert result["status"] == "ok" - - def test_list_loras(self, client: TsrClient, mock_server) -> None: - """Test listing LoRAs.""" - mock_server.get("/api/models/loras").mock( - return_value=Response( - 200, - json={ - "loras": [ - {"name": "detail_tweaker", "path": "/loras/detail_tweaker.safetensors"}, - ] - }, - ) - ) - - with client: - result = client.list_loras() - - assert len(result["loras"]) == 1 - - def test_scan_models(self, client: TsrClient, mock_server) -> None: - """Test scanning models.""" - mock_server.get("/api/models/scan").mock(return_value=Response(200, json={"scanned": 5})) - - with client: - result = client.scan_models() - - assert result["scanned"] == 5 - - -# ============================================================================= -# Generation Tests -# ============================================================================= - - -class TestGeneration: - """Tests for image generation.""" - - def test_generate(self, client: TsrClient, mock_server) -> None: - """Test generating an image.""" - mock_server.post("/api/generate").mock( - return_value=Response( - 200, - json={ - "images": [{"id": "999_42", "seed": 42}], - "parameters": {"prompt": "test prompt", "seed": 42}, - }, - ) - ) - - with client: - result = client.generate( - prompt="test prompt", - width=512, - height=512, - seed=42, - ) - - assert len(result["images"]) == 1 - assert result["images"][0]["seed"] == 42 - - def test_generate_with_all_params(self, client: TsrClient, mock_server) -> None: - """Test generation with all parameters.""" - mock_server.post("/api/generate").mock(return_value=Response(200, json={"images": []})) - - with client: - result = client.generate( - prompt="detailed test prompt", - negative_prompt="bad quality", - width=1024, - height=1024, - steps=30, - cfg_scale=5.5, - seed=12345, - sampler_name="DPM++ 2M", - scheduler="karras", - batch_size=2, - save_to_gallery=False, - return_base64=True, - ) - - assert "images" in result - - def test_list_samplers(self, client: TsrClient, mock_server) -> None: - """Test listing samplers.""" - mock_server.get("/api/samplers").mock(return_value=Response(200, json={"samplers": ["Euler", "DPM++ 2M", "Euler a"]})) - - with client: - result = client.list_samplers() - - assert "samplers" in result - - def test_list_schedulers(self, client: TsrClient, mock_server) -> None: - """Test listing schedulers.""" - mock_server.get("/api/schedulers").mock( - return_value=Response(200, json={"schedulers": ["simple", "karras", "sgm_uniform"]}) - ) - - with client: - result = client.list_schedulers() - - assert "schedulers" in result - - -# ============================================================================= -# Download Tests -# ============================================================================= - - -class TestDownload: - """Tests for CivitAI download operations.""" - - def test_start_download_by_version(self, client: TsrClient, mock_server) -> None: - """Test starting download by version ID.""" - mock_server.post("/api/download").mock( - return_value=Response(200, json={"download_id": "abc123", "status": "started", "version_id": 12345}) - ) - - with client: - result = client.start_download(version_id=12345) - - assert result["download_id"] == "abc123" - - def test_start_download_by_hash(self, client: TsrClient, mock_server) -> None: - """Test starting download by hash.""" - mock_server.post("/api/download").mock(return_value=Response(200, json={"download_id": "def456", "status": "started"})) - - with client: - result = client.start_download(hash_val="ABC123DEF456") - - assert result["status"] == "started" - - def test_get_download_status(self, client: TsrClient, mock_server) -> None: - """Test getting download status.""" - mock_server.get("/api/download/status/abc123").mock( - return_value=Response(200, json={"download_id": "abc123", "status": "downloading", "progress": 0.5}) - ) - - with client: - result = client.get_download_status("abc123") - - assert result["progress"] == 0.5 - - def test_list_downloads(self, client: TsrClient, mock_server) -> None: - """Test listing active downloads.""" - mock_server.get("/api/download/active").mock( - return_value=Response(200, json={"downloads": [{"id": "abc123", "progress": 0.75}]}) - ) - - with client: - result = client.list_downloads() - - assert len(result["downloads"]) == 1 - - -# ============================================================================= -# Database Tests -# ============================================================================= - - -class TestDatabase: - """Tests for database operations.""" - - def test_db_list_files(self, client: TsrClient, mock_server) -> None: - """Test listing local files.""" - mock_server.get("/api/db/files").mock( - return_value=Response(200, json=[{"id": 1, "file_path": "/models/test.safetensors", "sha256": "abc123"}]) - ) - - with client: - result = client.db_list_files() - - assert len(result) == 1 - assert result[0]["sha256"] == "abc123" - - def test_db_search_models(self, client: TsrClient, mock_server) -> None: - """Test searching cached models.""" - mock_server.get("/api/db/models").mock( - return_value=Response(200, json=[{"civitai_id": 12345, "name": "Test Model", "type": "LORA"}]) - ) - - with client: - result = client.db_search_models(query="Test", model_type="LORA") - - assert len(result) == 1 - assert result[0]["name"] == "Test Model" - - def test_db_get_model(self, client: TsrClient, mock_server) -> None: - """Test getting cached model.""" - mock_server.get("/api/db/models/12345").mock( - return_value=Response(200, json={"civitai_id": 12345, "name": "Test Model", "type": "Checkpoint"}) - ) - - with client: - result = client.db_get_model(12345) - - assert result["name"] == "Test Model" - - def test_db_get_triggers(self, client: TsrClient, mock_server) -> None: - """Test getting trigger words.""" - mock_server.get("/api/db/triggers/12345").mock(return_value=Response(200, json=["trigger1", "trigger2"])) - - with client: - result = client.db_get_triggers(version_id=12345) - - assert result == ["trigger1", "trigger2"] - - def test_db_stats(self, client: TsrClient, mock_server) -> None: - """Test getting database stats.""" - mock_server.get("/api/db/stats").mock( - return_value=Response(200, json={"local_files": 10, "models": 5, "model_versions": 15}) - ) - - with client: - result = client.db_stats() - - assert result["local_files"] == 10 - - def test_db_scan(self, client: TsrClient, mock_server) -> None: - """Test scanning directory.""" - mock_server.post("/api/db/scan").mock(return_value=Response(200, json={"scanned": 3, "files": []})) - - with client: - result = client.db_scan("/models") - - assert result["scanned"] == 3 - - def test_db_link(self, client: TsrClient, mock_server) -> None: - """Test linking files to CivitAI.""" - mock_server.post("/api/db/link").mock(return_value=Response(200, json={"linked": 2})) - - with client: - result = client.db_link() - - assert result["linked"] == 2 - - def test_db_cache(self, client: TsrClient, mock_server) -> None: - """Test caching model data.""" - mock_server.post("/api/db/cache").mock(return_value=Response(200, json={"model_id": 12345, "cached": True})) - - with client: - result = client.db_cache(12345) - - assert result["cached"] is True - - -# ============================================================================= -# Error Handling Tests -# ============================================================================= - - -class TestErrorHandling: - """Tests for error handling.""" - - def test_http_error(self, client: TsrClient, mock_server) -> None: - """Test HTTP error handling.""" - mock_server.get("/api/images").mock(return_value=Response(500, text="Internal server error")) - - with client, pytest.raises(TsrClientError, match="HTTP 500"): - client.list_images() - - def test_not_found_error(self, client: TsrClient, mock_server) -> None: - """Test 404 error handling.""" - mock_server.get("/api/images/nonexistent/meta").mock(return_value=Response(404, json={"detail": "Image not found"})) - - with client, pytest.raises(TsrClientError, match="HTTP 404"): - client.get_image_meta("nonexistent") - - -# ============================================================================= -# Context Manager Tests -# ============================================================================= - - -class TestContextManager: - """Tests for context manager usage.""" - - def test_context_manager(self, mock_server) -> None: - """Test client works as context manager.""" - mock_server.get("/status").mock(return_value=Response(200, json={"running": True})) - - with TsrClient(BASE_URL) as client: - result = client.status() - assert result["running"] is True - - def test_client_without_context(self, mock_server) -> None: - """Test client works without context manager.""" - mock_server.get("/status").mock(return_value=Response(200, json={"running": True})) - - client = TsrClient(BASE_URL) - result = client.status() - assert result["running"] is True diff --git a/tests/test_generate.py b/tests/test_generate.py deleted file mode 100644 index fdfe63d..0000000 --- a/tests/test_generate.py +++ /dev/null @@ -1,303 +0,0 @@ -"""Tests for tensors.generate package.""" - -from __future__ import annotations - -import base64 -import json -from pathlib import Path - -import httpx -import pytest -import respx - -from tensors.generate import SDClient -from tensors.generate._http import HttpTransport -from tensors.generate.params import Img2ImgParams, Txt2ImgParams -from tensors.generate.util import save_images, to_b64 -from tests.conftest import BASE_URL, TINY_PNG, TINY_PNG_B64 - -# ── util ────────────────────────────────────────────────────────────── - - -class TestToB64: - def test_bytes_input(self): - raw = b"hello" - assert to_b64(raw) == base64.b64encode(raw).decode() - - def test_file_path(self, tmp_path: Path): - f = tmp_path / "img.png" - f.write_bytes(b"\x89PNG") - result = to_b64(str(f)) - assert base64.b64decode(result) == b"\x89PNG" - - def test_pathlib_path(self, tmp_path: Path): - f = tmp_path / "img.png" - f.write_bytes(b"data") - result = to_b64(f) - assert base64.b64decode(result) == b"data" - - def test_passthrough_string(self): - b64 = base64.b64encode(b"already").decode() - assert to_b64(b64) == b64 - - def test_unsupported_type(self): - with pytest.raises(TypeError, match="unsupported image type"): - to_b64(12345) # type: ignore[arg-type] - - -class TestSaveImages: - def test_saves_files(self, tmp_path: Path): - images = [b"img0", b"img1", b"img2"] - paths = save_images(images, str(tmp_path), prefix="test") - assert len(paths) == 3 - for i, p in enumerate(paths): - assert p.name == f"test_{i:04d}.png" - assert p.read_bytes() == images[i] - - def test_creates_directory(self, tmp_path: Path): - out = tmp_path / "sub" / "dir" - save_images([b"x"], str(out)) - assert (out / "output_0000.png").exists() - - -# ── params ──────────────────────────────────────────────────────────── - - -class TestTxt2ImgParams: - def test_minimal_body(self): - p = Txt2ImgParams(prompt="a cat") - body = p.to_body() - assert body["prompt"] == "a cat" - assert body["width"] == 512 - assert body["height"] == 512 - assert body["steps"] == 20 - assert body["seed"] == -1 - assert "sampler_name" not in body - assert "scheduler" not in body - assert "clip_skip" not in body - assert "lora" not in body - - def test_optional_fields_included(self): - p = Txt2ImgParams( - prompt="test", - sampler_name="euler_a", - scheduler="karras", - clip_skip=2, - lora=[{"path": "x.safetensors", "multiplier": 0.5}], - ) - body = p.to_body() - assert body["sampler_name"] == "euler_a" - assert body["scheduler"] == "karras" - assert body["clip_skip"] == 2 - assert len(body["lora"]) == 1 - - -class TestImg2ImgParams: - def test_minimal_body(self, tmp_path: Path): - img = tmp_path / "init.png" - img.write_bytes(b"\x89PNG") - p = Img2ImgParams(prompt="paint it", init_image=str(img)) - body = p.to_body() - assert body["prompt"] == "paint it" - assert body["denoising_strength"] == 0.75 - decoded = base64.b64decode(body["init_images"][0]) - assert decoded == b"\x89PNG" - assert "width" not in body - assert "height" not in body - assert "mask" not in body - - def test_all_optional_fields(self, tmp_path: Path): - img = tmp_path / "init.png" - img.write_bytes(b"img") - mask = tmp_path / "mask.png" - mask.write_bytes(b"mask") - extra = tmp_path / "extra.png" - extra.write_bytes(b"extra") - - p = Img2ImgParams( - prompt="test", - init_image=str(img), - mask=str(mask), - width=768, - height=768, - inpainting_mask_invert=True, - sampler_name="euler", - scheduler="simple", - clip_skip=1, - lora=[{"path": "a.gguf", "multiplier": 1.0}], - extra_images=[str(extra)], - ) - body = p.to_body() - assert body["width"] == 768 - assert body["mask"] - assert body["inpainting_mask_invert"] == 1 - assert body["sampler_name"] == "euler" - assert len(body["extra_images"]) == 1 - - -# ── _http ───────────────────────────────────────────────────────────── - - -class TestHttpTransport: - def test_get_success(self): - with respx.mock(base_url=BASE_URL) as rsps: - rsps.get("/test").respond(json={"ok": True}) - t = HttpTransport(BASE_URL) - assert t.get("/test") == {"ok": True} - t.close() - - def test_post_success(self): - with respx.mock(base_url=BASE_URL) as rsps: - rsps.post("/gen").respond(json={"images": []}) - t = HttpTransport(BASE_URL) - assert t.post("/gen", {"prompt": "x"}) == {"images": []} - t.close() - - def test_get_http_error(self): - with respx.mock(base_url=BASE_URL) as rsps: - rsps.get("/bad").respond(status_code=404, text="not found") - t = HttpTransport(BASE_URL) - with pytest.raises(httpx.HTTPStatusError): - t.get("/bad") - t.close() - - def test_post_http_error(self): - with respx.mock(base_url=BASE_URL) as rsps: - rsps.post("/bad").respond(status_code=500, text="error") - t = HttpTransport(BASE_URL) - with pytest.raises(httpx.HTTPStatusError): - t.post("/bad", {}) - t.close() - - def test_get_connection_error(self): - with respx.mock(base_url=BASE_URL) as rsps: - rsps.get("/fail").mock(side_effect=httpx.ConnectError("refused")) - t = HttpTransport(BASE_URL) - with pytest.raises(httpx.ConnectError): - t.get("/fail") - t.close() - - -# ── info ────────────────────────────────────────────────────────────── - - -class TestInfoAPI: - def test_models(self, mock_api: respx.MockRouter, client: SDClient): - mock_api.get("/v1/models").respond(json={"data": [{"id": "sd-cpp-local", "object": "model", "owned_by": "local"}]}) - result = client.info.models() - assert len(result) == 1 - assert result[0]["id"] == "sd-cpp-local" - - def test_sd_models(self, mock_api: respx.MockRouter, client: SDClient): - mock_api.get("/sdapi/v1/sd-models").respond( - json=[{"title": "sdxl", "model_name": "sdxl", "filename": "sdxl.safetensors"}] - ) - result = client.info.sd_models() - assert result[0]["title"] == "sdxl" - - def test_options(self, mock_api: respx.MockRouter, client: SDClient): - mock_api.get("/sdapi/v1/options").respond( - json={ - "samples_format": "png", - "sd_model_checkpoint": "v1-5", - } - ) - result = client.info.options() - assert result["sd_model_checkpoint"] == "v1-5" - - def test_loras(self, mock_api: respx.MockRouter, client: SDClient): - mock_api.get("/sdapi/v1/loras").respond( - json=[ - {"name": "style", "path": "style.safetensors"}, - ] - ) - result = client.info.loras() - assert len(result) == 1 - assert result[0]["name"] == "style" - - def test_samplers(self, mock_api: respx.MockRouter, client: SDClient): - mock_api.get("/sdapi/v1/samplers").respond( - json=[ - {"name": "euler", "aliases": ["euler"], "options": {}}, - {"name": "euler_a", "aliases": ["euler_a"], "options": {}}, - ] - ) - result = client.info.samplers() - assert result == ["euler", "euler_a"] - - def test_schedulers(self, mock_api: respx.MockRouter, client: SDClient): - mock_api.get("/sdapi/v1/schedulers").respond( - json=[ - {"name": "discrete", "label": "discrete"}, - {"name": "karras", "label": "karras"}, - ] - ) - result = client.info.schedulers() - assert result == ["discrete", "karras"] - - -# ── generation ──────────────────────────────────────────────────────── - - -class TestTxt2Img: - def test_returns_decoded_images(self, mock_api: respx.MockRouter, client: SDClient): - mock_api.post("/sdapi/v1/txt2img").respond( - json={ - "images": [TINY_PNG_B64], - "parameters": {}, - "info": "", - } - ) - images = client.generate.txt2img(Txt2ImgParams(prompt="a cat")) - assert len(images) == 1 - assert images[0] == TINY_PNG - - def test_multiple_images(self, mock_api: respx.MockRouter, client: SDClient): - mock_api.post("/sdapi/v1/txt2img").respond( - json={ - "images": [TINY_PNG_B64, TINY_PNG_B64, TINY_PNG_B64], - "parameters": {}, - "info": "", - } - ) - params = Txt2ImgParams(prompt="cats", batch_size=3) - images = client.generate.txt2img(params) - assert len(images) == 3 - - def test_sends_correct_body(self, mock_api: respx.MockRouter, client: SDClient): - route = mock_api.post("/sdapi/v1/txt2img").respond( - json={ - "images": [TINY_PNG_B64], - "parameters": {}, - "info": "", - } - ) - params = Txt2ImgParams( - prompt="hello", - width=768, - height=768, - steps=30, - sampler_name="euler_a", - ) - client.generate.txt2img(params) - sent = json.loads(route.calls[0].request.content) - assert sent["prompt"] == "hello" - assert sent["width"] == 768 - assert sent["sampler_name"] == "euler_a" - - -class TestImg2Img: - def test_returns_decoded_images(self, mock_api: respx.MockRouter, client: SDClient, tmp_path: Path): - mock_api.post("/sdapi/v1/img2img").respond( - json={ - "images": [TINY_PNG_B64], - "parameters": {}, - "info": "", - } - ) - img = tmp_path / "init.png" - img.write_bytes(TINY_PNG) - params = Img2ImgParams(prompt="paint", init_image=str(img)) - images = client.generate.img2img(params) - assert len(images) == 1 - assert images[0] == TINY_PNG diff --git a/tests/test_server.py b/tests/test_server.py index 8050c49..907a9d2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,12 +1,8 @@ -"""Tests for tensors.server package (FastAPI sd-server proxy wrapper).""" +"""Tests for tensors.server package (gallery and CivitAI management).""" from __future__ import annotations -from unittest.mock import AsyncMock - -import httpx import pytest -import respx from fastapi.testclient import TestClient from tensors.server import create_app @@ -14,87 +10,17 @@ from tensors.server import create_app @pytest.fixture() def api() -> TestClient: - """Create test client with mock sd-server URL.""" - return TestClient(create_app(sd_server_url="http://mock-sd-server:1234")) + """Create test client.""" + return TestClient(create_app()) class TestStatus: - @respx.mock - def test_status_when_backend_reachable(self) -> None: - """Test status endpoint when sd-server is reachable.""" - respx.get("http://mock-sd-server:1234/").mock(return_value=httpx.Response(200)) - - with TestClient(create_app(sd_server_url="http://mock-sd-server:1234")) as client: - r = client.get("/status") - assert r.status_code == 200 - data = r.json() - assert data["status"] == "ok" - assert data["sd_server_url"] == "http://mock-sd-server:1234" - - @respx.mock - def test_status_when_backend_unreachable(self) -> None: - """Test status endpoint when sd-server is not reachable.""" - respx.get("http://mock-sd-server:1234/").mock(side_effect=httpx.ConnectError("Connection refused")) - - with TestClient(create_app(sd_server_url="http://mock-sd-server:1234")) as client: - r = client.get("/status") - assert r.status_code == 200 - data = r.json() - assert data["status"] == "error" - assert "Connection refused" in data["error"] - - -class TestProxy: - def test_proxy_forwards_request(self, api: TestClient) -> None: - """Test proxy forwards GET requests to backend.""" - upstream_response = httpx.Response( - 200, - json={"data": [{"id": "model-1"}]}, - headers={"content-type": "application/json"}, - ) - mock_client = AsyncMock() - mock_client.request.return_value = upstream_response - api.app.state.client = mock_client # type: ignore[attr-defined] - api.app.state.sd_server_url = "http://mock-sd-server:1234" # type: ignore[attr-defined] - - r = api.get("/v1/models") + def test_status_ok(self, api: TestClient) -> None: + """Test status endpoint returns ok.""" + r = api.get("/status") assert r.status_code == 200 - assert r.json() == {"data": [{"id": "model-1"}]} - mock_client.request.assert_called_once() - - def test_proxy_forwards_post_with_body(self, api: TestClient) -> None: - """Test proxy forwards POST requests with body.""" - upstream_response = httpx.Response(200, json={"ok": True}) - mock_client = AsyncMock() - mock_client.request.return_value = upstream_response - api.app.state.client = mock_client # type: ignore[attr-defined] - api.app.state.sd_server_url = "http://mock-sd-server:1234" # type: ignore[attr-defined] - - r = api.post("/sdapi/v1/txt2img", json={"prompt": "hello"}) - assert r.status_code == 200 - mock_client.request.assert_called_once() - - def test_proxy_503_on_connect_error(self, api: TestClient) -> None: - """Test proxy returns 503 when backend is unreachable.""" - mock_client = AsyncMock() - mock_client.request.side_effect = httpx.ConnectError("Connection refused") - api.app.state.client = mock_client # type: ignore[attr-defined] - api.app.state.sd_server_url = "http://mock-sd-server:1234" # type: ignore[attr-defined] - - r = api.get("/v1/models") - assert r.status_code == 503 - assert "Cannot connect" in r.json()["error"] - - def test_proxy_504_on_timeout(self, api: TestClient) -> None: - """Test proxy returns 504 on timeout.""" - mock_client = AsyncMock() - mock_client.request.side_effect = httpx.TimeoutException("Timeout") - api.app.state.client = mock_client # type: ignore[attr-defined] - api.app.state.sd_server_url = "http://mock-sd-server:1234" # type: ignore[attr-defined] - - r = api.get("/v1/models") - assert r.status_code == 504 - assert "Timeout" in r.json()["error"] + data = r.json() + assert data["status"] == "ok" # =============================================================================