diff --git a/packages/gemini-image/README.md b/packages/gemini-image/README.md new file mode 100644 index 0000000..3a87faf --- /dev/null +++ b/packages/gemini-image/README.md @@ -0,0 +1,189 @@ +# Gemini Image Generation + +A comprehensive image generation library built on Google's Gemini models (Nano Banana / Nano Banana Pro). + +## Features + +- **Text-to-image generation** with configurable resolution and aspect ratio +- **Reference-based editing** - modify existing images with prompts +- **Multi-part story generation** - sequential images with visual continuity +- **Draft-then-finalize workflow** - 75% cost reduction during iteration +- **Thinking mode** - visualize model reasoning with intermediate images + +## Installation + +```bash +# Using uv (recommended) +uv add byronwilliamscpa-gemini-image + +# Using pip +pip install byronwilliamscpa-gemini-image +``` + +## Quick Start + +### Set API Key + +```bash +export GEMINI_API_KEY='your-api-key' +``` + +### Python API + +```python +from gemini_image import generate_image, generate_story_sequence + +# Basic text-to-image +result = generate_image("A futuristic city at sunset") +print(f"Image saved to: {result}") + +# With resolution and aspect ratio +result = generate_image( + "A technical blueprint", + aspect_ratio="16:9", + image_size="2K", + verbose=True, +) + +# Draft mode for iteration (1K resolution) +draft = generate_image( + "A data governance diagram", + is_draft=True, +) + +# Reference-based editing +from pathlib import Path +edited = generate_image( + "Make the title larger", + reference_images=[Path("original.png")], +) + +# Multi-part story sequence +from gemini_image import generate_story_sequence + +images = generate_story_sequence( + "A journey through data governance evolution", + num_parts=3, + aspect_ratio="16:9", +) +``` + +### Command Line + +```bash +# Basic generation +gemini-image "A serene mountain landscape at dawn" + +# With output path +gemini-image "A data governance diagram" -o governance.png + +# Draft mode (faster, lower cost) +gemini-image "A technical blueprint" --draft-mode -o draft.png + +# Finalize draft at higher resolution +gemini-image --finalize draft.png --size 2K -o final.png + +# Reference-based editing +gemini-image "Make the building taller" -r blueprint.png + +# Multi-part story +gemini-image "Evolution of a data platform" --story-parts 4 -o evolution + +# Show thinking process +gemini-image "Complex blueprint design" --save-thoughts --verbose + +# List available models +gemini-image --list-models +``` + +## Models + +| Key | Model | Features | +|-----|-------|----------| +| `flash` | Gemini 2.5 Flash | Fast generation | +| `pro` | Gemini 3 Pro (default) | 4K, better text rendering, thinking mode | + +## Resolution Options (Pro Model) + +| Size | Dimensions (16:9) | Use Case | +|------|-------------------|----------| +| 1K | ~1408 x 768 | Draft mode, fast iteration | +| 2K | 2752 x 1536 | Standard documents | +| 4K | 5504 x 3072 | High-detail, large prints | + +## Aspect Ratios + +- `1:1` - Square +- `3:4` - Portrait +- `4:3` - Standard landscape +- `9:16` - Vertical/mobile +- `16:9` - Widescreen (default) + +## Draft-Then-Finalize Workflow + +Reduce costs by ~75% during iteration: + +```bash +# 1. Generate draft at 1K +gemini-image "A technical blueprint" --draft-mode -o draft.png + +# 2. Iterate on draft +gemini-image "Add more detail to the header" -r draft.png --draft-mode -o draft_v2.png + +# 3. Finalize at 2K when satisfied +gemini-image --finalize draft_v2.png --size 2K -o final.png +``` + +## API Reference + +### `generate_image()` + +```python +def generate_image( + prompt: str, + model_key: ModelKey = "pro", + reference_images: list[Path] | None = None, + output_path: Path | None = None, + output_dir: Path | None = None, + aspect_ratio: AspectRatio | None = None, + image_size: ImageSize | None = None, + use_search: bool = False, + save_thoughts: bool = False, + verbose: bool = False, + is_draft: bool = False, +) -> Path | None: +``` + +### `generate_story_sequence()` + +```python +def generate_story_sequence( + base_prompt: str, + num_parts: int, + model_key: ModelKey = "pro", + output_prefix: Path | None = None, + output_dir: Path | None = None, + aspect_ratio: AspectRatio | None = None, + image_size: ImageSize | None = None, + verbose: bool = False, +) -> list[Path]: +``` + +### `finalize_draft()` + +```python +def finalize_draft( + draft_path: Path, + prompt: str | None = None, + model_key: ModelKey = "pro", + output_path: Path | None = None, + output_dir: Path | None = None, + aspect_ratio: AspectRatio | None = None, + image_size: ImageSize | None = None, + verbose: bool = False, +) -> Path | None: +``` + +## License + +MIT diff --git a/packages/gemini-image/pyproject.toml b/packages/gemini-image/pyproject.toml new file mode 100644 index 0000000..6493126 --- /dev/null +++ b/packages/gemini-image/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "byronwilliamscpa-gemini-image" +version = "0.1.0" +description = "Image generation using Google Gemini models (Nano Banana / Nano Banana Pro)" +readme = "README.md" +requires-python = ">=3.10,<3.15" +license = {text = "MIT"} +authors = [ + {name = "Byron Williams", email = "byronawilliams@gmail.com"} +] +keywords = ["gemini", "image-generation", "ai", "google", "genai", "text-to-image"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Graphics", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Typing :: Typed", +] + +dependencies = [ + "google-genai>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "pytest-asyncio>=0.21.0", +] + +[project.scripts] +gemini-image = "gemini_image.cli:main" + +[project.urls] +Homepage = "https://github.com/ByronWilliamsCPA/python-libs" +Repository = "https://github.com/ByronWilliamsCPA/python-libs" +Documentation = "https://github.com/ByronWilliamsCPA/python-libs#readme" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/gemini_image"] + +# Per-package semantic release configuration +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +tag_format = "gemini-image-v{version}" + +[tool.semantic_release.commit_parser_options] +allowed_tags = ["feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore"] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] + +[tool.semantic_release.changelog] +changelog_file = "CHANGELOG.md" + +[tool.semantic_release.branches.main] +match = "(main|master)" +prerelease = false diff --git a/packages/gemini-image/src/gemini_image/__init__.py b/packages/gemini-image/src/gemini_image/__init__.py new file mode 100644 index 0000000..b1e82b6 --- /dev/null +++ b/packages/gemini-image/src/gemini_image/__init__.py @@ -0,0 +1,48 @@ +"""Gemini Image Generation Library. + +A comprehensive image generation system built on Google's Gemini models. + +Features: + - Text-to-image generation with configurable resolution and aspect ratio + - Reference-based image editing and refinement + - Multi-part story sequence generation with visual continuity + - Draft-then-finalize workflow for cost optimization + - Thinking mode with intermediate image visualization + +Models: + - flash: Gemini 2.5 Flash (fast generation) + - pro: Gemini 3 Pro (4K, better text rendering, thinking mode) + +Example: + >>> from gemini_image import generate_image, MODELS + >>> result = generate_image("A futuristic city at sunset") + >>> print(f"Image saved to: {result}") + +""" + +from gemini_image.generator import generate_image, generate_story_sequence +from gemini_image.models import ( + ASPECT_RATIOS, + DEFAULT_MODEL, + IMAGE_SIZES, + MODELS, + AspectRatio, + ImageSize, + ModelConfig, + ModelKey, +) + +__all__ = [ + "ASPECT_RATIOS", + "DEFAULT_MODEL", + "IMAGE_SIZES", + "MODELS", + "AspectRatio", + "ImageSize", + "ModelConfig", + "ModelKey", + "generate_image", + "generate_story_sequence", +] + +__version__ = "0.1.0" diff --git a/packages/gemini-image/src/gemini_image/cli.py b/packages/gemini-image/src/gemini_image/cli.py new file mode 100644 index 0000000..74ee5c2 --- /dev/null +++ b/packages/gemini-image/src/gemini_image/cli.py @@ -0,0 +1,230 @@ +"""Command-line interface for Gemini image generation.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from gemini_image.generator import ( + finalize_draft, + generate_image, + generate_story_sequence, +) +from gemini_image.models import ASPECT_RATIOS, DEFAULT_MODEL, IMAGE_SIZES, MODELS + + +def list_models() -> None: + """Print available models.""" + print("Available models:\n") # noqa: T201 + for key, config in MODELS.items(): + print(f" {key}:") # noqa: T201 + print(f" Name: {config['name']}") # noqa: T201 + print(f" ID: {config['id']}") # noqa: T201 + print(f" Description: {config['description']}") # noqa: T201 + print() # noqa: T201 + + +def main() -> None: + """Main entry point for CLI.""" + parser = argparse.ArgumentParser( + description="Generate images using Google Gemini (Nano Banana / Nano Banana Pro)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Single image generation + %(prog)s "A serene mountain landscape at dawn" + %(prog)s "A data governance diagram" -o governance.png + + # Draft-then-finalize workflow (cost-effective iteration) + %(prog)s "A technical blueprint" --draft-mode -o draft.png + %(prog)s "Adjust colors" -r draft.png --draft-mode -o draft_v2.png + %(prog)s --finalize draft_v2.png --size 2K -o final.png + + # Image editing with reference + %(prog)s "Make the building taller" -r blueprint.png + %(prog)s "Refine this architectural drawing" -r img1.png -r img2.png + + # Advanced options + %(prog)s "A landscape" --aspect 16:9 --size 4K + %(prog)s "Current weather in Tokyo" --search + %(prog)s "Complex blueprint design" --save-thoughts --verbose + + # Multi-part story generation (automatic continuity) + %(prog)s "A 3-part journey through data governance" --story-parts 3 -o journey + %(prog)s "Evolution of a data platform" --story-parts 4 --aspect 16:9 --size 2K -o evolution + """, + ) + + parser.add_argument( + "prompt", + nargs="?", + help="Text prompt describing the image to generate", + ) + + parser.add_argument( + "-o", + "--output", + type=Path, + help="Output file path (default: generated_TIMESTAMP.png)", + ) + + parser.add_argument( + "-d", + "--output-dir", + type=Path, + help="Output directory (default: current directory)", + ) + + parser.add_argument( + "-m", + "--model", + choices=list(MODELS.keys()), + default=DEFAULT_MODEL, + help=f"Model to use (default: {DEFAULT_MODEL})", + ) + + parser.add_argument( + "-r", + "--reference", + type=Path, + action="append", + dest="references", + help="Reference image(s) for editing or style (can be used multiple times)", + ) + + parser.add_argument( + "--aspect", + choices=ASPECT_RATIOS, + help="Aspect ratio (pro model only): 1:1, 3:4, 4:3, 9:16, 16:9", + ) + + parser.add_argument( + "--size", + choices=IMAGE_SIZES, + help="Image size (pro model only): 1K, 2K, 4K", + ) + + parser.add_argument( + "--search", + action="store_true", + help="Enable Google Search grounding for real-time data (pro model only)", + ) + + parser.add_argument( + "--save-thoughts", + action="store_true", + help="Save intermediate thought images (pro model only)", + ) + + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show detailed thinking process and save thought signatures", + ) + + parser.add_argument( + "--story-parts", + type=int, + metavar="N", + help="Generate a multi-part story with N parts (uses previous image as reference)", + ) + + parser.add_argument( + "--draft-mode", + action="store_true", + help="Generate at 1K resolution for faster, lower-cost iteration", + ) + + parser.add_argument( + "--finalize", + type=Path, + metavar="DRAFT_IMAGE", + help="Finalize a draft image by regenerating at higher resolution (2K default)", + ) + + parser.add_argument( + "--list-models", + action="store_true", + help="List available models and exit", + ) + + args = parser.parse_args() + + if args.list_models: + list_models() + return + + # Finalize mode + if args.finalize: + try: + result = finalize_draft( + draft_path=args.finalize, + prompt=args.prompt, + model_key=args.model, + output_path=args.output, + output_dir=args.output_dir, + aspect_ratio=args.aspect, + image_size=args.size, + verbose=args.verbose, + ) + sys.exit(0 if result else 1) + except FileNotFoundError as e: + print(f"Error: {e}") # noqa: T201 + sys.exit(1) + + if not args.prompt: + parser.print_help() + sys.exit(1) + + # Story sequence mode + if args.story_parts: + if args.story_parts < 2: + print("Error: Story must have at least 2 parts") # noqa: T201 + sys.exit(1) + + results = generate_story_sequence( + base_prompt=args.prompt, + num_parts=args.story_parts, + model_key=args.model, + output_prefix=args.output, + output_dir=args.output_dir, + aspect_ratio=args.aspect, + image_size=args.size, + verbose=args.verbose, + ) + + sys.exit(0 if len(results) == args.story_parts else 1) + + # Single image mode + try: + result = generate_image( + prompt=args.prompt, + model_key=args.model, + reference_images=args.references, + output_path=args.output, + output_dir=args.output_dir, + aspect_ratio=args.aspect, + image_size=args.size, + use_search=args.search, + save_thoughts=args.save_thoughts, + verbose=args.verbose, + is_draft=args.draft_mode, + ) + + if result and args.draft_mode: + print(f"\n{'=' * 60}") # noqa: T201 + print("Draft complete! To finalize at higher resolution:") # noqa: T201 + print(f" gemini-image --finalize {result} --size 2K") # noqa: T201 + print(f"{'=' * 60}") # noqa: T201 + + sys.exit(0 if result else 1) + + except (ValueError, ImportError) as e: + print(f"Error: {e}") # noqa: T201 + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/packages/gemini-image/src/gemini_image/generator.py b/packages/gemini-image/src/gemini_image/generator.py new file mode 100644 index 0000000..6b06e17 --- /dev/null +++ b/packages/gemini-image/src/gemini_image/generator.py @@ -0,0 +1,501 @@ +"""Core image generation functions using Google Gemini. + +Note: This module has complexity warnings (C901, PLR0912, PLR0915) due to the +comprehensive response handling logic inherited from the source script. +The google-genai types are dynamically loaded, causing reportUnknown* warnings. +""" +# ruff: noqa: C901, PLR0912, PLR0915, PLC0415 + +from __future__ import annotations + +import base64 +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from gemini_image.models import ( + ASPECT_RATIOS, + DEFAULT_MODEL, + IMAGE_SIZES, + MODELS, + AspectRatio, + ImageSize, + ModelKey, +) +from gemini_image.utils import ( + get_api_key, + get_file_extension, + load_image_as_base64, +) + +# Lazy import for google.genai +_genai = None +_types = None + + +def _get_genai() -> tuple[Any, Any]: + """Lazy import google.genai to avoid import errors when not installed.""" + global _genai, _types # noqa: PLW0603 + if _genai is None: + try: + from google import genai + from google.genai import types + + _genai = genai + _types = types + except ImportError as e: + msg = ( + "google-genai package not installed. " + "Install with: pip install google-genai" + ) + raise ImportError(msg) from e + return _genai, _types + + +def generate_image( + prompt: str, + model_key: ModelKey = DEFAULT_MODEL, + reference_images: list[Path] | None = None, + output_path: Path | None = None, + output_dir: Path | None = None, + aspect_ratio: AspectRatio | None = None, + image_size: ImageSize | None = None, + use_search: bool = False, + save_thoughts: bool = False, + verbose: bool = False, + is_draft: bool = False, +) -> Path | None: + """Generate an image using Gemini. + + Args: + prompt: Text description of the image to generate. + model_key: Model to use ('flash' or 'pro'). + reference_images: Optional list of reference images for editing/style. + output_path: Optional output file path. If not provided, generates + a timestamped filename. + output_dir: Optional output directory. Defaults to current directory. + aspect_ratio: Aspect ratio for pro model (e.g., "16:9", "1:1"). + image_size: Image size for pro model ("1K", "2K", "4K"). + use_search: Enable Google Search grounding (pro model only). + save_thoughts: Save intermediate thought images (pro model only). + verbose: Show detailed thinking process and thought signatures. + is_draft: Generate at 1K resolution for fast iteration. + + Returns: + Path to the generated image, or None on failure. + + Raises: + ValueError: If model_key is invalid or API key is missing. + ImportError: If google-genai is not installed. + + """ + genai, types = _get_genai() + api_key = get_api_key() + + if model_key not in MODELS: + msg = f"Unknown model '{model_key}'. Valid options: {list(MODELS.keys())}" + raise ValueError(msg) + + model_config = MODELS[model_key] + model_id = model_config["id"] + + if verbose: + print(f"Using model: {model_config['name']}") # noqa: T201 + print(f"Prompt: {prompt[:100]}{'...' if len(prompt) > 100 else ''}") # noqa: T201 + + # Initialize client + client = genai.Client(api_key=api_key) + + # Build the content parts + contents: list = [] + + # Add reference images if provided + if reference_images: + for img_path in reference_images: + if not img_path.exists(): + if verbose: + print(f"Warning: Reference image not found: {img_path}") # noqa: T201 + continue + + if verbose: + print(f"Including reference image: {img_path}") # noqa: T201 + img_data, mime_type = load_image_as_base64(img_path) + contents.append( + types.Part.from_bytes( + data=base64.standard_b64decode(img_data), + mime_type=mime_type, + ) + ) + + # Add the text prompt + contents.append(prompt) + + # Build config kwargs + config_kwargs = { + "response_modalities": ["IMAGE", "TEXT"], + } + + # Override size to 1K if draft mode + effective_size = "1K" if is_draft else image_size + + # Add image config for pro model + if model_config.get("supports_image_config"): + image_config_kwargs = {} + if aspect_ratio: + if aspect_ratio not in ASPECT_RATIOS: + if verbose: + print( # noqa: T201 + f"Warning: Invalid aspect ratio '{aspect_ratio}'. " + f"Valid: {ASPECT_RATIOS}" + ) + else: + image_config_kwargs["aspect_ratio"] = aspect_ratio + if verbose: + print(f"Aspect ratio: {aspect_ratio}") # noqa: T201 + if effective_size: + if effective_size not in IMAGE_SIZES: + if verbose: + print( # noqa: T201 + f"Warning: Invalid image size '{effective_size}'. " + f"Valid: {IMAGE_SIZES}" + ) + else: + image_config_kwargs["image_size"] = effective_size + if verbose: + print(f"Image size: {effective_size}") # noqa: T201 + + if image_config_kwargs: + config_kwargs["image_config"] = types.ImageConfig(**image_config_kwargs) + + # Add Google Search grounding if requested + if use_search: + config_kwargs["tools"] = [{"google_search": {}}] + if verbose: + print("Google Search grounding: enabled") # noqa: T201 + + # Configure generation + generate_config = types.GenerateContentConfig(**config_kwargs) + + if verbose: + print("Generating image...") # noqa: T201 + + response = client.models.generate_content( + model=model_id, + contents=contents, + config=generate_config, + ) + + # Process response + if not response.candidates: + if verbose: + print("Error: No response candidates returned.") # noqa: T201 + if hasattr(response, "prompt_feedback"): + print(f"Feedback: {response.prompt_feedback}") # noqa: T201 + return None + + # Track thoughts and final images + thought_count = 0 + final_image_data = None + final_mime_type = None + final_signature = None + + # Determine output directory + if output_dir is None: + output_dir = Path.cwd() + + # Process all parts in response + for part in response.candidates[0].content.parts: + # Check if this is a thought (intermediate reasoning step) + is_thought = hasattr(part, "thought") and part.thought + + if is_thought: + thought_count += 1 + if verbose: + print(f"\n[Thought {thought_count}]") # noqa: T201 + + # Handle thought text + if part.text is not None and verbose: + print(f"Reasoning: {part.text}") # noqa: T201 + + # Handle thought image + if part.inline_data is not None and save_thoughts: + thought_data = part.inline_data.data + thought_mime = part.inline_data.mime_type + thought_ext = get_file_extension(thought_mime) + + # Save thought image + if output_path: + thought_path = ( + output_dir + / f"{output_path.stem}_thought{thought_count}{thought_ext}" + ) + else: + timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S") + thought_path = ( + output_dir / f"thought{thought_count}_{timestamp}{thought_ext}" + ) + + thought_path.parent.mkdir(parents=True, exist_ok=True) + with open(thought_path, "wb") as f: + f.write(thought_data) + + if verbose: + print(f"Thought image {thought_count} saved to: {thought_path}") # noqa: T201 + + # Non-thought content (final output) + elif part.inline_data is not None: + # Final image + final_image_data = part.inline_data.data + final_mime_type = part.inline_data.mime_type + + # Extract thought signature if available + if hasattr(part, "thought_signature") and part.thought_signature: + final_signature = part.thought_signature + if verbose: + print(f"\n[Thought Signature]: {final_signature[:100]}...") # noqa: T201 + + elif part.text is not None and verbose: + # Final text response + print(f"\nModel response: {part.text}") # noqa: T201 + + # Extract thought signature from text part if available + if hasattr(part, "thought_signature") and part.thought_signature: + final_signature = part.thought_signature + if verbose: + print(f"[Thought Signature]: {final_signature[:100]}...") # noqa: T201 + + # Save final image + if final_image_data is not None: + # Determine output filename + if output_path is None: + timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S") + ext = get_file_extension(final_mime_type or "image/png") + prefix = "draft_" if is_draft else "generated_" + output_path = output_dir / f"{prefix}{timestamp}{ext}" + elif not output_path.is_absolute(): + output_path = output_dir / output_path + + # Ensure output directory exists + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Write image + with open(output_path, "wb") as f: + f.write(final_image_data) + + if verbose: + if thought_count > 0: + print(f"\nProcessed {thought_count} thought step(s)") # noqa: T201 + print(f"Final image saved to: {output_path}") # noqa: T201 + + # Optionally save thought signature to sidecar file + if final_signature and verbose: + sig_path = output_path.with_suffix(".signature.bin") + with open(sig_path, "wb") as f: + if isinstance(final_signature, bytes): + f.write(final_signature) + else: + f.write(str(final_signature).encode()) + print(f"Thought signature saved to: {sig_path}") # noqa: T201 + + return output_path + + if verbose: + print("Error: No image data in response.") # noqa: T201 + return None + + +def generate_story_sequence( + base_prompt: str, + num_parts: int, + model_key: ModelKey = DEFAULT_MODEL, + output_prefix: Path | None = None, + output_dir: Path | None = None, + aspect_ratio: AspectRatio | None = None, + image_size: ImageSize | None = None, + verbose: bool = False, +) -> list[Path]: + """Generate a multi-part story sequence using conversational refinement. + + Each subsequent image uses the previous image as a reference for + visual continuity. + + Args: + base_prompt: Base story description. + num_parts: Number of story parts to generate. + model_key: Model to use. + output_prefix: Prefix for output files (e.g., "story" -> + story_part1.png, story_part2.png). + output_dir: Output directory for generated images. + aspect_ratio: Aspect ratio for all images. + image_size: Image size for all images. + verbose: Show detailed process. + + Returns: + List of paths to generated images. + + Raises: + ValueError: If num_parts < 1. + + """ + if num_parts < 1: + msg = "Number of story parts must be at least 1" + raise ValueError(msg) + + if output_dir is None: + output_dir = Path.cwd() + + if output_prefix is None: + timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S") + output_prefix = Path(f"story_{timestamp}") + + generated_images: list[Path] = [] + previous_image_path: Path | None = None + + if verbose: + print(f"Generating {num_parts}-part story sequence...") # noqa: T201 + print(f"Base prompt: {base_prompt}\n") # noqa: T201 + + for part_num in range(1, num_parts + 1): + if verbose: + print(f"\n{'=' * 60}") # noqa: T201 + print(f"PART {part_num}/{num_parts}") # noqa: T201 + print(f"{'=' * 60}") # noqa: T201 + + # Build prompt for this part + if part_num == 1: + prompt = ( + f"{base_prompt}\n\n" + f"This is part 1 of {num_parts}. Create the opening scene that " + "establishes the context and visual style for the entire sequence." + ) + elif part_num == num_parts: + prompt = ( + f"This is part {part_num} of {num_parts}, the final scene. " + "Building on the previous image, create a concluding scene that " + "resolves the narrative. Maintain visual consistency with the " + "established style." + ) + else: + prompt = ( + f"This is part {part_num} of {num_parts}. Building on the previous " + "image, advance the narrative while maintaining visual consistency " + "with the established style." + ) + + # Build output path + output_path = Path(f"{output_prefix.stem}_part{part_num}.png") + + # Build reference images list + reference_images = [previous_image_path] if previous_image_path else None + + if verbose: + print(f"Prompt: {prompt[:100]}...") # noqa: T201 + + # Generate this part + result = generate_image( + prompt=prompt, + model_key=model_key, + reference_images=reference_images, + output_path=output_path, + output_dir=output_dir, + aspect_ratio=aspect_ratio, + image_size=image_size, + use_search=False, + save_thoughts=False, + verbose=verbose, + ) + + if result: + generated_images.append(result) + previous_image_path = result + if verbose: + print(f"Part {part_num} complete: {result}") # noqa: T201 + else: + if verbose: + print(f"Failed to generate part {part_num}") # noqa: T201 + break + + if verbose: + print(f"\n{'=' * 60}") # noqa: T201 + print( # noqa: T201 + f"Story sequence complete: {len(generated_images)}/{num_parts} parts generated" + ) + print(f"{'=' * 60}\n") # noqa: T201 + + for i, path in enumerate(generated_images, 1): + print(f" Part {i}: {path}") # noqa: T201 + + return generated_images + + +def finalize_draft( + draft_path: Path, + prompt: str | None = None, + model_key: ModelKey = DEFAULT_MODEL, + output_path: Path | None = None, + output_dir: Path | None = None, + aspect_ratio: AspectRatio | None = None, + image_size: ImageSize | None = None, + verbose: bool = False, +) -> Path | None: + """Finalize a draft image by regenerating at higher resolution. + + Args: + draft_path: Path to the draft image. + prompt: Optional refinement prompt. If not provided, uses a + default upscaling prompt. + model_key: Model to use. + output_path: Output path for the final image. + output_dir: Output directory. + aspect_ratio: Aspect ratio (default: "16:9"). + image_size: Target resolution (default: "2K"). + verbose: Show detailed process. + + Returns: + Path to the finalized image, or None on failure. + + Raises: + FileNotFoundError: If the draft image doesn't exist. + + """ + if not draft_path.exists(): + msg = f"Draft image not found: {draft_path}" + raise FileNotFoundError(msg) + + # Determine final resolution + final_size = image_size if image_size else "2K" + final_aspect = aspect_ratio if aspect_ratio else "16:9" + + if verbose: + print(f"Finalizing draft image: {draft_path}") # noqa: T201 + print(f"Target resolution: {final_size} ({final_aspect})") # noqa: T201 + + # Use provided prompt or default upscaling prompt + final_prompt = prompt or ( + "Recreate this image at higher resolution with the same " + "composition, style, and details" + ) + + # Determine output path + if output_path is None: + output_path = Path(f"{draft_path.stem}_final.png") + + result = generate_image( + prompt=final_prompt, + model_key=model_key, + reference_images=[draft_path], + output_path=output_path, + output_dir=output_dir, + aspect_ratio=final_aspect, + image_size=final_size, + verbose=verbose, + ) + + if result and verbose: + print(f"\n{'=' * 60}") # noqa: T201 + print("Finalization complete!") # noqa: T201 + print(f"Draft: {draft_path}") # noqa: T201 + print(f"Final ({final_size}): {result}") # noqa: T201 + print(f"{'=' * 60}") # noqa: T201 + + return result diff --git a/packages/gemini-image/src/gemini_image/models.py b/packages/gemini-image/src/gemini_image/models.py new file mode 100644 index 0000000..57db449 --- /dev/null +++ b/packages/gemini-image/src/gemini_image/models.py @@ -0,0 +1,45 @@ +"""Model configurations and type definitions for Gemini image generation.""" + +from __future__ import annotations + +from typing import Literal, TypedDict + +# Type aliases for model configuration +ModelKey = Literal["flash", "pro"] +AspectRatio = Literal["1:1", "3:4", "4:3", "9:16", "16:9"] +ImageSize = Literal["1K", "2K", "4K"] + + +class ModelConfig(TypedDict): + """Configuration for a Gemini image generation model.""" + + id: str + name: str + description: str + supports_image_config: bool + + +# Model configurations +# Note: Actual API model IDs are gemini-2.5-flash-image and gemini-3-pro-image-preview +MODELS: dict[ModelKey, ModelConfig] = { + "flash": { + "id": "gemini-2.5-flash-image", + "name": "Nano Banana (Gemini 2.5 Flash)", + "description": "Fast image generation model", + "supports_image_config": False, + }, + "pro": { + "id": "gemini-3-pro-image-preview", + "name": "Nano Banana Pro (Gemini 3 Pro)", + "description": "4K resolution, better text rendering, Google Search grounding, thinking mode", + "supports_image_config": True, + }, +} + +DEFAULT_MODEL: ModelKey = "pro" + +# Valid aspect ratios for pro model +ASPECT_RATIOS: list[AspectRatio] = ["1:1", "3:4", "4:3", "9:16", "16:9"] + +# Valid image sizes for pro model +IMAGE_SIZES: list[ImageSize] = ["1K", "2K", "4K"] diff --git a/packages/gemini-image/src/gemini_image/py.typed b/packages/gemini-image/src/gemini_image/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/gemini-image/src/gemini_image/utils.py b/packages/gemini-image/src/gemini_image/utils.py new file mode 100644 index 0000000..62de1da --- /dev/null +++ b/packages/gemini-image/src/gemini_image/utils.py @@ -0,0 +1,110 @@ +"""Utility functions for Gemini image generation.""" + +from __future__ import annotations + +import base64 +import os +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + +def get_api_key(env_file: Path | None = None) -> str: + """Get the Gemini API key from environment or .env file. + + Args: + env_file: Optional path to .env file. If not provided, checks + GEMINI_API_KEY environment variable only. + + Returns: + The API key string. + + Raises: + ValueError: If no API key is found. + + """ + api_key = os.environ.get("GEMINI_API_KEY") + + if not api_key and env_file and env_file.exists(): + with open(env_file) as f: + for line in f: + line = line.strip() + if line.startswith("GEMINI_API_KEY="): + api_key = line.split("=", 1)[1].strip().strip('"').strip("'") + break + + if not api_key: + msg = ( + "GEMINI_API_KEY environment variable not set. " + "Set it with: export GEMINI_API_KEY='your-api-key'" + ) + raise ValueError(msg) + + return api_key + + +def load_image_as_base64(image_path: Path) -> tuple[str, str]: + """Load an image file and return base64 data and mime type. + + Args: + image_path: Path to the image file. + + Returns: + Tuple of (base64_encoded_data, mime_type). + + Raises: + FileNotFoundError: If the image file doesn't exist. + + """ + if not image_path.exists(): + msg = f"Image file not found: {image_path}" + raise FileNotFoundError(msg) + + suffix = image_path.suffix.lower() + mime_types = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + } + + mime_type = mime_types.get(suffix, "image/png") + + with open(image_path, "rb") as f: + data = base64.standard_b64encode(f.read()).decode("utf-8") + + return data, mime_type + + +def decode_base64_image(base64_data: str) -> bytes: + """Decode base64 image data to bytes. + + Args: + base64_data: Base64-encoded image data. + + Returns: + Raw image bytes. + + """ + return base64.standard_b64decode(base64_data) + + +def get_file_extension(mime_type: str) -> str: + """Get file extension for a given MIME type. + + Args: + mime_type: MIME type string (e.g., "image/png"). + + Returns: + File extension including the dot (e.g., ".png"). + + """ + extensions = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + } + return extensions.get(mime_type, ".png") diff --git a/packages/gemini-image/tests/__init__.py b/packages/gemini-image/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/gemini-image/tests/conftest.py b/packages/gemini-image/tests/conftest.py new file mode 100644 index 0000000..8d3e09a --- /dev/null +++ b/packages/gemini-image/tests/conftest.py @@ -0,0 +1,59 @@ +"""Pytest configuration and fixtures for gemini-image tests.""" + +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def sample_image_bytes() -> bytes: + """Return sample PNG image bytes (1x1 red pixel).""" + # Minimal valid PNG: 1x1 red pixel + return base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIA" + "X8jx0gAAAABJRU5ErkJggg==" + ) + + +@pytest.fixture +def sample_image_path(tmp_path: Path, sample_image_bytes: bytes) -> Path: + """Create a temporary sample image file.""" + image_path = tmp_path / "sample.png" + image_path.write_bytes(sample_image_bytes) + return image_path + + +@pytest.fixture +def mock_genai_response(sample_image_bytes: bytes) -> MagicMock: + """Create a mock Gemini API response with image data.""" + mock_response = MagicMock() + + # Create mock part with inline data + mock_part = MagicMock() + mock_part.thought = False + mock_part.text = None + mock_part.inline_data = MagicMock() + mock_part.inline_data.data = sample_image_bytes + mock_part.inline_data.mime_type = "image/png" + + # Create mock candidate + mock_candidate = MagicMock() + mock_candidate.content.parts = [mock_part] + + mock_response.candidates = [mock_candidate] + return mock_response + + +@pytest.fixture +def mock_genai_client(mock_genai_response: MagicMock) -> MagicMock: + """Create a mock Gemini client.""" + mock_client = MagicMock() + mock_client.models.generate_content.return_value = mock_genai_response + return mock_client diff --git a/packages/gemini-image/tests/test_generator.py b/packages/gemini-image/tests/test_generator.py new file mode 100644 index 0000000..55a9e92 --- /dev/null +++ b/packages/gemini-image/tests/test_generator.py @@ -0,0 +1,182 @@ +"""Tests for image generation functions.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from gemini_image import generator +from gemini_image.generator import finalize_draft, generate_story_sequence + +if TYPE_CHECKING: + from pathlib import Path + + +class TestGenerateImage: + """Tests for generate_image function.""" + + def test_generate_image_invalid_model_raises(self) -> None: + """Test that invalid model key raises ValueError.""" + # Mock genai to avoid ImportError + mock_genai = MagicMock() + mock_types = MagicMock() + + with ( + patch.object(generator, "_genai", mock_genai), + patch.object(generator, "_types", mock_types), + patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}), + pytest.raises(ValueError, match="Unknown model"), + ): + generator.generate_image("test prompt", model_key="invalid") # type: ignore[arg-type] + + def test_generate_image_missing_api_key_raises(self) -> None: + """Test that missing API key raises ValueError.""" + # Mock genai to avoid ImportError + mock_genai = MagicMock() + mock_types = MagicMock() + + with ( + patch.object(generator, "_genai", mock_genai), + patch.object(generator, "_types", mock_types), + patch.dict(os.environ, {}, clear=True), + ): + os.environ.pop("GEMINI_API_KEY", None) + with pytest.raises(ValueError, match="GEMINI_API_KEY"): + generator.generate_image("test prompt") + + def test_generate_image_with_mock_client( + self, + tmp_path: Path, + mock_genai_response: MagicMock, + ) -> None: + """Test image generation with mocked Gemini client.""" + # Create mock genai module + mock_genai = MagicMock() + mock_types = MagicMock() + + mock_client = MagicMock() + mock_client.models.generate_content.return_value = mock_genai_response + mock_genai.Client.return_value = mock_client + + # Patch the lazy-loaded modules + with ( + patch.object(generator, "_genai", mock_genai), + patch.object(generator, "_types", mock_types), + patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}), + ): + result = generator.generate_image( + prompt="A test image", + output_dir=tmp_path, + verbose=False, + ) + + assert result is not None + assert result.exists() + assert result.suffix == ".png" + + def test_generate_image_draft_mode_uses_1k( + self, + tmp_path: Path, + mock_genai_response: MagicMock, + ) -> None: + """Test that draft mode sets 1K resolution.""" + mock_genai = MagicMock() + mock_types = MagicMock() + + mock_client = MagicMock() + mock_client.models.generate_content.return_value = mock_genai_response + mock_genai.Client.return_value = mock_client + + with ( + patch.object(generator, "_genai", mock_genai), + patch.object(generator, "_types", mock_types), + patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}), + ): + result = generator.generate_image( + prompt="A test draft", + output_dir=tmp_path, + is_draft=True, + ) + + assert result is not None + assert "draft_" in result.name + + +class TestGenerateStorySequence: + """Tests for generate_story_sequence function.""" + + def test_story_sequence_invalid_parts_raises(self) -> None: + """Test that num_parts < 1 raises ValueError.""" + with pytest.raises(ValueError, match="at least 1"): + generate_story_sequence("test story", num_parts=0) + + def test_story_sequence_generates_multiple_images( + self, + tmp_path: Path, + mock_genai_response: MagicMock, + ) -> None: + """Test that story sequence generates the correct number of images.""" + mock_genai = MagicMock() + mock_types = MagicMock() + + mock_client = MagicMock() + mock_client.models.generate_content.return_value = mock_genai_response + mock_genai.Client.return_value = mock_client + + with ( + patch.object(generator, "_genai", mock_genai), + patch.object(generator, "_types", mock_types), + patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}), + ): + results = generator.generate_story_sequence( + base_prompt="A test story", + num_parts=3, + output_dir=tmp_path, + output_prefix=tmp_path / "story", + ) + + assert len(results) == 3 + for i, path in enumerate(results, 1): + assert path.exists() + assert f"part{i}" in path.name + + +class TestFinalizeDraft: + """Tests for finalize_draft function.""" + + def test_finalize_missing_draft_raises(self, tmp_path: Path) -> None: + """Test that missing draft image raises FileNotFoundError.""" + missing_path = tmp_path / "nonexistent.png" + + with pytest.raises(FileNotFoundError): + finalize_draft(missing_path) + + def test_finalize_draft_uses_2k_by_default( + self, + sample_image_path: Path, + tmp_path: Path, + mock_genai_response: MagicMock, + ) -> None: + """Test that finalize_draft defaults to 2K resolution.""" + mock_genai = MagicMock() + mock_types = MagicMock() + + mock_client = MagicMock() + mock_client.models.generate_content.return_value = mock_genai_response + mock_genai.Client.return_value = mock_client + + with ( + patch.object(generator, "_genai", mock_genai), + patch.object(generator, "_types", mock_types), + patch.dict(os.environ, {"GEMINI_API_KEY": "test-key"}), + ): + result = generator.finalize_draft( + draft_path=sample_image_path, + output_dir=tmp_path, + ) + + assert result is not None + assert "_final" in result.name diff --git a/packages/gemini-image/tests/test_models.py b/packages/gemini-image/tests/test_models.py new file mode 100644 index 0000000..887e17a --- /dev/null +++ b/packages/gemini-image/tests/test_models.py @@ -0,0 +1,38 @@ +"""Tests for model configurations.""" + +from gemini_image.models import ( + ASPECT_RATIOS, + DEFAULT_MODEL, + IMAGE_SIZES, + MODELS, +) + + +class TestModelConfigurations: + """Tests for model configuration constants.""" + + def test_models_has_flash(self) -> None: + """Test that flash model is defined.""" + assert "flash" in MODELS + assert MODELS["flash"]["id"] == "gemini-2.5-flash-image" + assert MODELS["flash"]["supports_image_config"] is False + + def test_models_has_pro(self) -> None: + """Test that pro model is defined.""" + assert "pro" in MODELS + assert MODELS["pro"]["id"] == "gemini-3-pro-image-preview" + assert MODELS["pro"]["supports_image_config"] is True + + def test_default_model_is_pro(self) -> None: + """Test that default model is pro.""" + assert DEFAULT_MODEL == "pro" + + def test_aspect_ratios(self) -> None: + """Test that all expected aspect ratios are defined.""" + expected = ["1:1", "3:4", "4:3", "9:16", "16:9"] + assert expected == ASPECT_RATIOS + + def test_image_sizes(self) -> None: + """Test that all expected image sizes are defined.""" + expected = ["1K", "2K", "4K"] + assert expected == IMAGE_SIZES diff --git a/packages/gemini-image/tests/test_utils.py b/packages/gemini-image/tests/test_utils.py new file mode 100644 index 0000000..a5ba3d7 --- /dev/null +++ b/packages/gemini-image/tests/test_utils.py @@ -0,0 +1,111 @@ +"""Tests for utility functions.""" + +from __future__ import annotations + +import base64 +import os +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from gemini_image.utils import ( + decode_base64_image, + get_api_key, + get_file_extension, + load_image_as_base64, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestGetApiKey: + """Tests for get_api_key function.""" + + def test_get_api_key_from_env(self) -> None: + """Test getting API key from environment variable.""" + with patch.dict(os.environ, {"GEMINI_API_KEY": "test-key-123"}): + assert get_api_key() == "test-key-123" + + def test_get_api_key_from_env_file(self, tmp_path: Path) -> None: + """Test getting API key from .env file.""" + env_file = tmp_path / ".env" + env_file.write_text('GEMINI_API_KEY="file-key-456"') + + with patch.dict(os.environ, {}, clear=True): + # Remove GEMINI_API_KEY if it exists + os.environ.pop("GEMINI_API_KEY", None) + assert get_api_key(env_file=env_file) == "file-key-456" + + def test_get_api_key_missing_raises(self) -> None: + """Test that missing API key raises ValueError.""" + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("GEMINI_API_KEY", None) + with pytest.raises(ValueError, match="GEMINI_API_KEY"): + get_api_key() + + +class TestLoadImageAsBase64: + """Tests for load_image_as_base64 function.""" + + def test_load_png_image(self, sample_image_path: Path) -> None: + """Test loading a PNG image.""" + data, mime_type = load_image_as_base64(sample_image_path) + + assert isinstance(data, str) + assert mime_type == "image/png" + # Verify it's valid base64 + decoded = base64.standard_b64decode(data) + assert len(decoded) > 0 + + def test_load_jpeg_image(self, tmp_path: Path, sample_image_bytes: bytes) -> None: + """Test loading a JPEG image (using PNG bytes, just testing extension).""" + image_path = tmp_path / "image.jpg" + image_path.write_bytes(sample_image_bytes) + + _, mime_type = load_image_as_base64(image_path) + assert mime_type == "image/jpeg" + + def test_load_missing_image_raises(self, tmp_path: Path) -> None: + """Test that loading missing image raises FileNotFoundError.""" + missing_path = tmp_path / "nonexistent.png" + + with pytest.raises(FileNotFoundError): + load_image_as_base64(missing_path) + + +class TestDecodeBase64Image: + """Tests for decode_base64_image function.""" + + def test_decode_valid_base64(self) -> None: + """Test decoding valid base64 data.""" + original = b"test image data" + encoded = base64.standard_b64encode(original).decode() + + decoded = decode_base64_image(encoded) + assert decoded == original + + +class TestGetFileExtension: + """Tests for get_file_extension function.""" + + def test_png_extension(self) -> None: + """Test PNG MIME type returns .png.""" + assert get_file_extension("image/png") == ".png" + + def test_jpeg_extension(self) -> None: + """Test JPEG MIME type returns .jpg.""" + assert get_file_extension("image/jpeg") == ".jpg" + + def test_gif_extension(self) -> None: + """Test GIF MIME type returns .gif.""" + assert get_file_extension("image/gif") == ".gif" + + def test_webp_extension(self) -> None: + """Test WebP MIME type returns .webp.""" + assert get_file_extension("image/webp") == ".webp" + + def test_unknown_extension_defaults_to_png(self) -> None: + """Test unknown MIME type defaults to .png.""" + assert get_file_extension("image/unknown") == ".png" diff --git a/pyproject.toml b/pyproject.toml index f39e418..0e3a20b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -268,7 +268,7 @@ max-returns = 6 # Return statement limit convention = "google" [tool.ruff.lint.isort] -known-first-party = ["python_libs", "cloudflare_auth", "gcs_utilities"] +known-first-party = ["python_libs", "cloudflare_auth", "gcs_utilities", "gemini_image"] [tool.ruff.lint.per-file-ignores] # Module init files @@ -462,7 +462,7 @@ pythonPlatform = "All" typeCheckingMode = "strict" # Source paths -include = ["src", "packages/cloudflare-auth/src", "packages/gcs-utilities/src"] +include = ["src", "packages/cloudflare-auth/src", "packages/gcs-utilities/src", "packages/gemini-image/src"] exclude = [ "**/__pycache__", "**/node_modules", @@ -525,8 +525,8 @@ deprecateTypingAliases = true # Warn on deprecated typing module aliase # Pytest Configuration [tool.pytest.ini_options] minversion = "7.0" -testpaths = ["tests", "packages/cloudflare-auth/tests", "packages/gcs-utilities/tests"] -pythonpath = [".", "src", "packages/cloudflare-auth/src", "packages/gcs-utilities/src"] +testpaths = ["tests", "packages/cloudflare-auth/tests", "packages/gcs-utilities/tests", "packages/gemini-image/tests"] +pythonpath = [".", "src", "packages/cloudflare-auth/src", "packages/gcs-utilities/src", "packages/gemini-image/src"] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] @@ -698,7 +698,9 @@ members = ["packages/*"] # Workspace source references - allow packages to depend on each other [tool.uv.sources] byronwilliamscpa-cloudflare-auth = { workspace = true } +byronwilliamscpa-cloudflare-api = { workspace = true } byronwilliamscpa-gcs-utilities = { workspace = true } +byronwilliamscpa-gemini-image = { workspace = true } # Package Index Configuration # Google Assured OSS as primary source with PyPI fallback diff --git a/uv.lock b/uv.lock index 4185a45..02cb36e 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ resolution-markers = [ members = [ "byronwilliamscpa-cloudflare-auth", "byronwilliamscpa-gcs-utilities", + "byronwilliamscpa-gemini-image", "python-libs", ] @@ -398,6 +399,30 @@ requires-dist = [ ] provides-extras = ["async", "dev"] +[[package]] +name = "byronwilliamscpa-gemini-image" +version = "0.1.0" +source = { editable = "packages/gemini-image" } +dependencies = [ + { name = "google-genai" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "google-genai", specifier = ">=1.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, +] +provides-extras = ["dev"] + [[package]] name = "cachecontrol" version = "0.14.4" @@ -919,6 +944,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "dparse" version = "0.6.4" @@ -1070,6 +1104,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" }, ] +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + [[package]] name = "google-cloud-core" version = "2.5.0" @@ -1135,6 +1174,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/3c/2a19a60a473de48717b4efb19398c3f914795b64a96cf3fbe82588044f78/google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82", size = 28048, upload-time = "2025-03-26T14:41:46.696Z" }, ] +[[package]] +name = "google-genai" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/7c/19b59750592702305ae211905985ec8ab56f34270af4a159fba5f0214846/google_genai-1.55.0.tar.gz", hash = "sha256:ae9f1318fedb05c7c1b671a4148724751201e8908a87568364a309804064d986", size = 477615, upload-time = "2025-12-11T02:49:28.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/86/a5a8e32b2d40b30b5fb20e7b8113fafd1e38befa4d1801abd5ce6991065a/google_genai-1.55.0-py3-none-any.whl", hash = "sha256:98c422762b5ff6e16b8d9a1e4938e8e0ad910392a5422e47f5301498d7f373a1", size = 703389, upload-time = "2025-12-11T02:49:27.105Z" }, +] + [[package]] name = "google-resumable-media" version = "2.8.0" @@ -3711,6 +3771,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -4086,6 +4155,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + [[package]] name = "widgetsnbextension" version = "4.0.15"