From 35b102e003ce3fed14b70386e58845b87ebfb6c9 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 18 Feb 2026 11:03:24 +0100 Subject: [PATCH 1/4] Python: improve .env precedence and observability samples - Switch load_settings to explicit precedence: overrides -> explicit .env -> environment -> defaults\n- Raise when env_file_path is provided but missing\n- Update settings docs and tests for new behavior\n- Refresh observability samples and README guidance for env loading options\n\nCloses #3864\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_anthropic/_chat_client.py | 6 +- .../anthropic/tests/test_anthropic_client.py | 7 +-- .../_context_provider.py | 5 +- .../agent_framework_azure_ai/_shared.py | 7 +-- .../tests/test_azure_ai_agent_client.py | 2 +- .../azure-ai/tests/test_azure_ai_client.py | 2 +- .../claude/agent_framework_claude/__init__.py | 3 +- .../claude/agent_framework_claude/_agent.py | 59 +++++++++++++------ .../agent_framework_claude/_settings.py | 29 --------- .../agent_framework_copilotstudio/_agent.py | 6 +- .../packages/core/agent_framework/__init__.py | 5 ++ .../core/agent_framework/_settings.py | 44 ++++++++++---- .../core/agent_framework/azure/_shared.py | 7 +-- .../core/agent_framework/openai/_shared.py | 7 +-- .../azure/test_azure_assistants_client.py | 2 +- .../tests/azure/test_azure_chat_client.py | 3 - .../azure/test_azure_responses_client.py | 1 - python/packages/core/tests/conftest.py | 3 +- .../core/tests/core/test_observability.py | 17 +++--- .../packages/core/tests/core/test_settings.py | 33 ++++++++--- .../openai/test_openai_assistants_client.py | 4 +- .../tests/openai/test_openai_chat_client.py | 2 - .../openai/test_openai_responses_client.py | 2 - .../_foundry_local_client.py | 6 +- .../tests/test_foundry_local_client.py | 26 ++++---- .../__init__.py | 3 +- .../agent_framework_github_copilot/_agent.py | 26 +++++++- .../_settings.py | 27 --------- .../ollama/tests/test_ollama_chat_client.py | 1 - .../samples/02-agents/observability/README.md | 12 +++- .../advanced_manual_setup_console_output.py | 12 ++-- .../observability/advanced_zero_code.py | 19 +++--- .../observability/agent_observability.py | 6 +- .../agent_with_foundry_tracing.py | 8 ++- .../azure_ai_agent_observability.py | 8 ++- .../configure_otel_providers_with_env_var.py | 19 +++--- ...onfigure_otel_providers_with_parameters.py | 15 ++--- 37 files changed, 238 insertions(+), 206 deletions(-) delete mode 100644 python/packages/claude/agent_framework_claude/_settings.py delete mode 100644 python/packages/github_copilot/agent_framework_github_copilot/_settings.py diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 840aa8d7fd..485933aa40 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -196,9 +196,9 @@ class AnthropicChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], class AnthropicSettings(TypedDict, total=False): """Anthropic Project settings. - The settings are first loaded from environment variables with the prefix 'ANTHROPIC_'. - If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'ANTHROPIC_'. Keys: api_key: The Anthropic API key. diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 4b164ce84d..2325b7c0da 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -50,7 +50,6 @@ def create_test_anthropic_client( env_prefix="ANTHROPIC_", api_key="test-api-key-12345", chat_model_id="claude-3-5-sonnet-20241022", - env_file_path="test.env", ) # Create client instance directly @@ -72,7 +71,7 @@ def create_test_anthropic_client( def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> None: """Test AnthropicSettings initialization.""" - settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_", env_file_path="test.env") + settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_") assert settings["api_key"] is not None assert settings["api_key"].get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"] @@ -86,7 +85,6 @@ def test_anthropic_settings_init_with_explicit_values() -> None: env_prefix="ANTHROPIC_", api_key="custom-api-key", chat_model_id="claude-3-opus-20240229", - env_file_path="test.env", ) assert settings["api_key"] is not None @@ -97,7 +95,7 @@ def test_anthropic_settings_init_with_explicit_values() -> None: @pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True) def test_anthropic_settings_missing_api_key(anthropic_unit_test_env: dict[str, str]) -> None: """Test AnthropicSettings when API key is missing.""" - settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_", env_file_path="test.env") + settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_") assert settings["api_key"] is None assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] @@ -119,7 +117,6 @@ def test_anthropic_client_init_auto_create_client(anthropic_unit_test_env: dict[ client = AnthropicClient( api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"], model_id=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"], - env_file_path="test.env", ) assert client.anthropic_client is not None diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index f2d74ec1fb..e4bcaf2723 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -111,8 +111,9 @@ class AzureAISearchSettings(TypedDict, total=False): """Settings for Azure AI Search Context Provider with auto-loading from environment. - The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'. - If the environment variables are not found, the settings can be loaded from a .env file. + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'AZURE_SEARCH_'. Keys: endpoint: Azure AI Search endpoint URL. diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py index a709b3f66c..3d1779fbde 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py @@ -43,10 +43,9 @@ class AzureAISettings(TypedDict, total=False): """Azure AI Project settings. - The settings are first loaded from environment variables with the prefix 'AZURE_AI_'. - If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. If the settings are not found in the .env file, the settings - are ignored; however, validation will fail alerting that the settings are missing. + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'AZURE_AI_'. If settings are missing after resolution, validation will fail. Keyword Args: project_endpoint: The Azure AI Project endpoint URL. diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index 8ca3f0dc56..8df5c92dbe 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -68,7 +68,7 @@ def create_test_azure_ai_chat_client( ) -> AzureAIAgentClient: """Helper function to create AzureAIAgentClient instances for testing, bypassing normal validation.""" if azure_ai_settings is None: - azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", env_file_path="test.env") + azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") # Create client instance directly client = object.__new__(AzureAIAgentClient) diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index 38839b32d0..23feb8b1cf 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -114,7 +114,7 @@ def create_test_azure_ai_client( ) -> AzureAIClient: """Helper function to create AzureAIClient instances for testing, bypassing normal validation.""" if azure_ai_settings is None: - azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", env_file_path="test.env") + azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") # Create client instance directly client = object.__new__(AzureAIClient) diff --git a/python/packages/claude/agent_framework_claude/__init__.py b/python/packages/claude/agent_framework_claude/__init__.py index 18f30bf25e..3c666f4a31 100644 --- a/python/packages/claude/agent_framework_claude/__init__.py +++ b/python/packages/claude/agent_framework_claude/__init__.py @@ -2,8 +2,7 @@ import importlib.metadata -from ._agent import ClaudeAgent, ClaudeAgentOptions -from ._settings import ClaudeAgentSettings +from ._agent import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings try: __version__ = importlib.metadata.version(__name__) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 204b364962..83ffcbe504 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -13,6 +13,7 @@ AgentMiddlewareTypes, AgentResponse, AgentResponseUpdate, + AgentRunInputs, AgentSession, BaseAgent, BaseContextProvider, @@ -20,11 +21,11 @@ FunctionTool, Message, ResponseStream, + ToolTypes, + load_settings, normalize_messages, + normalize_tools, ) -from agent_framework._settings import load_settings -from agent_framework._tools import ToolTypes -from agent_framework._types import AgentRunInputs, normalize_tools from agent_framework.exceptions import ServiceException from claude_agent_sdk import ( AssistantMessage, @@ -38,8 +39,6 @@ ) from claude_agent_sdk.types import StreamEvent, TextBlock -from ._settings import ClaudeAgentSettings - if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover else: @@ -60,16 +59,40 @@ SdkBeta, ) -__all__ = ["ClaudeAgent", "ClaudeAgentOptions"] logger = logging.getLogger("agent_framework.claude") + # Name of the in-process MCP server that hosts Agent Framework tools. # FunctionTool instances are converted to SDK MCP tools and served # through this server, as Claude Code CLI only supports tools via MCP. TOOLS_MCP_SERVER_NAME = "_agent_framework_tools" +class ClaudeAgentSettings(TypedDict, total=False): + """Claude Agent settings. + + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'CLAUDE_AGENT_'. + + Keys: + cli_path: The path to Claude CLI executable. + model: The model to use (sonnet, opus, haiku). + cwd: The working directory for Claude CLI. + permission_mode: Permission mode (default, acceptEdits, plan, bypassPermissions). + max_turns: Maximum number of conversation turns. + max_budget_usd: Maximum budget in USD. + """ + + cli_path: str | None + model: str | None + cwd: str | None + permission_mode: str | None + max_turns: int | None + max_budget_usd: float | None + + class ClaudeAgentOptions(TypedDict, total=False): """Claude Agent-specific options.""" @@ -402,18 +425,18 @@ def _prepare_client_options(self, resume_session_id: str | None = None) -> SDKOp opts["resume"] = resume_session_id # Apply settings from environment - if self._settings["cli_path"]: - opts["cli_path"] = self._settings["cli_path"] - if self._settings["model"]: - opts["model"] = self._settings["model"] - if self._settings["cwd"]: - opts["cwd"] = self._settings["cwd"] - if self._settings["permission_mode"]: - opts["permission_mode"] = self._settings["permission_mode"] - if self._settings["max_turns"]: - opts["max_turns"] = self._settings["max_turns"] - if self._settings["max_budget_usd"]: - opts["max_budget_usd"] = self._settings["max_budget_usd"] + if cli_path := self._settings.get("cli_path"): + opts["cli_path"] = cli_path + if model := self._settings.get("model"): + opts["model"] = model + if cwd := self._settings.get("cwd"): + opts["cwd"] = cwd + if permission_mode := self._settings.get("permission_mode"): + opts["permission_mode"] = permission_mode + if max_turns := self._settings.get("max_turns"): + opts["max_turns"] = max_turns + if max_budget_usd := self._settings.get("max_budget_usd"): + opts["max_budget_usd"] = max_budget_usd # Apply default options for key, value in self._default_options.items(): diff --git a/python/packages/claude/agent_framework_claude/_settings.py b/python/packages/claude/agent_framework_claude/_settings.py deleted file mode 100644 index cccc0fe373..0000000000 --- a/python/packages/claude/agent_framework_claude/_settings.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import TypedDict - -__all__ = ["ClaudeAgentSettings"] - - -class ClaudeAgentSettings(TypedDict, total=False): - """Claude Agent settings. - - The settings are first loaded from environment variables with the prefix 'CLAUDE_AGENT_'. - If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. - - Keys: - cli_path: The path to Claude CLI executable. - model: The model to use (sonnet, opus, haiku). - cwd: The working directory for Claude CLI. - permission_mode: Permission mode (default, acceptEdits, plan, bypassPermissions). - max_turns: Maximum number of conversation turns. - max_budget_usd: Maximum budget in USD. - """ - - cli_path: str | None - model: str | None - cwd: str | None - permission_mode: str | None - max_turns: int | None - max_budget_usd: float | None diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index fb832e4468..cfa1899640 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -28,9 +28,9 @@ class CopilotStudioSettings(TypedDict, total=False): """Copilot Studio model settings. - The settings are first loaded from environment variables with the prefix 'COPILOTSTUDIOAGENT__'. - If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'COPILOTSTUDIOAGENT__'. Keys: environmentid: Environment ID of environment with the Copilot Studio App. diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 2cc7d0d713..44898783de 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -56,6 +56,7 @@ SessionContext, register_state_type, ) +from ._settings import SecretString, load_settings from ._telemetry import ( AGENT_FRAMEWORK_USER_AGENT, APP_INFO, @@ -67,6 +68,7 @@ FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, + ToolTypes, normalize_function_invocation_configuration, tool, ) @@ -234,6 +236,7 @@ "RoleLiteral", "Runner", "RunnerContext", + "SecretString", "SessionContext", "SingleEdgeGroup", "SubWorkflowRequestMessage", @@ -250,6 +253,7 @@ "SwitchCaseEdgeGroupDefault", "TextSpanRegion", "ToolMode", + "ToolTypes", "TypeCompatibilityError", "UpdateT", "UsageDetails", @@ -282,6 +286,7 @@ "executor", "function_middleware", "handler", + "load_settings", "map_chat_to_agent_update", "merge_chat_options", "normalize_function_invocation_configuration", diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py index ada0c146f0..c6a510c943 100644 --- a/python/packages/core/agent_framework/_settings.py +++ b/python/packages/core/agent_framework/_settings.py @@ -36,7 +36,7 @@ class MySettings(TypedDict, total=False): from contextlib import suppress from typing import Any, Union, get_args, get_origin, get_type_hints -from dotenv import load_dotenv +from dotenv import dotenv_values from .exceptions import SettingNotFoundError @@ -172,14 +172,14 @@ def load_settings( required_fields: Sequence[str | tuple[str, ...]] | None = None, **overrides: Any, ) -> SettingsT: - """Load settings from environment variables, a ``.env`` file, and explicit overrides. + """Load settings from explicit overrides, an optional ``.env`` file, and environment variables. The *settings_type* must be a ``TypedDict`` subclass. Values are resolved in this order (highest priority first): 1. Explicit keyword *overrides* (``None`` values are filtered out). - 2. Environment variables (````). - 3. A ``.env`` file (loaded via ``python-dotenv``; existing env vars take precedence). + 2. A ``.env`` file (when *env_file_path* is explicitly provided). + 3. Environment variables (````). 4. Default values — fields with class-level defaults on the TypedDict, or ``None`` for optional fields. @@ -192,7 +192,8 @@ def load_settings( Args: settings_type: A ``TypedDict`` class describing the settings schema. env_prefix: Prefix for environment variable lookup (e.g. ``"OPENAI_"``). - env_file_path: Path to ``.env`` file. Defaults to ``".env"`` when omitted. + env_file_path: Path to ``.env`` file. When provided, the file is required + and values are resolved before process environment variables. env_file_encoding: Encoding for reading the ``.env`` file. Defaults to ``"utf-8"``. required_fields: Field names (``str``) that must resolve to a non-``None`` value, or tuples of field names where exactly one must be set. @@ -203,16 +204,22 @@ def load_settings( A populated dict matching *settings_type*. Raises: + FileNotFoundError: If *env_file_path* was provided but the file does not exist. SettingNotFoundError: If a required field could not be resolved from any source, or if a mutually exclusive constraint is violated. ServiceInitializationError: If an override value has an incompatible type. """ encoding = env_file_encoding or "utf-8" - # Load .env file if it exists (existing env vars take precedence by default) - env_path = env_file_path or ".env" - if os.path.isfile(env_path): - load_dotenv(dotenv_path=env_path, encoding=encoding) + loaded_dotenv_values: dict[str, str] = {} + if env_file_path is not None: + if not os.path.isfile(env_file_path): + raise FileNotFoundError(env_file_path) + + raw_dotenv_values = dotenv_values(dotenv_path=env_file_path, encoding=encoding) + loaded_dotenv_values = { + key: value for key, value in raw_dotenv_values.items() if key is not None and value is not None + } # Filter out None overrides so defaults / env vars are preserved overrides = {k: v for k, v in overrides.items() if v is not None} @@ -220,7 +227,7 @@ def load_settings( # Get field type hints from the TypedDict hints = get_type_hints(settings_type) - result: dict[str, Any] = {} + result: SettingsT = {} for field_name, field_type in hints.items(): # 1. Explicit override wins if field_name in overrides: @@ -235,8 +242,19 @@ def load_settings( result[field_name] = override_value continue - # 2. Environment variable env_var_name = f"{env_prefix}{field_name.upper()}" + + # 2. Optional .env value (only when env_file_path is explicitly provided) + if loaded_dotenv_values: + dotenv_value = loaded_dotenv_values.get(env_var_name) + if dotenv_value is not None: + try: + result[field_name] = _coerce_value(dotenv_value, field_type) + except (ValueError, TypeError): + result[field_name] = dotenv_value + continue + + # 3. Environment variable env_value = os.getenv(env_var_name) if env_value is not None: try: @@ -245,7 +263,7 @@ def load_settings( result[field_name] = env_value continue - # 3. Default from TypedDict class-level defaults, or None for optional fields + # 4. Default from TypedDict class-level defaults, or None for optional fields if hasattr(settings_type, field_name): result[field_name] = getattr(settings_type, field_name) else: @@ -276,4 +294,4 @@ def load_settings( f"Only one of {all_names} may be provided, but multiple were set: {set_names}." ) - return result # type: ignore[return-value] + return result diff --git a/python/packages/core/agent_framework/azure/_shared.py b/python/packages/core/agent_framework/azure/_shared.py index c3e4399555..02529ee8b3 100644 --- a/python/packages/core/agent_framework/azure/_shared.py +++ b/python/packages/core/agent_framework/azure/_shared.py @@ -33,10 +33,9 @@ class AzureOpenAISettings(TypedDict, total=False): """AzureOpenAI model settings. - The settings are first loaded from environment variables with the prefix 'AZURE_OPENAI_'. - If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. If the settings are not found in the .env file, the settings - are ignored; however, validation will fail alerting that the settings are missing. + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'AZURE_OPENAI_'. If settings are missing after resolution, validation will fail. Keyword Args: endpoint: The endpoint of the Azure deployment. This value diff --git a/python/packages/core/agent_framework/openai/_shared.py b/python/packages/core/agent_framework/openai/_shared.py index d7b0be3723..38929cca75 100644 --- a/python/packages/core/agent_framework/openai/_shared.py +++ b/python/packages/core/agent_framework/openai/_shared.py @@ -78,10 +78,9 @@ def _check_openai_version_for_callable_api_key() -> None: class OpenAISettings(TypedDict, total=False): """OpenAI environment settings. - The settings are first loaded from environment variables with the prefix 'OPENAI_'. - If the environment variables are not found, the settings can be loaded from a .env file with the - encoding 'utf-8'. If the settings are not found in the .env file, the settings are ignored; - however, validation will fail alerting that the settings are missing. + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'OPENAI_'. If settings are missing after resolution, validation will fail. Keyword Args: api_key: OpenAI API key, see https://platform.openai.com/account/api-keys. diff --git a/python/packages/core/tests/azure/test_azure_assistants_client.py b/python/packages/core/tests/azure/test_azure_assistants_client.py index eff19b27e6..f2c6a7701d 100644 --- a/python/packages/core/tests/azure/test_azure_assistants_client.py +++ b/python/packages/core/tests/azure/test_azure_assistants_client.py @@ -130,7 +130,7 @@ def test_azure_assistants_client_init_missing_deployment_name(azure_openai_unit_ """Test AzureOpenAIAssistantsClient initialization with missing deployment name.""" with pytest.raises(ServiceInitializationError): AzureOpenAIAssistantsClient( - api_key=azure_openai_unit_test_env.get("AZURE_OPENAI_API_KEY", "test-key"), env_file_path="nonexistent.env" + api_key=azure_openai_unit_test_env.get("AZURE_OPENAI_API_KEY", "test-key") ) diff --git a/python/packages/core/tests/azure/test_azure_chat_client.py b/python/packages/core/tests/azure/test_azure_chat_client.py index b9a279d478..091b875a13 100644 --- a/python/packages/core/tests/azure/test_azure_chat_client.py +++ b/python/packages/core/tests/azure/test_azure_chat_client.py @@ -95,7 +95,6 @@ def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: def test_init_with_empty_deployment_name(azure_openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): AzureOpenAIChatClient( - env_file_path="test.env", ) @@ -103,7 +102,6 @@ def test_init_with_empty_deployment_name(azure_openai_unit_test_env: dict[str, s def test_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): AzureOpenAIChatClient( - env_file_path="test.env", ) @@ -126,7 +124,6 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: "api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], "api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"], "default_headers": default_headers, - "env_file_path": "test.env", } azure_chat_client = AzureOpenAIChatClient.from_dict(settings) diff --git a/python/packages/core/tests/azure/test_azure_responses_client.py b/python/packages/core/tests/azure/test_azure_responses_client.py index 5bd7744652..106aca40b1 100644 --- a/python/packages/core/tests/azure/test_azure_responses_client.py +++ b/python/packages/core/tests/azure/test_azure_responses_client.py @@ -112,7 +112,6 @@ def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): AzureOpenAIResponsesClient( - env_file_path="test.env", ) diff --git a/python/packages/core/tests/conftest.py b/python/packages/core/tests/conftest.py index fd8b93ebc2..6feb27bbfc 100644 --- a/python/packages/core/tests/conftest.py +++ b/python/packages/core/tests/conftest.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import os from collections.abc import Generator from typing import Any from unittest.mock import patch @@ -61,7 +62,7 @@ def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_da importlib.reload(observability) # recreate observability settings with values from above and no file. - observability_settings = observability.ObservabilitySettings(env_file_path="test.env") + observability_settings = observability.ObservabilitySettings(env_file_path=os.devnull) # Configure providers manually without calling _configure() to avoid OTLP imports if enable_instrumentation or enable_sensitive_data: diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 7a96cece27..1aa5fc2cb5 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import logging +import os from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence from typing import Any from unittest.mock import Mock @@ -901,7 +902,7 @@ def test_console_exporters_opt_in_false(monkeypatch): monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "false") monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) - settings = ObservabilitySettings(env_file_path="test.env") + settings = ObservabilitySettings(env_file_path=os.devnull) assert settings.enable_console_exporters is False @@ -911,7 +912,7 @@ def test_console_exporters_opt_in_true(monkeypatch): monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true") - settings = ObservabilitySettings(env_file_path="test.env") + settings = ObservabilitySettings(env_file_path=os.devnull) assert settings.enable_console_exporters is True @@ -921,7 +922,7 @@ def test_console_exporters_default_false(monkeypatch): monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) - settings = ObservabilitySettings(env_file_path="test.env") + settings = ObservabilitySettings(env_file_path=os.devnull) assert settings.enable_console_exporters is False @@ -996,7 +997,7 @@ def test_observability_settings_is_setup_initial(monkeypatch): from agent_framework.observability import ObservabilitySettings monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) - settings = ObservabilitySettings(env_file_path="test.env") + settings = ObservabilitySettings(env_file_path=os.devnull) assert settings.is_setup is False @@ -1452,7 +1453,7 @@ def test_get_exporters_from_env_no_endpoints(monkeypatch): ]: monkeypatch.delenv(key, raising=False) - exporters = _get_exporters_from_env() + exporters = _get_exporters_from_env(env_file_path=os.devnull) assert exporters == [] @@ -1464,7 +1465,7 @@ def test_observability_settings_configure_not_enabled(monkeypatch): from agent_framework.observability import ObservabilitySettings monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") - settings = ObservabilitySettings(env_file_path="test.env") + settings = ObservabilitySettings(env_file_path=os.devnull) # Should not raise, should just return early settings._configure() @@ -1485,7 +1486,7 @@ def test_observability_settings_configure_already_setup(monkeypatch): ]: monkeypatch.delenv(key, raising=False) - settings = ObservabilitySettings(env_file_path="test.env") + settings = ObservabilitySettings(env_file_path=os.devnull) # Manually mark as set up settings._executed_setup = True @@ -2021,7 +2022,7 @@ def test_configure_providers_with_span_exporters(monkeypatch): ]: monkeypatch.delenv(key, raising=False) - settings = ObservabilitySettings(env_file_path="test.env") + settings = ObservabilitySettings(env_file_path=os.devnull) # Create mock span exporter mock_span_exporter = Mock(spec=SpanExporter) diff --git a/python/packages/core/tests/core/test_settings.py b/python/packages/core/tests/core/test_settings.py index 8ab60ca043..3aa610de29 100644 --- a/python/packages/core/tests/core/test_settings.py +++ b/python/packages/core/tests/core/test_settings.py @@ -8,7 +8,7 @@ import pytest -from agent_framework._settings import SecretString, load_settings +from agent_framework import SecretString, load_settings class SimpleSettings(TypedDict, total=False): @@ -106,7 +106,7 @@ def test_load_from_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None: finally: os.unlink(env_path) - def test_env_vars_override_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_dotenv_overrides_env_vars_when_env_file_path_is_set(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("TEST_APP_API_KEY", "real-env-key") with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: @@ -117,15 +117,34 @@ def test_env_vars_override_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None try: settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=env_path) - assert settings["api_key"] == "real-env-key" + assert settings["api_key"] == "dotenv-key" finally: os.unlink(env_path) - def test_missing_dotenv_file(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("TEST_APP_API_KEY", raising=False) - settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path="/nonexistent/.env") + def test_env_vars_are_used_when_env_file_path_is_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_API_KEY", "real-env-key") + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_") - assert settings["api_key"] is None + assert settings["api_key"] == "real-env-key" + + def test_overrides_beat_dotenv_and_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_TIMEOUT", "120") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("TEST_APP_TIMEOUT=90\n") + f.flush() + env_path = f.name + + try: + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=env_path, timeout=60) + + assert settings["timeout"] == 60 + finally: + os.unlink(env_path) + + def test_missing_dotenv_file_raises(self) -> None: + with pytest.raises(FileNotFoundError): + load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path="/nonexistent/.env") class TestSecretString: diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index 4521a3c693..fa07d8f1a1 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -155,7 +155,7 @@ def test_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None: """Test OpenAIAssistantsClient initialization with missing model ID.""" with pytest.raises(ServiceInitializationError): OpenAIAssistantsClient( - api_key=openai_unit_test_env.get("OPENAI_API_KEY", "test-key"), env_file_path="nonexistent.env" + api_key=openai_unit_test_env.get("OPENAI_API_KEY", "test-key") ) @@ -163,7 +163,7 @@ def test_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None: def test_init_missing_api_key(openai_unit_test_env: dict[str, str]) -> None: """Test OpenAIAssistantsClient initialization with missing API key.""" with pytest.raises(ServiceInitializationError): - OpenAIAssistantsClient(model_id="gpt-4", env_file_path="nonexistent.env") + OpenAIAssistantsClient(model_id="gpt-4") def test_init_with_default_headers(openai_unit_test_env: dict[str, str]) -> None: diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index a6482367e9..5258a2030f 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -98,7 +98,6 @@ def test_init_base_url_from_settings_env() -> None: def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): OpenAIChatClient( - env_file_path="test.env", ) @@ -109,7 +108,6 @@ def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): OpenAIChatClient( model_id=model_id, - env_file_path="test.env", ) diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index 240f09c4a0..3c5beed6af 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -140,7 +140,6 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): OpenAIResponsesClient( - env_file_path="test.env", ) @@ -151,7 +150,6 @@ def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): OpenAIResponsesClient( model_id=model_id, - env_file_path="test.env", ) diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index ece84bc483..747c320db7 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -118,9 +118,9 @@ class FoundryLocalChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModel class FoundryLocalSettings(TypedDict, total=False): """Foundry local model settings. - The settings are first loaded from environment variables with the prefix 'FOUNDRY_LOCAL_'. - If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'FOUNDRY_LOCAL_'. Keys: model_id: The name of the model deployment to use. diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index 88b9fb4260..a9866943d9 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -15,7 +15,7 @@ def test_foundry_local_settings_init_from_env(foundry_local_unit_test_env: dict[str, str]) -> None: """Test FoundryLocalSettings initialization from environment variables.""" - settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", env_file_path="test.env") + settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_") assert settings["model_id"] == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] @@ -26,7 +26,6 @@ def test_foundry_local_settings_init_with_explicit_values() -> None: FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model_id="custom-model-id", - env_file_path="test.env", ) assert settings["model_id"] == "custom-model-id" @@ -40,14 +39,13 @@ def test_foundry_local_settings_missing_model_id(foundry_local_unit_test_env: di FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", required_fields=["model_id"], - env_file_path="test.env", ) def test_foundry_local_settings_explicit_overrides_env(foundry_local_unit_test_env: dict[str, str]) -> None: """Test that explicit values override environment variables.""" settings = load_settings( - FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model_id="override-model-id", env_file_path="test.env" + FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model_id="override-model-id" ) assert settings["model_id"] == "override-model-id" @@ -63,7 +61,7 @@ def test_foundry_local_client_init(mock_foundry_local_manager: MagicMock) -> Non "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - client = FoundryLocalClient(model_id="test-model-id", env_file_path="test.env") + client = FoundryLocalClient(model_id="test-model-id") assert client.model_id == "test-model-id" assert client.manager is mock_foundry_local_manager @@ -76,7 +74,7 @@ def test_foundry_local_client_init_with_bootstrap_false(mock_foundry_local_manag "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ) as mock_manager_class: - FoundryLocalClient(model_id="test-model-id", bootstrap=False, env_file_path="test.env") + FoundryLocalClient(model_id="test-model-id", bootstrap=False) mock_manager_class.assert_called_once_with( bootstrap=False, @@ -90,7 +88,7 @@ def test_foundry_local_client_init_with_timeout(mock_foundry_local_manager: Magi "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ) as mock_manager_class: - FoundryLocalClient(model_id="test-model-id", timeout=60.0, env_file_path="test.env") + FoundryLocalClient(model_id="test-model-id", timeout=60.0) mock_manager_class.assert_called_once_with( bootstrap=True, @@ -109,7 +107,7 @@ def test_foundry_local_client_init_model_not_found(mock_foundry_local_manager: M ), pytest.raises(ServiceInitializationError, match="not found in Foundry Local"), ): - FoundryLocalClient(model_id="unknown-model", env_file_path="test.env") + FoundryLocalClient(model_id="unknown-model") def test_foundry_local_client_uses_model_info_id(mock_foundry_local_manager: MagicMock) -> None: @@ -122,7 +120,7 @@ def test_foundry_local_client_uses_model_info_id(mock_foundry_local_manager: Mag "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - client = FoundryLocalClient(model_id="model-alias", env_file_path="test.env") + client = FoundryLocalClient(model_id="model-alias") assert client.model_id == "resolved-model-id" @@ -135,7 +133,7 @@ def test_foundry_local_client_init_from_env( "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - client = FoundryLocalClient(env_file_path="test.env") + client = FoundryLocalClient() assert client.model_id == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] @@ -148,7 +146,7 @@ def test_foundry_local_client_init_with_device(mock_foundry_local_manager: Magic "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - FoundryLocalClient(model_id="test-model-id", device=DeviceType.CPU, env_file_path="test.env") + FoundryLocalClient(model_id="test-model-id", device=DeviceType.CPU) mock_foundry_local_manager.get_model_info.assert_called_once_with( alias_or_model_id="test-model-id", @@ -177,7 +175,7 @@ def test_foundry_local_client_init_model_not_found_with_device(mock_foundry_loca ), pytest.raises(ServiceInitializationError, match="unknown-model:GPU.*not found"), ): - FoundryLocalClient(model_id="unknown-model", device=DeviceType.GPU, env_file_path="test.env") + FoundryLocalClient(model_id="unknown-model", device=DeviceType.GPU) def test_foundry_local_client_init_with_prepare_model_false(mock_foundry_local_manager: MagicMock) -> None: @@ -186,7 +184,7 @@ def test_foundry_local_client_init_with_prepare_model_false(mock_foundry_local_m "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - FoundryLocalClient(model_id="test-model-id", prepare_model=False, env_file_path="test.env") + FoundryLocalClient(model_id="test-model-id", prepare_model=False) mock_foundry_local_manager.download_model.assert_not_called() mock_foundry_local_manager.load_model.assert_not_called() @@ -198,7 +196,7 @@ def test_foundry_local_client_init_calls_download_and_load(mock_foundry_local_ma "agent_framework_foundry_local._foundry_local_client.FoundryLocalManager", return_value=mock_foundry_local_manager, ): - FoundryLocalClient(model_id="test-model-id", env_file_path="test.env") + FoundryLocalClient(model_id="test-model-id") mock_foundry_local_manager.download_model.assert_called_once_with( alias_or_model_id="test-model-id", diff --git a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py index 606ee48c5d..432427fd9d 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py @@ -2,8 +2,7 @@ import importlib.metadata -from ._agent import GitHubCopilotAgent, GitHubCopilotOptions -from ._settings import GitHubCopilotSettings +from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings try: __version__ = importlib.metadata.version(__name__) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 36a2ee80b7..da28a0e543 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -40,8 +40,6 @@ ) from copilot.types import Tool as CopilotTool -from ._settings import GitHubCopilotSettings - if sys.version_info >= (3, 13): from typing import TypeVar else: @@ -57,6 +55,30 @@ logger = logging.getLogger("agent_framework.github_copilot") +class GitHubCopilotSettings(TypedDict, total=False): + """GitHub Copilot model settings. + + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'GITHUB_COPILOT_'. + + Keys: + cli_path: Path to the Copilot CLI executable. + Can be set via environment variable GITHUB_COPILOT_CLI_PATH. + model: Model to use (e.g., "gpt-5", "claude-sonnet-4"). + Can be set via environment variable GITHUB_COPILOT_MODEL. + timeout: Request timeout in seconds. + Can be set via environment variable GITHUB_COPILOT_TIMEOUT. + log_level: CLI log level. + Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL. + """ + + cli_path: str | None + model: str | None + timeout: float | None + log_level: str | None + + class GitHubCopilotOptions(TypedDict, total=False): """GitHub Copilot-specific options.""" diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py deleted file mode 100644 index 884d23e9a8..0000000000 --- a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import TypedDict - - -class GitHubCopilotSettings(TypedDict, total=False): - """GitHub Copilot model settings. - - The settings are first loaded from environment variables with the prefix 'GITHUB_COPILOT_'. - If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. - - Keys: - cli_path: Path to the Copilot CLI executable. - Can be set via environment variable GITHUB_COPILOT_CLI_PATH. - model: Model to use (e.g., "gpt-5", "claude-sonnet-4"). - Can be set via environment variable GITHUB_COPILOT_MODEL. - timeout: Request timeout in seconds. - Can be set via environment variable GITHUB_COPILOT_TIMEOUT. - log_level: CLI log level. - Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL. - """ - - cli_path: str | None - model: str | None - timeout: float | None - log_level: str | None diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index 14f21332d9..78fb6d620a 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -186,7 +186,6 @@ def test_with_invalid_settings(ollama_unit_test_env: dict[str, str]) -> None: OllamaChatClient( host="http://localhost:12345", model_id=None, - env_file_path="test.env", ) diff --git a/python/samples/02-agents/observability/README.md b/python/samples/02-agents/observability/README.md index b311138714..2a20ec2553 100644 --- a/python/samples/02-agents/observability/README.md +++ b/python/samples/02-agents/observability/README.md @@ -1,4 +1,4 @@ -# Agent Framework Python Observability +# Agent Framework Observability This sample folder shows how a Python application can be configured to send Agent Framework observability data to the Application Performance Management (APM) vendor(s) of your choice based on the OpenTelemetry standard. @@ -222,7 +222,15 @@ This folder contains different samples demonstrating how to use telemetry in var 1. Open a terminal and navigate to this folder: `python/samples/02-agents/observability/`. This is necessary for the `.env` file to be read correctly. 2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example). > **Note**: You can start with just `ENABLE_INSTRUMENTATION=true` and add `OTEL_EXPORTER_OTLP_ENDPOINT` or other configuration as needed. If no exporters are configured, you can set `ENABLE_CONSOLE_EXPORTERS=true` for console output. -3. Activate your python virtual environment, and then run `python configure_otel_providers_with_env_var.py` or others. +3. Choose one environment-loading approach: + - **A. Sample-managed loading (current samples):** run from this folder so the sample's `load_dotenv()` call can find `.env`. + - **B. Shell/IDE-managed environment:** set/export environment variables directly, or use an IDE run configuration that injects env vars / `.env`. + - **C. Explicit env file in code:** pass `env_file_path` to APIs like `configure_otel_providers(env_file_path=".env")` (or your own settings loader path). + - **D. CLI-managed env file:** run with `uv` and pass the file explicitly, for example: + `uv run --env-file=.env python configure_otel_providers_with_env_var.py` +4. Activate your python virtual environment, then run a sample (for example `python configure_otel_providers_with_env_var.py`). + +> If you do manual provider setup (e.g., Azure Monitor), call `enable_instrumentation()` to turn on Agent Framework telemetry code paths; if you want Agent Framework to configure exporters/providers for you, call `configure_otel_providers(...)`. > Each sample will print the Operation/Trace ID, which can be used later for filtering logs and traces in Application Insights or Aspire Dashboard. diff --git a/python/samples/02-agents/observability/advanced_manual_setup_console_output.py b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py index c0bfd7473e..8f02c003cb 100644 --- a/python/samples/02-agents/observability/advanced_manual_setup_console_output.py +++ b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py @@ -5,7 +5,7 @@ from random import randint from typing import Annotated -from agent_framework import tool +from agent_framework import Message, tool from agent_framework.observability import enable_instrumentation from agent_framework.openai import OpenAIChatClient from opentelemetry._logs import set_logger_provider @@ -66,7 +66,9 @@ def setup_metrics(): set_meter_provider(meter_provider) -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. +# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -107,9 +109,9 @@ async def run_chat_client() -> None: message = "What's the weather in Amsterdam and in Paris?" print(f"User: {message}") print("Assistant: ", end="") - async for chunk in client.get_response(message, tools=get_weather, stream=True): - if str(chunk): - print(str(chunk), end="") + async for chunk in client.get_response([Message(role="user", text=message)], tools=get_weather, stream=True): + if chunk.text: + print(chunk.text, end="") print("") diff --git a/python/samples/02-agents/observability/advanced_zero_code.py b/python/samples/02-agents/observability/advanced_zero_code.py index 650a838da8..9ee0b922c5 100644 --- a/python/samples/02-agents/observability/advanced_zero_code.py +++ b/python/samples/02-agents/observability/advanced_zero_code.py @@ -4,7 +4,7 @@ from random import randint from typing import TYPE_CHECKING, Annotated -from agent_framework import tool +from agent_framework import Message, tool from agent_framework.observability import get_tracer from agent_framework.openai import OpenAIResponsesClient from opentelemetry.trace import SpanKind @@ -20,13 +20,14 @@ It relies on the OpenTelemetry auto-instrumentation capabilities, and the observability setup is done via environment variables. -Follow the install guidance from https://opentelemetry.io/docs/zero-code/python/ to install the OpenTelemetry CLI tool. +Follow the install guidance from https://opentelemetry.io/docs/zero-code/python/ to install the OpenTelemetry CLI tool, +when using `uv` there are some additional steps, so follow the instructions carefully. And setup a local OpenTelemetry Collector instance to receive the traces and metrics (and update the endpoint below). Then you can run: ```bash -opentelemetry-enable_instrumentation \ +opentelemetry-instrument \ --traces_exporter otlp \ --metrics_exporter otlp \ --service_name agent_framework \ @@ -40,7 +41,9 @@ """ -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. +# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -81,12 +84,12 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals print(f"User: {message}") if stream: print("Assistant: ", end="") - async for chunk in client.get_response(message, tools=get_weather, stream=True): - if str(chunk): - print(str(chunk), end="") + async for chunk in client.get_response([Message(role="user", text=message)], tools=get_weather, stream=True): + if chunk.text: + print(chunk.text, end="") print("") else: - response = await client.get_response(message, tools=get_weather) + response = await client.get_response([Message(role="user", text=message)], tools=get_weather) print(f"Assistant: {response}") diff --git a/python/samples/02-agents/observability/agent_observability.py b/python/samples/02-agents/observability/agent_observability.py index d7732120ff..772a7d6c7f 100644 --- a/python/samples/02-agents/observability/agent_observability.py +++ b/python/samples/02-agents/observability/agent_observability.py @@ -53,11 +53,7 @@ async def main(): for question in questions: print(f"\nUser: {question}") print(f"{agent.name}: ", end="") - async for update in agent.run( - question, - session=session, - stream=True, - ): + async for update in agent.run(question, session=session, stream=True): if update.text: print(update.text, end="") diff --git a/python/samples/02-agents/observability/agent_with_foundry_tracing.py b/python/samples/02-agents/observability/agent_with_foundry_tracing.py index 345da453b7..00780e8f12 100644 --- a/python/samples/02-agents/observability/agent_with_foundry_tracing.py +++ b/python/samples/02-agents/observability/agent_with_foundry_tracing.py @@ -15,13 +15,13 @@ from random import randint from typing import Annotated -import dotenv from agent_framework import Agent, tool from agent_framework.observability import create_resource, enable_instrumentation, get_tracer from agent_framework.openai import OpenAIResponsesClient from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import AzureCliCredential from azure.monitor.opentelemetry import configure_azure_monitor +from dotenv import load_dotenv from opentelemetry.trace import SpanKind from opentelemetry.trace.span import format_trace_id from pydantic import Field @@ -36,12 +36,14 @@ """ # For loading the `AZURE_AI_PROJECT_ENDPOINT` environment variable -dotenv.load_dotenv() +load_dotenv() logger = logging.getLogger(__name__) -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. +# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/02-agents/observability/azure_ai_agent_observability.py b/python/samples/02-agents/observability/azure_ai_agent_observability.py index d3860f39af..286dba33ec 100644 --- a/python/samples/02-agents/observability/azure_ai_agent_observability.py +++ b/python/samples/02-agents/observability/azure_ai_agent_observability.py @@ -5,12 +5,12 @@ from random import randint from typing import Annotated -import dotenv from agent_framework import Agent, tool from agent_framework.azure import AzureAIClient from agent_framework.observability import get_tracer from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv from opentelemetry.trace import SpanKind from opentelemetry.trace.span import format_trace_id from pydantic import Field @@ -26,10 +26,12 @@ """ # For loading the `AZURE_AI_PROJECT_ENDPOINT` environment variable -dotenv.load_dotenv() +load_dotenv() -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. +# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], diff --git a/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py index 2b79435df5..9c848f1bf8 100644 --- a/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_env_var.py @@ -6,7 +6,7 @@ from random import randint from typing import TYPE_CHECKING, Annotated, Literal -from agent_framework import tool +from agent_framework import Message, tool from agent_framework.observability import configure_otel_providers, get_tracer from agent_framework.openai import OpenAIResponsesClient from opentelemetry import trace @@ -31,7 +31,9 @@ SCENARIOS = ["client", "client_stream", "tool", "all"] -# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +# NOTE: approval_mode="never_require" is for sample brevity. +# Use "always_require" in production; see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. @tool(approval_mode="never_require") async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -71,12 +73,14 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals print(f"User: {message}") if stream: print("Assistant: ", end="") - async for chunk in client.get_response(message, tools=get_weather, stream=True): - if str(chunk): - print(str(chunk), end="") + async for chunk in client.get_response( + [Message(role="user", text=message)], tools=get_weather, stream=True + ): + if chunk.text: + print(chunk.text, end="") print("") else: - response = await client.get_response(message, tools=get_weather) + response = await client.get_response([Message(role="user", text=message)], tools=get_weather) print(f"Assistant: {response}") @@ -92,8 +96,7 @@ async def run_tool() -> None: """ with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT): print("Running scenario: AI Function") - func = tool(get_weather) - weather = await func.invoke(location="Amsterdam") + weather = await get_weather.invoke(location="Amsterdam") print(f"Weather in Amsterdam:\n{weather}") diff --git a/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py index 4ad525d354..e325864996 100644 --- a/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py +++ b/python/samples/02-agents/observability/configure_otel_providers_with_parameters.py @@ -7,7 +7,7 @@ from random import randint from typing import TYPE_CHECKING, Annotated, Literal -from agent_framework import tool +from agent_framework import Message, tool from agent_framework.observability import configure_otel_providers, get_tracer from agent_framework.openai import OpenAIResponsesClient from opentelemetry import trace @@ -74,12 +74,14 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals print(f"User: {message}") if stream: print("Assistant: ", end="") - async for chunk in client.get_response(message, stream=True, tools=get_weather): - if str(chunk): - print(str(chunk), end="") + async for chunk in client.get_response( + [Message(role="user", text=message)], stream=True, tools=get_weather + ): + if chunk.text: + print(chunk.text, end="") print("") else: - response = await client.get_response(message, tools=get_weather) + response = await client.get_response([Message(role="user", text=message)], tools=get_weather) print(f"Assistant: {response}") @@ -95,8 +97,7 @@ async def run_tool() -> None: """ with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT): print("Running scenario: AI Function") - func = tool(get_weather) - weather = await func.invoke(location="Amsterdam") + weather = await get_weather.invoke(location="Amsterdam") print(f"Weather in Amsterdam:\n{weather}") From 40b8a2ddcd2de19b6b64767e7dd9459bd03d06ba Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 18 Feb 2026 11:04:50 +0100 Subject: [PATCH 2/4] fixed some imports --- .../getting_started/test_agent_samples.py | 19 +++++++++---------- .../test_chat_client_samples.py | 1 - .../getting_started/test_threads_samples.py | 1 - 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/python/tests/samples/getting_started/test_agent_samples.py b/python/tests/samples/getting_started/test_agent_samples.py index 1042dafae7..e310521b10 100644 --- a/python/tests/samples/getting_started/test_agent_samples.py +++ b/python/tests/samples/getting_started/test_agent_samples.py @@ -7,16 +7,6 @@ import pytest from pytest import MonkeyPatch, mark, param -from samples.getting_started.agents.azure_ai.azure_ai_with_function_tools import ( - mixed_tools_example as azure_ai_with_function_tools_mixed, -) -from samples.getting_started.agents.azure_ai.azure_ai_with_function_tools import ( - tools_on_agent_level as azure_ai_with_function_tools_agent, -) -from samples.getting_started.agents.azure_ai.azure_ai_with_function_tools import ( - tools_on_run_level as azure_ai_with_function_tools_run, -) - from samples.getting_started.agents.azure_ai.azure_ai_basic import ( main as azure_ai_basic, ) @@ -29,6 +19,15 @@ from samples.getting_started.agents.azure_ai.azure_ai_with_explicit_settings import ( main as azure_ai_with_explicit_settings, ) +from samples.getting_started.agents.azure_ai.azure_ai_with_function_tools import ( + mixed_tools_example as azure_ai_with_function_tools_mixed, +) +from samples.getting_started.agents.azure_ai.azure_ai_with_function_tools import ( + tools_on_agent_level as azure_ai_with_function_tools_agent, +) +from samples.getting_started.agents.azure_ai.azure_ai_with_function_tools import ( + tools_on_run_level as azure_ai_with_function_tools_run, +) from samples.getting_started.agents.azure_ai.azure_ai_with_local_mcp import ( main as azure_ai_with_local_mcp, ) diff --git a/python/tests/samples/getting_started/test_chat_client_samples.py b/python/tests/samples/getting_started/test_chat_client_samples.py index df3c18b6d5..b145ba84e0 100644 --- a/python/tests/samples/getting_started/test_chat_client_samples.py +++ b/python/tests/samples/getting_started/test_chat_client_samples.py @@ -7,7 +7,6 @@ import pytest from pytest import MonkeyPatch, mark, param - from samples.getting_started.client.azure_ai_chat_client import ( main as azure_ai_chat_client, ) diff --git a/python/tests/samples/getting_started/test_threads_samples.py b/python/tests/samples/getting_started/test_threads_samples.py index 51c9103c39..d0630d2181 100644 --- a/python/tests/samples/getting_started/test_threads_samples.py +++ b/python/tests/samples/getting_started/test_threads_samples.py @@ -7,7 +7,6 @@ import pytest from pytest import MonkeyPatch, mark, param - from samples.getting_started.threads.custom_chat_message_store_thread import main as threads_custom_store from samples.getting_started.threads.suspend_resume_thread import main as threads_suspend_resume From a9cdac8d7cf85f938f1e87b42e2836290be52163 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 18 Feb 2026 11:14:15 +0100 Subject: [PATCH 3/4] Fix load_settings CI regressions Allow explicit env_file_path values that exist but are not regular files (for example /dev/null) by checking path existence before dotenv parsing, and restore a dict accumulator with typed return cast to satisfy mypy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py index c6a510c943..326abaaff4 100644 --- a/python/packages/core/agent_framework/_settings.py +++ b/python/packages/core/agent_framework/_settings.py @@ -213,7 +213,7 @@ def load_settings( loaded_dotenv_values: dict[str, str] = {} if env_file_path is not None: - if not os.path.isfile(env_file_path): + if not os.path.exists(env_file_path): raise FileNotFoundError(env_file_path) raw_dotenv_values = dotenv_values(dotenv_path=env_file_path, encoding=encoding) @@ -227,7 +227,7 @@ def load_settings( # Get field type hints from the TypedDict hints = get_type_hints(settings_type) - result: SettingsT = {} + result: dict[str, Any] = {} for field_name, field_type in hints.items(): # 1. Explicit override wins if field_name in overrides: @@ -294,4 +294,4 @@ def load_settings( f"Only one of {all_names} may be provided, but multiple were set: {set_names}." ) - return result + return result # type: ignore[return-value] From 232a430425ba92a9da9fa0d3a027efa1bd304959 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 18 Feb 2026 11:23:34 +0100 Subject: [PATCH 4/4] Avoid implicit dotenv in observability Only load dotenv in observability helpers when env_file_path is explicitly provided, and remove test os.devnull workarounds that are no longer necessary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/observability.py | 14 ++++++++------ python/packages/core/tests/conftest.py | 3 +-- .../core/tests/core/test_observability.py | 17 ++++++++--------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index e10d770b20..8f581a605d 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -440,7 +440,7 @@ def _get_exporters_from_env( Args: env_file_path: Path to a .env file to load environment variables from. - Default is None, which loads from '.env' if present. + Default is None, which does not load a .env file. env_file_encoding: Encoding to use when reading the .env file. Default is None, which uses the system default encoding. @@ -451,8 +451,9 @@ def _get_exporters_from_env( - https://opentelemetry.io/docs/languages/sdk-configuration/general/ - https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/ """ - # Load environment variables from .env file if present - load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding) + # Load environment variables from a .env file only when explicitly provided + if env_file_path is not None: + load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding) # Get base endpoint base_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") @@ -513,7 +514,7 @@ def create_resource( service_version: Override the service version. If not provided, reads from OTEL_SERVICE_VERSION environment variable or defaults to the package version. env_file_path: Path to a .env file to load environment variables from. - Default is None, which loads from '.env' if present. + Default is None, which does not load a .env file. env_file_encoding: Encoding to use when reading the .env file. Default is None, which uses the system default encoding. **attributes: Additional resource attributes to include. These will be merged @@ -541,8 +542,9 @@ def create_resource( # Load from custom .env file resource = create_resource(env_file_path="config/.env") """ - # Load environment variables from .env file if present - load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding) + # Load environment variables from a .env file only when explicitly provided + if env_file_path is not None: + load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding) # Start with provided attributes resource_attributes: dict[str, Any] = dict(attributes) diff --git a/python/packages/core/tests/conftest.py b/python/packages/core/tests/conftest.py index 6feb27bbfc..08f3f07762 100644 --- a/python/packages/core/tests/conftest.py +++ b/python/packages/core/tests/conftest.py @@ -1,6 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -import os from collections.abc import Generator from typing import Any from unittest.mock import patch @@ -62,7 +61,7 @@ def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_da importlib.reload(observability) # recreate observability settings with values from above and no file. - observability_settings = observability.ObservabilitySettings(env_file_path=os.devnull) + observability_settings = observability.ObservabilitySettings() # Configure providers manually without calling _configure() to avoid OTLP imports if enable_instrumentation or enable_sensitive_data: diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 1aa5fc2cb5..fccaf2f9f1 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import logging -import os from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence from typing import Any from unittest.mock import Mock @@ -902,7 +901,7 @@ def test_console_exporters_opt_in_false(monkeypatch): monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "false") monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) - settings = ObservabilitySettings(env_file_path=os.devnull) + settings = ObservabilitySettings() assert settings.enable_console_exporters is False @@ -912,7 +911,7 @@ def test_console_exporters_opt_in_true(monkeypatch): monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true") - settings = ObservabilitySettings(env_file_path=os.devnull) + settings = ObservabilitySettings() assert settings.enable_console_exporters is True @@ -922,7 +921,7 @@ def test_console_exporters_default_false(monkeypatch): monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False) - settings = ObservabilitySettings(env_file_path=os.devnull) + settings = ObservabilitySettings() assert settings.enable_console_exporters is False @@ -997,7 +996,7 @@ def test_observability_settings_is_setup_initial(monkeypatch): from agent_framework.observability import ObservabilitySettings monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) - settings = ObservabilitySettings(env_file_path=os.devnull) + settings = ObservabilitySettings() assert settings.is_setup is False @@ -1453,7 +1452,7 @@ def test_get_exporters_from_env_no_endpoints(monkeypatch): ]: monkeypatch.delenv(key, raising=False) - exporters = _get_exporters_from_env(env_file_path=os.devnull) + exporters = _get_exporters_from_env() assert exporters == [] @@ -1465,7 +1464,7 @@ def test_observability_settings_configure_not_enabled(monkeypatch): from agent_framework.observability import ObservabilitySettings monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") - settings = ObservabilitySettings(env_file_path=os.devnull) + settings = ObservabilitySettings() # Should not raise, should just return early settings._configure() @@ -1486,7 +1485,7 @@ def test_observability_settings_configure_already_setup(monkeypatch): ]: monkeypatch.delenv(key, raising=False) - settings = ObservabilitySettings(env_file_path=os.devnull) + settings = ObservabilitySettings() # Manually mark as set up settings._executed_setup = True @@ -2022,7 +2021,7 @@ def test_configure_providers_with_span_exporters(monkeypatch): ]: monkeypatch.delenv(key, raising=False) - settings = ObservabilitySettings(env_file_path=os.devnull) + settings = ObservabilitySettings() # Create mock span exporter mock_span_exporter = Mock(spec=SpanExporter)