diff --git a/google/genai/tests/types/test_live_client_and_list_type.py b/google/genai/tests/types/test_live_client_and_list_type.py new file mode 100644 index 000000000..91e39d119 --- /dev/null +++ b/google/genai/tests/types/test_live_client_and_list_type.py @@ -0,0 +1,165 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Tests to verify both LiveClient classes and list[pydantic.BaseModel] support.""" + +import inspect +from typing import List, Optional + +import pytest +from pydantic import BaseModel, Field + +from google import genai +from google.genai import types + + +@pytest.fixture +def client(): + """Return a client that uses the replay_session.""" + client = genai.Client(api_key="test-api-key") + return client + + +def test_live_client_classes_exist(): + """Verify that LiveClient classes exist and have expected attributes.""" + # Check that LiveClientMessage exists + assert hasattr(types, "LiveClientMessage") + assert inspect.isclass(types.LiveClientMessage) + + # Check that LiveClientContent exists + assert hasattr(types, "LiveClientContent") + assert inspect.isclass(types.LiveClientContent) + + # Check that LiveClientRealtimeInput exists + assert hasattr(types, "LiveClientRealtimeInput") + assert inspect.isclass(types.LiveClientRealtimeInput) + + # Check that LiveClientSetup exists + assert hasattr(types, "LiveClientSetup") + assert inspect.isclass(types.LiveClientSetup) + + # Check for Dict versions + assert hasattr(types, "LiveClientMessageDict") + assert hasattr(types, "LiveClientContentDict") + assert hasattr(types, "LiveClientRealtimeInputDict") + assert hasattr(types, "LiveClientSetupDict") + + +def test_live_client_message_fields(): + """Verify that LiveClientMessage has expected fields.""" + # Get the field details + fields = types.LiveClientMessage.__fields__ + + # Check for expected fields + assert "setup" in fields + assert "client_content" in fields + assert "realtime_input" in fields + assert "tool_response" in fields + + +def test_list_pydantic_in_generate_content_response(): + """Verify that GenerateContentResponse can handle list[pydantic.BaseModel].""" + + class Recipe(BaseModel): + recipe_name: str + ingredients: List[str] + + # Create a test response + response = types.GenerateContentResponse() + + # Assign a list of pydantic models + recipes = [ + Recipe( + recipe_name="Chocolate Chip Cookies", + ingredients=["Flour", "Sugar", "Chocolate"], + ), + Recipe( + recipe_name="Oatmeal Cookies", ingredients=["Oats", "Flour", "Brown Sugar"] + ), + ] + + # This assignment would fail with mypy if the type annotation is incorrect + response.parsed = recipes + + # Verify assignment worked properly + assert response.parsed is not None + assert isinstance(response.parsed, list) + assert len(response.parsed) == 2 + assert all(isinstance(item, Recipe) for item in response.parsed) + + +def test_combined_functionality(client): + """Test that combines verification of both LiveClient classes and list[pydantic.BaseModel] support.""" + # Verify LiveClient classes exist + assert hasattr(types, "LiveClientMessage") + assert inspect.isclass(types.LiveClientMessage) + + # Test the list[pydantic.BaseModel] support in generate_content + class Recipe(BaseModel): + recipe_name: str + ingredients: List[str] + instructions: Optional[List[str]] = None + + response = client.models.generate_content( + model="gemini-1.5-flash", + contents="List 2 simple cookie recipes.", + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=list[Recipe], + ), + ) + + # Verify the parsed field contains a list of Recipe objects + assert isinstance(response.parsed, list) + assert len(response.parsed) > 0 + assert all(isinstance(item, Recipe) for item in response.parsed) + + # Access a property to verify the type annotation works correctly + recipe = response.parsed[0] + assert isinstance(recipe.recipe_name, str) + assert isinstance(recipe.ingredients, list) + + +def test_live_connect_config_exists(): + """Verify that LiveConnectConfig exists and has expected attributes.""" + # Check that LiveConnectConfig exists + assert hasattr(types, "LiveConnectConfig") + assert inspect.isclass(types.LiveConnectConfig) + + # Check that LiveConnectConfigDict exists + assert hasattr(types, "LiveConnectConfigDict") + + # Get the field details if it's a pydantic model + if hasattr(types.LiveConnectConfig, "__fields__"): + fields = types.LiveConnectConfig.__fields__ + + # Check for expected fields (these might vary based on actual implementation) + assert "model" in fields + + +def test_live_client_tool_response(): + """Verify that LiveClientToolResponse exists and has expected attributes.""" + # Check that LiveClientToolResponse exists + assert hasattr(types, "LiveClientToolResponse") + assert inspect.isclass(types.LiveClientToolResponse) + + # Check that LiveClientToolResponseDict exists + assert hasattr(types, "LiveClientToolResponseDict") + + # Get the field details + fields = types.LiveClientToolResponse.__fields__ + + # Check for expected fields (these might vary based on actual implementation) + assert "function_response" in fields or "tool_outputs" in fields diff --git a/google/genai/tests/types/test_parsed_list_mypy.py b/google/genai/tests/types/test_parsed_list_mypy.py new file mode 100644 index 000000000..06940b898 --- /dev/null +++ b/google/genai/tests/types/test_parsed_list_mypy.py @@ -0,0 +1,184 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Tests to verify that mypy correctly handles list[pydantic.BaseModel] in response.parsed.""" + +from typing import List, cast +import logging + +from pydantic import BaseModel + +from google.genai import types + +# Configure logging +logger = logging.getLogger(__name__) + + +def test_mypy_with_list_pydantic(): + """ + This test doesn't actually run, but it's meant to be analyzed by mypy. + + The code patterns here would have caused mypy errors before the fix, + but now should pass type checking with our enhanced types. + """ + + # Define a Pydantic model for structured output + class Recipe(BaseModel): + recipe_name: str + ingredients: List[str] + + # Create a mock response (simulating what we'd get from the API) + response = types.GenerateContentResponse() + + # Before the fix[issue #886], this next line would cause a mypy error: + # Incompatible types in assignment (expression has type "List[Recipe]", + # variable has type "Optional[Union[BaseModel, Dict[Any, Any], Enum]]") + # + # With the fix adding list[pydantic.BaseModel] to the Union, this is now valid: + response.parsed = [ + Recipe( + recipe_name="Chocolate Chip Cookies", + ingredients=["Flour", "Sugar", "Chocolate"], + ), + Recipe( + recipe_name="Oatmeal Cookies", ingredients=["Oats", "Flour", "Brown Sugar"] + ), + ] + + # This pattern would require a type cast before the fix + if response.parsed is not None: + # Before the fix, accessing response.parsed as a list would cause a mypy error + # and require a cast: + # parsed_items = cast(list[Recipe], response.parsed) + + # With the fix, we can directly use it as a list without casting: + recipes = response.parsed + + # Now iteration over the list without casting is possible + for recipe in recipes: + logger.info(f"Recipe: {recipe.recipe_name}") + for ingredient in recipe.ingredients: + logger.info(f" - {ingredient}") + + # Also accessing elements by index without casting is possible + first_recipe = recipes[0] + logger.info(f"First recipe: {first_recipe.recipe_name}") + + +def test_with_pydantic_inheritance(): + """Test with inheritance to ensure the type annotation works with subclasses.""" + + class FoodItem(BaseModel): + name: str + + class Recipe(FoodItem): + ingredients: List[str] + + response = types.GenerateContentResponse() + + # Before the fix, this would require a cast with mypy + # Now it works directly with the enhanced type annotation + response.parsed = [ + Recipe( + name="Chocolate Chip Cookies", + ingredients=["Flour", "Sugar", "Chocolate"], + ), + Recipe( + name="Oatmeal Cookies", + ingredients=["Oats", "Flour", "Brown Sugar"], + ), + ] + + if response.parsed is not None: + # Previously would need: cast(list[Recipe], response.parsed) + recipes = response.parsed + + # Access fields from parent class + for recipe in recipes: + logger.info(f"Recipe name: {recipe.name}") + + +def test_with_nested_list_models(): + """Test with nested list models to ensure complex structures work.""" + + class Ingredient(BaseModel): + name: str + amount: str + + class Recipe(BaseModel): + recipe_name: str + ingredients: List[Ingredient] + + response = types.GenerateContentResponse() + + # With the fix, mypy correctly handles this complex structure + response.parsed = [ + Recipe( + recipe_name="Chocolate Chip Cookies", + ingredients=[ + Ingredient(name="Flour", amount="2 cups"), + Ingredient(name="Sugar", amount="1 cup"), + ], + ), + Recipe( + recipe_name="Oatmeal Cookies", + ingredients=[ + Ingredient(name="Oats", amount="1 cup"), + Ingredient(name="Flour", amount="1.5 cups"), + ], + ), + ] + + if response.parsed is not None: + recipes = response.parsed + + # Access nested structures without casting + for recipe in recipes: + logger.info(f"Recipe: {recipe.recipe_name}") + for ingredient in recipe.ingredients: + logger.info(f" - {ingredient.name}: {ingredient.amount}") + + +# Example of how you would previously need to cast the results +def old_approach_with_cast(): + """ + This demonstrates the old approach that required explicit casting, + which was less type-safe and more error-prone. + """ + + class Recipe(BaseModel): + recipe_name: str + ingredients: List[str] + + response = types.GenerateContentResponse() + + # Simulate API response + response.parsed = [ + Recipe( + recipe_name="Chocolate Chip Cookies", + ingredients=["Flour", "Sugar", "Chocolate"], + ), + Recipe( + recipe_name="Oatmeal Cookies", ingredients=["Oats", "Flour", "Brown Sugar"] + ), + ] + + if response.parsed is not None: + # Before the fix, you'd need this cast for mypy to work successfully + recipes = cast(List[Recipe], response.parsed) + + # Using the cast list + for recipe in recipes: + logger.info(f"Recipe: {recipe.recipe_name}") diff --git a/google/genai/tests/types/test_parsed_list_support.py b/google/genai/tests/types/test_parsed_list_support.py new file mode 100644 index 000000000..a82918eb6 --- /dev/null +++ b/google/genai/tests/types/test_parsed_list_support.py @@ -0,0 +1,205 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import sys +from enum import Enum +from typing import List, Optional, Union + +import pytest +from pydantic import BaseModel, Field + +from google import genai +from google.genai import types + + +@pytest.fixture +def client(): + """Return a client that uses the replay_session.""" + client = genai.Client(api_key="test-api-key") + return client + + +def test_basic_list_of_pydantic_schema(client): + """Test basic list of pydantic schema support.""" + + class Recipe(BaseModel): + recipe_name: str + ingredients: List[str] + prep_time_minutes: int + + response = client.models.generate_content( + model="gemini-1.5-flash", + contents="List 3 simple cookie recipes.", + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=list[Recipe], + ), + ) + + # Verify the parsed field contains a list of Recipe objects + assert isinstance(response.parsed, list) + assert len(response.parsed) > 0 + assert all(isinstance(item, Recipe) for item in response.parsed) + + # Access a property to verify the type annotation works correctly + recipe = response.parsed[0] + assert isinstance(recipe.recipe_name, str) + assert isinstance(recipe.ingredients, list) + + +def test_nested_list_of_pydantic_schema(client): + """Test nested list of pydantic schema support.""" + + class RecipeStep(BaseModel): + step_number: int + instruction: str + + class Recipe(BaseModel): + recipe_name: str + steps: List[RecipeStep] + + response = client.models.generate_content( + model="gemini-1.5-flash", + contents="Give me 2 recipes with detailed steps.", + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=list[Recipe], + ), + ) + + # Verify the parsed field contains a list of Recipe objects with nested RecipeStep objects + assert isinstance(response.parsed, list) + assert len(response.parsed) > 0 + assert all(isinstance(item, Recipe) for item in response.parsed) + + # Access nested property to verify the type annotation works correctly + recipe = response.parsed[0] + assert isinstance(recipe.steps, list) + assert all(isinstance(step, RecipeStep) for step in recipe.steps) + + +def test_empty_list_of_pydantic_schema(client): + """Test empty list of pydantic schema support.""" + + class Recipe(BaseModel): + recipe_name: str + ingredients: List[str] + + # Note: I am only testing the type annotation support, not the model's behavior + + # Create a mock response with an empty list + response = types.GenerateContentResponse() + # Set parsed to an empty list which should be valid with our type annotation update + response.parsed = [] + + assert isinstance(response.parsed, list) + assert len(response.parsed) == 0 + + +def test_list_with_optional_fields(client): + """Test list of pydantic schema with optional fields.""" + + class Recipe(BaseModel): + recipe_name: str + ingredients: List[str] + prep_time_minutes: Optional[int] = None + cook_time_minutes: Optional[int] = None + difficulty: Optional[str] = None + + response = client.models.generate_content( + model="gemini-1.5-flash", + contents="List 2 simple recipes with varying details.", + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=list[Recipe], + ), + ) + + assert isinstance(response.parsed, list) + assert len(response.parsed) > 0 + assert all(isinstance(item, Recipe) for item in response.parsed) + + # Even if the optional fields are None, the type annotation should work + recipe = response.parsed[0] + assert isinstance(recipe.recipe_name, str) + + assert recipe.prep_time_minutes is None or isinstance(recipe.prep_time_minutes, int) + + +def test_list_with_enum_fields(client): + """Test list of pydantic schema with enum fields.""" + + class DifficultyLevel(Enum): + EASY = "easy" + MEDIUM = "medium" + HARD = "hard" + + class Recipe(BaseModel): + recipe_name: str + difficulty: DifficultyLevel + + response = client.models.generate_content( + model="gemini-1.5-flash", + contents="List 3 recipes with their difficulty levels.", + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=list[Recipe], + ), + ) + + assert isinstance(response.parsed, list) + assert len(response.parsed) > 0 + assert all(isinstance(item, Recipe) for item in response.parsed) + + # Check that enum values are properly parsed + recipe = response.parsed[0] + assert isinstance(recipe.difficulty, DifficultyLevel) + + +def test_double_nested_list_of_pydantic_schema(client): + """Test double nested list of pydantic schema support.""" + + class Ingredient(BaseModel): + name: str + amount: str + + class Recipe(BaseModel): + recipe_name: str + ingredients: List[Ingredient] + + response = client.models.generate_content( + model="gemini-1.5-flash", + contents="Give me a list of 2 recipes with detailed ingredients.", + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=list[Recipe], + ), + ) + + # Verify the parsed field contains a list of Recipe objects with nested Ingredient objects + assert isinstance(response.parsed, list) + assert len(response.parsed) > 0 + assert all(isinstance(item, Recipe) for item in response.parsed) + + # Access doubly nested property to verify the type annotation works correctly + recipe = response.parsed[0] + assert isinstance(recipe.ingredients, list) + assert all(isinstance(ingredient, Ingredient) for ingredient in recipe.ingredients) + + # Access properties of the nested objects + if recipe.ingredients: + ingredient = recipe.ingredients[0] + assert isinstance(ingredient.name, str) + assert isinstance(ingredient.amount, str) diff --git a/google/genai/types.py b/google/genai/types.py index 1fd642ea9..29ebd9de5 100644 --- a/google/genai/types.py +++ b/google/genai/types.py @@ -7413,7 +7413,7 @@ class GenerateContentResponse(_common.BaseModel): default=None, description="""Usage metadata about the response(s).""" ) automatic_function_calling_history: Optional[list[Content]] = None - parsed: Optional[Union[pydantic.BaseModel, dict[Any, Any], Enum]] = Field( + parsed: Optional[Union[pydantic.BaseModel, list[pydantic.BaseModel], dict[Any, Any], Enum]] = Field( default=None, description="""First candidate from the parsed response if response_schema is provided. Not available for streaming.""", )