diff --git a/sdk/ai/azure-ai-projects/assets.json b/sdk/ai/azure-ai-projects/assets.json index 4eaa940a0b11..c5814cde3f02 100644 --- a/sdk/ai/azure-ai-projects/assets.json +++ b/sdk/ai/azure-ai-projects/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "python", "TagPrefix": "python/ai/azure-ai-projects", - "Tag": "python/ai/azure-ai-projects_314598932e" + "Tag": "python/ai/azure-ai-projects_93d1dc0fe7" } diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/computer_use_util.py b/sdk/ai/azure-ai-projects/samples/agents/tools/computer_use_util.py index 46e3cb6803e4..c1793779b0fe 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/computer_use_util.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/computer_use_util.py @@ -159,4 +159,4 @@ def print_final_output(response): final_output += getattr(part, "text", None) or getattr(part, "refusal", None) or "" + "\n" print(f"Final status: {response.status}") - print(f"==> Result: {final_output.strip()}") + print(f"Final result: {final_output.strip()}") diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_ai_search.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_ai_search.py index 0b227f224754..263aee360715 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_ai_search.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_ai_search.py @@ -108,7 +108,7 @@ ) elif event.type == "response.completed": print(f"\nFollow-up completed!") - print(f"==> Result: {event.response.output_text}") + print(f"Agent response: {event.response.output_text}") print("\nCleaning up...") project_client.agents.delete_version(agent_name=agent.name, agent_version=agent.version) diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_code_interpreter.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_code_interpreter.py index 9cd10f63af2d..db84258efae8 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_code_interpreter.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_code_interpreter.py @@ -108,7 +108,6 @@ file_path = os.path.join(tempfile.gettempdir(), filename) with open(file_path, "wb") as f: f.write(file_content.read()) - # Print result (should contain "file") - print(f"==> Result: file, {file_path} downloaded successfully.") + print(f"File downloaded successfully: {file_path}") else: print("No file generated in response") diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_code_interpreter_async.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_code_interpreter_async.py index be88a1f9555c..b794599b70af 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_code_interpreter_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_code_interpreter_async.py @@ -112,8 +112,7 @@ async def main() -> None: file_path = os.path.join(tempfile.gettempdir(), filename) with open(file_path, "wb") as f: f.write(file_content.read()) - # Print result (should contain "file") - print(f"==> Result: file, {file_path} downloaded successfully.") + print(f"File downloaded successfully: {file_path}") else: print("No file generated in response") diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search.py index 78b69f7e0e33..9351a29a8977 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search.py @@ -78,7 +78,7 @@ input="Tell me about Contoso products", extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, ) - print(f"==> Result: {response.output_text}") + print(f"Agent response: {response.output_text}") print("\nCleaning up...") project_client.agents.delete_version(agent_name=agent.name, agent_version=agent.version) print("Agent deleted") diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search_in_stream.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search_in_stream.py index 48728d150870..21c04ba22bf6 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search_in_stream.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search_in_stream.py @@ -140,7 +140,7 @@ print(f"File Citation - Filename: {annotation.filename}, File ID: {annotation.file_id}") elif event.type == "response.completed": print(f"\nFollow-up completed!") - print(f"==> Result: {event.response.output_text}") + print(f"Agent response: {event.response.output_text}") # Clean up resources print("\n" + "=" * 60) diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search_in_stream_async.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search_in_stream_async.py index 3c5e22d1b440..31280352ac7c 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search_in_stream_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_file_search_in_stream_async.py @@ -145,7 +145,7 @@ async def main() -> None: print(f"File Citation - Filename: {annotation.filename}, File ID: {annotation.file_id}") elif event.type == "response.completed": print(f"\nFollow-up completed!") - print(f"==> Result: {event.response.output_text}") + print(f"Agent response: {event.response.output_text}") # Clean up resources print("\n" + "=" * 60) diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_function_tool.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_function_tool.py index 97d349bec751..cfa1dad9ba7f 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_function_tool.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_function_tool.py @@ -108,8 +108,7 @@ def get_horoscope(sign: str) -> str: extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, ) - # Print result (should contain "Tuesday") - print(f"==> Result: {response.output_text}") + print(f"Agent response: {response.output_text}") print("\nCleaning up...") project_client.agents.delete_version(agent_name=agent.name, agent_version=agent.version) diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_function_tool_async.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_function_tool_async.py index 2ff5c44d108f..9a70726fb2cf 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_function_tool_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_function_tool_async.py @@ -109,8 +109,7 @@ async def main(): extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, ) - # Print result (should contain "Tuesday") - print(f"==> Result: {response.output_text}") + print(f"Agent response: {response.output_text}") if __name__ == "__main__": diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_image_generation.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_image_generation.py index 2f9764f2ba95..d901af701df4 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_image_generation.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_image_generation.py @@ -96,5 +96,4 @@ f.write(base64.b64decode(image_data[0])) # [END download_image] - # Print result (should contain "file") - print(f"==> Result: Image downloaded and saved to file: {file_path}") + print(f"Image saved to: {file_path}") diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_image_generation_async.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_image_generation_async.py index 4202bee8a8b1..aeb2e099695c 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_image_generation_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_image_generation_async.py @@ -91,8 +91,7 @@ async def main(): with open(file_path, "wb") as f: f.write(base64.b64decode(image_data[0])) - # Print result (should contain "file") - print(f"==> Result: Image downloaded and saved to file: {file_path}") + print(f"Image saved to: {file_path}") if __name__ == "__main__": diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_mcp.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_mcp.py index 4be58ad9c32a..17fcbe97df6f 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_mcp.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_mcp.py @@ -95,8 +95,7 @@ extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, ) - # Print result (should contain "Azure") - print(f"==> Result: {response.output_text}") + print(f"Agent response: {response.output_text}") # Clean up resources by deleting the agent version # This prevents accumulation of unused agent versions in your project diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_mcp_async.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_mcp_async.py index 9c1ae981c5b1..ddcbc3e4614c 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_mcp_async.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_mcp_async.py @@ -99,8 +99,7 @@ async def main(): extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, ) - # Print result (should contain "Azure") - print(f"==> Result: {response.output_text}") + print(f"Agent response: {response.output_text}") # Clean up resources by deleting the agent version # This prevents accumulation of unused agent versions in your project diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_openapi.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_openapi.py index 781056555036..a95740e2e53f 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_openapi.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_openapi.py @@ -76,8 +76,7 @@ input="Use the OpenAPI tool to print out, what is the weather in Seattle, WA today.", extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, ) - # Print result (should contain "\u00b0F") - print(f"==> Result: {response.output_text}") + print(f"Agent response: {response.output_text}") print("\nCleaning up...") project_client.agents.delete_version(agent_name=agent.name, agent_version=agent.version) diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_sharepoint.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_sharepoint.py index 71b6ae49168a..c41be0e489d2 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_sharepoint.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_sharepoint.py @@ -101,7 +101,7 @@ ) elif event.type == "response.completed": print(f"\nFollow-up completed!") - print(f"==> Result: {event.response.output_text}") + print(f"Agent response: {event.response.output_text}") print("Cleaning up...") project_client.agents.delete_version(agent_name=agent.name, agent_version=agent.version) diff --git a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_web_search.py b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_web_search.py index 3a08a869c62a..7ef45abec3dd 100644 --- a/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_web_search.py +++ b/sdk/ai/azure-ai-projects/samples/agents/tools/sample_agent_web_search.py @@ -65,7 +65,7 @@ input="Show me the latest London Underground service updates", extra_body={"agent": {"name": agent.name, "type": "agent_reference"}}, ) - print(f"==> Result: {response.output_text}") + print(f"Agent response: {response.output_text}") print("\nCleaning up...") project_client.agents.delete_version(agent_name=agent.name, agent_version=agent.version) diff --git a/sdk/ai/azure-ai-projects/tests/agents/tools/multitool/test_agent_code_interpreter_and_function.py b/sdk/ai/azure-ai-projects/tests/agents/tools/multitool/test_agent_code_interpreter_and_function.py index a51bc7b5514e..e4e42fe991f5 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/tools/multitool/test_agent_code_interpreter_and_function.py +++ b/sdk/ai/azure-ai-projects/tests/agents/tools/multitool/test_agent_code_interpreter_and_function.py @@ -18,6 +18,7 @@ from azure.ai.projects.models import PromptAgentDefinition, CodeInterpreterTool, CodeInterpreterToolAuto, FunctionTool from openai.types.responses.response_input_param import FunctionCallOutput, ResponseInputParam + class TestAgentCodeInterpreterAndFunction(TestBase): """Tests for agents using Code Interpreter + Function Tool combination.""" diff --git a/sdk/ai/azure-ai-projects/tests/agents/tools/multitool/test_agent_file_search_and_function.py b/sdk/ai/azure-ai-projects/tests/agents/tools/multitool/test_agent_file_search_and_function.py index ae6d9ad86c70..f95f7f21af1a 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/tools/multitool/test_agent_file_search_and_function.py +++ b/sdk/ai/azure-ai-projects/tests/agents/tools/multitool/test_agent_file_search_and_function.py @@ -328,7 +328,8 @@ def calculate_sum(numbers): # Verify findings discuss the code (proves File Search was used) findings_lower = arguments["findings"].lower() assert any( - keyword in findings_lower for keyword in ["sum", "calculate", "function", "numbers", "list", "return"] + keyword in findings_lower + for keyword in ["sum", "calculate", "function", "numbers", "list", "return"] ), f"Expected findings to discuss the code content. Got: {arguments['findings'][:100]}" input_list.append( diff --git a/sdk/ai/azure-ai-projects/tests/agents/tools/test_agent_ai_search.py b/sdk/ai/azure-ai-projects/tests/agents/tools/test_agent_ai_search.py index 5ee41aea715f..188b24806409 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/tools/test_agent_ai_search.py +++ b/sdk/ai/azure-ai-projects/tests/agents/tools/test_agent_ai_search.py @@ -19,6 +19,7 @@ # The tests in this file rely on an existing Azure AI Search project connection that has been populated with the following document: # https://arxiv.org/pdf/2508.03680 + class TestAgentAISearch(TestBase): # Test questions with expected answers diff --git a/sdk/ai/azure-ai-projects/tests/agents/tools/test_agent_ai_search_async.py b/sdk/ai/azure-ai-projects/tests/agents/tools/test_agent_ai_search_async.py index 71525b089398..6f050e553de1 100644 --- a/sdk/ai/azure-ai-projects/tests/agents/tools/test_agent_ai_search_async.py +++ b/sdk/ai/azure-ai-projects/tests/agents/tools/test_agent_ai_search_async.py @@ -20,6 +20,7 @@ # The tests in this file rely on an existing Azure AI Search project connection that has been populated with the following document: # https://arxiv.org/pdf/2508.03680 + class TestAgentAISearchAsync(TestBase): # Test questions with expected answers diff --git a/sdk/ai/azure-ai-projects/tests/conftest.py b/sdk/ai/azure-ai-projects/tests/conftest.py index ad54a729211e..a074884c8063 100644 --- a/sdk/ai/azure-ai-projects/tests/conftest.py +++ b/sdk/ai/azure-ai-projects/tests/conftest.py @@ -19,7 +19,7 @@ add_general_regex_sanitizer, add_body_key_sanitizer, add_remove_header_sanitizer, - add_general_string_sanitizer, + add_body_regex_sanitizer, ) if not load_dotenv(find_dotenv(), override=True): @@ -120,6 +120,14 @@ def sanitize_url_paths(): value="/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/00000/providers/Microsoft.MachineLearningServices/workspaces/00000/connections/connector-name", ) + # Sanitize print output from sample validation to prevent replay failures when print statements change + # Only targets the validation Responses API call by matching the unique input prefix + add_body_key_sanitizer( + json_path="$.input", + value="sanitized-print-output", + regex=r"print contents array = .*", + ) + # Remove Stainless headers from OpenAI client requests, since they include platform and OS specific info, which we can't have in recorded requests. # Here is an example of all the `x-stainless` headers from a Responses call: # x-stainless-arch: other:amd64 diff --git a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py index a60773a0b691..edfa548ab38d 100644 --- a/sdk/ai/azure-ai-projects/tests/samples/test_samples.py +++ b/sdk/ai/azure-ai-projects/tests/samples/test_samples.py @@ -3,20 +3,34 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. # ------------------------------------ -import csv, os, pytest, re, inspect, sys +import csv, os, pytest, re, inspect, sys, json import importlib.util import unittest.mock as mock +from typing import Union, cast, overload +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import HttpResponseError from devtools_testutils.aio import recorded_by_proxy_async from devtools_testutils import AzureRecordedTestCase, recorded_by_proxy, RecordedTransport from test_base import servicePreparer, patched_open_crlf_to_lf from pytest import MonkeyPatch from azure.ai.projects import AIProjectClient +from azure.ai.projects.aio import AIProjectClient as AsyncAIProjectClient +from azure.identity import DefaultAzureCredential +from azure.identity.aio import DefaultAzureCredential as AsyncDefaultAzureCredential +from pydantic import BaseModel class SampleExecutor: """Helper class for executing sample files with proper environment setup and credential mocking.""" + class TestReport(BaseModel): + """Schema for validation test report.""" + + model_config = {"extra": "forbid"} + correct: bool + reason: str + def __init__( self, test_instance: "AzureRecordedTestCase", sample_path: str, env_var_mapping: dict[str, str], **kwargs ): @@ -52,92 +66,147 @@ def _capture_print(self, *args, **kwargs): self.print_calls.append(" ".join(str(arg) for arg in args)) self._original_print(*args, **kwargs) - def execute(self): + def _get_mock_credential(self, is_async: bool): + """Get a mock credential that supports context manager protocol.""" + credential_instance = self.test_instance.get_credential(AIProjectClient, is_async=is_async) + if is_async: + patch_target = "azure.identity.aio.DefaultAzureCredential" + else: + patch_target = "azure.identity.DefaultAzureCredential" + + # Create a mock that returns a context manager wrapping the credential + mock_credential_class = mock.MagicMock() + mock_credential_class.return_value.__enter__ = mock.MagicMock(return_value=credential_instance) + mock_credential_class.return_value.__exit__ = mock.MagicMock(return_value=None) + mock_credential_class.return_value.__aenter__ = mock.AsyncMock(return_value=credential_instance) + mock_credential_class.return_value.__aexit__ = mock.AsyncMock(return_value=None) + + return mock.patch(patch_target, new=mock_credential_class) + + def execute(self, enable_llm_validation: bool = True): """Execute a synchronous sample with proper mocking and environment setup.""" with ( MonkeyPatch.context() as mp, - mock.patch("builtins.print", side_effect=self._capture_print), - mock.patch("builtins.open", side_effect=patched_open_crlf_to_lf), - mock.patch("azure.identity.DefaultAzureCredential") as mock_credential, + self._get_mock_credential(is_async=False), ): for var_name, var_value in self.env_vars.items(): mp.setenv(var_name, var_value) - credential_instance = self.test_instance.get_credential(AIProjectClient, is_async=False) - credential_mock = mock.MagicMock() - credential_mock.__enter__.return_value = credential_instance - credential_mock.__exit__.return_value = False - mock_credential.return_value = credential_mock - if self.spec.loader is None: - raise ImportError(f"Could not load module {self.spec.name} from {self.sample_path}") - self.spec.loader.exec_module(self.module) - - self._validate_output() - - async def execute_async(self): + + with ( + mock.patch("builtins.print", side_effect=self._capture_print), + mock.patch("builtins.open", side_effect=patched_open_crlf_to_lf), + ): + if self.spec.loader is None: + raise ImportError(f"Could not load module {self.spec.name} from {self.sample_path}") + self.spec.loader.exec_module(self.module) + + if enable_llm_validation: + self._validate_output() + + async def execute_async(self, enable_llm_validation: bool = True): """Execute an asynchronous sample with proper mocking and environment setup.""" + with ( MonkeyPatch.context() as mp, - mock.patch("builtins.print", side_effect=self._capture_print), - mock.patch("builtins.open", side_effect=patched_open_crlf_to_lf), - mock.patch("azure.identity.aio.DefaultAzureCredential") as mock_credential, + self._get_mock_credential(is_async=True), ): for var_name, var_value in self.env_vars.items(): mp.setenv(var_name, var_value) - # Create a mock credential that supports async context manager protocol - credential_instance = self.test_instance.get_credential(AIProjectClient, is_async=True) - credential_mock = mock.AsyncMock() - credential_mock.__aenter__.return_value = credential_instance - credential_mock.__aexit__.return_value = False - mock_credential.return_value = credential_mock - if self.spec.loader is None: - raise ImportError(f"Could not load module {self.spec.name} from {self.sample_path}") - self.spec.loader.exec_module(self.module) - await self.module.main() + with ( + mock.patch("builtins.open", side_effect=patched_open_crlf_to_lf), + mock.patch("builtins.print", side_effect=self._capture_print), + ): + if self.spec.loader is None: + raise ImportError(f"Could not load module {self.spec.name} from {self.sample_path}") + self.spec.loader.exec_module(self.module) + await self.module.main() - self._validate_output() + if enable_llm_validation: + await self._validate_output_async() + + def _get_validation_request_params(self) -> dict: + """Get common parameters for validation request.""" + return { + "model": "gpt-4o", + "instructions": """We just run Python code and captured a Python array of print statements. +Validating the printed content to determine if correct or not: +Respond false if any entries show: +- Error messages or exception text +- Empty or null results where data is expected +- Malformed or corrupted data +- Timeout or connection errors +- Warning messages indicating failures +- Failure to retrieve or process data +- Statements saying documents/information didn't provide relevant data +- Statements saying unable to find/retrieve information +- Asking the user to specify, clarify, or provide more details +- Suggesting to use other tools or sources +- Asking follow-up questions to complete the task +- Indicating lack of knowledge or missing information +- Responses that defer answering or redirect the question +Respond with true only if the result provides a complete, substantive answer with actual data/information. +Always respond with `reason` indicating the reason for the response.""", + "text": { + "format": { + "type": "json_schema", + "name": "TestReport", + "schema": self.TestReport.model_json_schema(), + } + }, + # The input field is sanitized in recordings (see conftest.py) by matching the unique prefix + # "print contents array = ". This allows sample print statements to change without breaking playback. + # The instructions field is preserved as-is in recordings. If you modify the instructions, + # you must re-record the tests. + "input": f"print contents array = {self.print_calls}", + } + + def _assert_validation_result(self, test_report: dict) -> None: + """Assert validation result and print reason.""" + if not test_report["correct"]: + # Write print statements to log file in temp folder for debugging + import tempfile + from datetime import datetime + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + log_file = os.path.join(tempfile.gettempdir(), f"sample_validation_error_{timestamp}.log") + with open(log_file, "w") as f: + f.write(f"Sample: {self.sample_path}\n") + f.write(f"Validation Error: {test_report['reason']}\n\n") + f.write("Print Statements:\n") + f.write("=" * 80 + "\n") + for i, print_call in enumerate(self.print_calls, 1): + f.write(f"{i}. {print_call}\n") + print(f"\nValidation failed! Print statements logged to: {log_file}") + assert test_report["correct"], f"Error is identified: {test_report['reason']}" + print(f"Reason: {test_report['reason']}") def _validate_output(self): - """ - Validates: - * Sample output contains the marker '==> Result: ' - * If the sample includes a comment '# Print result (should contain "")', - the result must include that keyword (case-insensitive) - * If no keyword is specified, the result must be at least 200 characters long - """ - # Find content after ==> Result: marker in print_calls array - result_line = None - for call in self.print_calls: - if call.startswith("==> Result:"): - result_line = call - break - - if not result_line: - assert False, "Expected to find '==> Result:' in print calls." - - # Extract content after ==> Result: - arrow_match = re.search(r"==> Result:(.*)", result_line, re.IGNORECASE | re.DOTALL) - if not arrow_match: - assert False, f"Expected to find '==> Result:' in line: {result_line}" - - content_after_arrow = arrow_match.group(1).strip() - - # Read the sample file to check for expected output comment - with open(self.sample_path) as f: - sample_code = f.read() - - # Verify pattern: # Print result (should contain '...') if exist - match = re.search(r"# Print result \(should contain ['\"](.+?)['\"]\)", sample_code) - if match: - # Decode Unicode escape sequences like \u00b0F to actual characters - expected_contain = match.group(1).encode().decode("unicode_escape") - assert ( - expected_contain.lower() in content_after_arrow.lower() - ), f"Expected to find '{expected_contain}' after '==> Result:', but got: {content_after_arrow}" - else: - result_len = len(content_after_arrow) - assert result_len > 200, f"Expected 200 characters after '==> Result:', but got {result_len} characters" + """Validate sample output using synchronous OpenAI client.""" + credential = self.test_instance.get_credential(AIProjectClient, is_async=False) + with ( + AIProjectClient( + endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=cast(TokenCredential, credential) + ) as project_client, + project_client.get_openai_client() as openai_client, + ): + response = openai_client.responses.create(**self._get_validation_request_params()) + test_report = json.loads(response.output_text) + self._assert_validation_result(test_report) + + async def _validate_output_async(self): + """Validate sample output using asynchronous OpenAI client.""" + credential = self.test_instance.get_credential(AIProjectClient, is_async=True) + async with ( + AsyncAIProjectClient( + endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=cast(AsyncTokenCredential, credential) + ) as project_client, + ): + async with project_client.get_openai_client() as openai_client: + response = await openai_client.responses.create(**self._get_validation_request_params()) + test_report = json.loads(response.output_text) + self._assert_validation_result(test_report) class SamplePathPasser: @@ -156,367 +225,119 @@ def _wrapper_sync(test_class, sample_path, **kwargs): return _wrapper_sync -def _get_tools_sample_paths(): - # Get the path to the samples folder - current_dir = os.path.dirname(os.path.abspath(__file__)) - samples_folder_path = os.path.normpath(os.path.join(current_dir, os.pardir, os.pardir)) - tools_folder = os.path.join(samples_folder_path, "samples", "agents", "tools") - - tools_samples_to_skip = [ - "sample_agent_bing_custom_search.py", - "sample_agent_bing_grounding.py", - "sample_agent_browser_automation.py", - "sample_agent_fabric.py", - "sample_agent_mcp_with_project_connection.py", - "sample_agent_memory_search.py", - "sample_agent_openapi_with_project_connection.py", - "sample_agent_to_agent.py", - ] - samples = [] +@overload +def _get_sample_paths(sub_folder: str, *, samples_to_test: list[str]) -> list: + """Get sample paths for testing (whitelist mode). - for filename in sorted(os.listdir(tools_folder)): - # Only include .py files, exclude __pycache__ and utility files - if "sample_" in filename and "_async" not in filename and filename not in tools_samples_to_skip: - sample_path = os.path.join(tools_folder, filename) - samples.append(pytest.param(sample_path, id=filename.replace(".py", ""))) + Args: + sub_folder: Relative path to the samples subfolder (e.g., "agents/tools") + samples_to_test: Whitelist of sample filenames to include - return samples + Returns: + List of pytest.param objects with sample paths and test IDs + """ + ... -def _get_tools_sample_paths_async(): +@overload +def _get_sample_paths(sub_folder: str, *, samples_to_skip: list[str], is_async: bool) -> list: + """Get sample paths for testing (blacklist mode). + + Args: + sub_folder: Relative path to the samples subfolder (e.g., "agents/tools") + samples_to_skip: Blacklist of sample filenames to exclude (auto-discovers all samples) + is_async: Whether to filter for async samples (_async.py suffix) + + Returns: + List of pytest.param objects with sample paths and test IDs + """ + ... + + +def _get_sample_paths( + sub_folder: str, + *, + samples_to_skip: Union[list[str], None] = None, + is_async: Union[bool, None] = None, + samples_to_test: Union[list[str], None] = None, +) -> list: # Get the path to the samples folder current_dir = os.path.dirname(os.path.abspath(__file__)) samples_folder_path = os.path.normpath(os.path.join(current_dir, os.pardir, os.pardir)) - tools_folder = os.path.join(samples_folder_path, "samples", "agents", "tools") + target_folder = os.path.join(samples_folder_path, "samples", *sub_folder.split("/")) - # Skip async samples that are not yet ready for testing - tools_samples_to_skip = [ - "sample_agent_mcp_with_project_connection_async.py", - "sample_agent_memory_search_async.py", - ] - samples = [] + if not os.path.exists(target_folder): + raise ValueError(f"Target folder does not exist: {target_folder}") - for filename in sorted(os.listdir(tools_folder)): - # Only include async .py files, exclude __pycache__ and utility files - if "sample_" in filename and "_async" in filename and filename not in tools_samples_to_skip: - sample_path = os.path.join(tools_folder, filename) - samples.append(pytest.param(sample_path, id=filename.replace(".py", ""))) + # Discover all sample files in the folder + all_files = [f for f in os.listdir(target_folder) if f.startswith("sample_") and f.endswith(".py")] - return samples + # Filter by async suffix only when using samples_to_skip + if samples_to_skip is not None and is_async is not None: + if is_async: + all_files = [f for f in all_files if f.endswith("_async.py")] + else: + all_files = [f for f in all_files if not f.endswith("_async.py")] + # Apply whitelist or blacklist + if samples_to_test is not None: + files_to_test = [f for f in all_files if f in samples_to_test] + else: # samples_to_skip is not None + assert samples_to_skip is not None + files_to_test = [f for f in all_files if f not in samples_to_skip] -class TestSamples(AzureRecordedTestCase): - _samples_folder_path: str - _results: dict[str, tuple[bool, str]] + # Create pytest.param objects + samples = [] + for filename in sorted(files_to_test): + sample_path = os.path.join(target_folder, filename) + test_id = filename.replace(".py", "") + samples.append(pytest.param(sample_path, id=test_id)) - """ - Test class for running all samples in the `/sdk/ai/azure-ai-projects/samples` folder. - - To run this test: - * 'cd' to the folder '/sdk/ai/azure-ai-projects' in your azure-sdk-for-python repo. - * set AZURE_AI_PROJECT_ENDPOINT= - Define your Microsoft Foundry project endpoint used by the test. - * set AZURE_AI_PROJECTS_CONSOLE_LOGGING=false - to make sure logging is not enabled in the test, to reduce console spew. - * Uncomment the two lines that start with "@pytest.mark.skip" below. - * Run: pytest tests\\samples\\test_samples.py::TestSamples - * Load the resulting report in Excel: tests\\samples\\samples_report.csv - """ + return samples - @classmethod - def setup_class(cls): - current_path = os.path.abspath(__file__) - cls._samples_folder_path = os.path.join(current_path, os.pardir, os.pardir, os.pardir) - cls._results: dict[str, tuple[bool, str]] = {} - - @classmethod - def teardown_class(cls): - """ - Class-level teardown method that generates a report file named "samples_report.csv" after all tests have run. - - The report contains one line per sample run, with three columns: - 1. PASS or FAIL indicating the sample result. - 2. The name of the sample. - 3. The exception string summary if the sample failed, otherwise empty. - - The report is written to the same directory as this test file. - """ - report_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "samples_report.csv") - with open(report_path, mode="w", newline="") as file: - writer = csv.writer(file, quotechar='"', quoting=csv.QUOTE_ALL) # Ensures proper quoting - for test_name, (passed, exception_string) in cls._results.items(): - exception_message = f'"{exception_string.splitlines()[0]}"' if exception_string else "" - writer.writerow([f"{'PASS' if passed else 'FAIL'}", test_name, exception_message]) - - @classmethod - def _set_env_vars(cls, sample_name: str, **kwargs): - """ - Sets environment variables for a given sample run and prints them. - - Args: - sample_name (str): The name of the sample being executed. - **kwargs: Arbitrary keyword arguments representing environment variable names and their values. - """ - - print(f"\nRunning {sample_name} with environment variables: ", end="") - for key, value in kwargs.items(): - if value: - env_key = key.upper() - os.environ[env_key] = value - print(f"{env_key}={value} ", end="") - print("\n") - - @classmethod - def _run_sample(cls, sample_name: str) -> None: - """ - Executes a synchronous sample file and records the result. - - Args: - sample_name (str): The name of the sample file to execute. - - Raises: - Exception: Re-raises any exception encountered during execution of the sample file. - - Side Effects: - Updates the class-level _results dictionary with the execution status and error message (if any) - for the given sample. - Prints an error message to stdout if execution fails. - """ - - sample_path = os.path.normpath(os.path.join(TestSamples._samples_folder_path, sample_name)) - with open(sample_path) as f: - code = f.read() - try: - exec(code) - except HttpResponseError as exc: - exception_message = f"{exc.status_code}, {exc.reason}, {str(exc)}" - TestSamples._results[sample_name] = (False, exception_message) - print(f"=================> Error running sample {sample_path}: {exception_message}") - raise Exception from exc - except Exception as exc: - TestSamples._results[sample_name] = (False, str(exc)) - print(f"=================> Error running sample {sample_path}: {exc}") - raise Exception from exc - TestSamples._results[sample_name] = (True, "") - - @classmethod - async def _run_sample_async(cls, sample_name: str) -> None: - """ - Asynchronously runs a sample Python script specified by its file name. - - This method dynamically imports the sample module from the given file path, - executes its `main()` coroutine, and records the result. If an exception occurs - during execution, the error is logged and re-raised. - - Args: - sample_name (str): The name of the sample Python file to run (relative to the samples folder). - - Raises: - ImportError: If the sample module cannot be loaded. - Exception: If an error occurs during the execution of the sample's `main()` coroutine. - - Side Effects: - Updates the `_results` dictionary with the execution status and error message (if any). - Prints error messages to the console if execution fails. - """ - - sample_path = os.path.normpath(os.path.join(TestSamples._samples_folder_path, sample_name)) - # Dynamically import the module from the given path - module_name = os.path.splitext(os.path.basename(sample_path))[0] - spec = importlib.util.spec_from_file_location(module_name, sample_path) - if spec is None or spec.loader is None: - raise ImportError(f"Could not load module {module_name} from {sample_path}") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - # Await the main() coroutine defined in the sample - try: - await module.main() - except HttpResponseError as exc: - exception_message = f"{exc.status_code}, {exc.reason}, {str(exc)}" - TestSamples._results[sample_name] = (False, exception_message) - print(f"=================> Error running sample {sample_path}: {exception_message}") - raise Exception from exc - except Exception as exc: - TestSamples._results[sample_name] = (False, str(exc)) - print(f"=================> Error running sample {sample_path}: {exc}") - raise Exception from exc - TestSamples._results[sample_name] = (True, "") - @pytest.mark.parametrize( - "sample_name, model_deployment_name, connection_name, data_folder", - [ - ("samples\\agents\\sample_agents.py", "gpt-4o", "", ""), - ("samples\\connections\\sample_connections.py", "", "connection1", ""), - ("samples\\deployments\\sample_deployments.py", "DeepSeek-V3", "", ""), - ("samples\\datasets\\sample_datasets.py", "", "balapvbyostoragecanary", "samples\\datasets\\data_folder"), - ( - "samples\\datasets\\sample_datasets_download.py", - "", - "balapvbyostoragecanary", - "samples\\datasets\\data_folder", - ), - ("samples\\indexes\\sample_indexes.py", "", "", ""), - ( - "samples\\inference\\azure-ai-inference\\sample_chat_completions_with_azure_ai_inference_client.py", - "Phi-4", - "", - "", - ), - ( - "samples\\inference\\azure-ai-inference\\sample_chat_completions_with_azure_ai_inference_client_and_azure_monitor_tracing.py", - "Phi-4", - "", - "", - ), - ( - "samples\\inference\\azure-ai-inference\\sample_chat_completions_with_azure_ai_inference_client_and_console_tracing.py", - "Phi-4", - "", - "", - ), - ( - "samples\\inference\\azure-openai\\sample_chat_completions_with_azure_openai_client.py", - "gpt-4o", - "connection1", - "", - ), - ( - "samples\\inference\\azure-openai\\sample_responses_with_azure_openai_client.py", - "gpt-4o", - "connection1", - "", - ), - ( - "samples\\inference\\azure-openai\\sample_chat_completions_with_azure_openai_client_and_azure_monitor_tracing.py", - "gpt-4o", - "", - "", - ), - ( - "samples\\inference\\azure-openai\\sample_chat_completions_with_azure_openai_client_and_console_tracing.py", - "gpt-4o", - "", - "", - ), - ( - "samples\\inference\\azure-ai-inference\\sample_image_embeddings_with_azure_ai_inference_client.py", - "Cohere-embed-v3-english", - "", - "samples\\inference\\azure-ai-inference", - ), - ( - "samples\\inference\\azure-ai-inference\\sample_text_embeddings_with_azure_ai_inference_client.py", - "text-embedding-3-large", - "", - "", - ), - ("samples\\telemetry\\sample_telemetry.py", "", "", ""), - ], - ) - @pytest.mark.skip(reason="This test should only run manually on your local machine, with live service calls.") - def test_samples( - self, sample_name: str, model_deployment_name: str, connection_name: str, data_folder: str - ) -> None: - """ - Run all the synchronous sample code in the samples folder. If a sample throws an exception, which for example - happens when the service responds with an error, the test will fail. - - Before running this test, you need to define the following environment variables: - 1) AZURE_AI_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the overview page of your - Microsoft Foundry project. - """ - - self._set_env_vars( - sample_name, - **{ - "model_deployment_name": model_deployment_name, - "connection_name": connection_name, - "data_folder": data_folder, - }, - ) - TestSamples._run_sample(sample_name) +class TestSamples(AzureRecordedTestCase): + @servicePreparer() @pytest.mark.parametrize( - "sample_name, model_deployment_name, connection_name, data_folder", - [ - ("samples\\agents\\sample_agents_async.py", "gpt-4o", "", ""), - ("samples\\connections\\sample_connections_async.py", "", "connection1", ""), - ( - "samples\\datasets\\sample_datasets_async.py", - "", - "balapvbyostoragecanary", - "samples\\datasets\\data_folder", - ), - ("samples\\deployments\\sample_deployments_async.py", "DeepSeek-V3", "", ""), - ("samples\\indexes\\sample_indexes_async.py", "", "", ""), - ( - "samples\\inference\\azure-ai-inference\\sample_chat_completions_with_azure_ai_inference_client_async.py", - "Phi-4", - "", - "", - ), - ( - "samples\\inference\\azure-openai\\sample_chat_completions_with_azure_openai_client_async.py", - "gpt-4o", - "connection1", - "", - ), - ( - "samples\\inference\\azure-openai\\sample_responses_with_azure_openai_client_async.py", - "gpt-4o", - "connection1", - "", - ), - ( - "samples\\inference\\azure-ai-inference\\sample_image_embeddings_with_azure_ai_inference_client_async.py", - "Cohere-embed-v3-english", - "", - "samples\\inference\\azure-ai-inference", - ), - ( - "samples\\inference\\azure-ai-inference\\sample_text_embeddings_with_azure_ai_inference_client_async.py", - "text-embedding-3-large", - "", - "", - ), - ("samples\\telemetry\\sample_telemetry_async.py", "", "", ""), - ], + "sample_path", + _get_sample_paths( + "agents/tools", + samples_to_skip=[ + "sample_agent_bing_custom_search.py", + "sample_agent_bing_grounding.py", + "sample_agent_browser_automation.py", + "sample_agent_fabric.py", + "sample_agent_mcp_with_project_connection.py", + "sample_agent_memory_search.py", + "sample_agent_openapi_with_project_connection.py", + "sample_agent_to_agent.py", + ], + is_async=False, + ), ) - @pytest.mark.skip(reason="This test should only run manually on your local machine, with live service calls.") - async def test_samples_async( - self, sample_name: str, model_deployment_name: str, connection_name: str, data_folder: str - ) -> None: - """ - Run all the asynchronous sample code in the samples folder. If a sample throws an exception, which for example - happens when the service responds with an error, the test will fail. - - Before running this test, you need to define the following environment variables: - 1) AZURE_AI_PROJECT_ENDPOINT - The Azure AI Project endpoint, as found in the overview page of your - Microsoft Foundry project. - """ - - self._set_env_vars( - sample_name, - **{ - "model_deployment_name": model_deployment_name, - "connection_name": connection_name, - "data_folder": data_folder, - }, - ) - await TestSamples._run_sample_async(sample_name) - - @servicePreparer() - @pytest.mark.parametrize("sample_path", _get_tools_sample_paths()) @SamplePathPasser() @recorded_by_proxy(RecordedTransport.AZURE_CORE, RecordedTransport.HTTPX) - def test_tools_samples(self, sample_path: str, **kwargs) -> None: + def test_agent_tools_samples(self, sample_path: str, **kwargs) -> None: env_var_mapping = self._get_sample_environment_variables_map() executor = SampleExecutor(self, sample_path, env_var_mapping, **kwargs) executor.execute() @servicePreparer() - @pytest.mark.parametrize("sample_path", _get_tools_sample_paths_async()) + @pytest.mark.parametrize( + "sample_path", + _get_sample_paths( + "agents/tools", + samples_to_skip=[ + "sample_agent_mcp_with_project_connection_async.py", + "sample_agent_memory_search_async.py", + ], + is_async=True, + ), + ) @SamplePathPasser() @recorded_by_proxy_async(RecordedTransport.AZURE_CORE, RecordedTransport.HTTPX) - async def test_tools_samples_async(self, sample_path: str, **kwargs) -> None: + async def test_agent_tools_samples_async(self, sample_path: str, **kwargs) -> None: env_var_mapping = self._get_sample_environment_variables_map() executor = SampleExecutor(self, sample_path, env_var_mapping, **kwargs) await executor.execute_async()