Python: Replace Pydantic Settings with TypedDict + load_settings() (#3843)

* Replace Pydantic Settings with TypedDict + load_settings()

- Remove pydantic-settings dependency, add python-dotenv
- Delete _pydantic.py (AFBaseSettings, HTTPsUrl)
- Add _settings.py with generic load_settings() function, SecretString,
  type coercion, and Required field validation (SettingNotFoundError)
- Convert all 13 settings classes from AFBaseSettings subclasses to
  TypedDict definitions with load_settings() calls
- Update all consumers from attribute access to dict access
- Add 20 unit tests for load_settings() covering basic loading, dotenv,
  SecretString, type coercion, and required field validation
- Update all existing tests for new settings patterns

* Fix mypy type errors from settings conversion

- Fix str | None attribute access in responses_client (walrus operator)
- Fix SecretString | None narrowing in bedrock (type: ignore after guard)
- Convert _context_provider.py attribute access to dict access (missed file)
- Fix endpoint type narrowing in search_provider and context_provider
- Fix purview: str | None .rstrip(), int | None defaults, urlparse bytes

* Address PR review: required_fields param, type validation, fixes

- Move required field validation from TypedDict annotations (Required)
  to a required_fields parameter on load_settings(), enabling runtime
  decisions about which fields are required
- Remove Required imports and restore from __future__ import annotations
  in ollama and foundry_local
- Add _check_override_type() for deterministic ServiceInitializationError
  on invalid override types (e.g. dict passed for str field)
- Fix all multi-exception test catches back to single exception type
- Fix Ollama host=None: use .get() so None is passed through to SDK default
- Fix Purview processor: use explicit is-None checks instead of or operator
- Remove unused BaseModel import from openai/_shared.py
- Add 4 new tests (24 total): required_fields param, type validation

* Fix type validation: allow int for float fields

_check_override_type now permits int values for float-typed fields,
matching Python's standard numeric promotion behavior.

* fix: wrap urlparse arg with str() to fix mypy bytes endswith error
This commit is contained in:
Eduard van Valkenburg
2026-02-12 09:51:20 +01:00
committed by GitHub
Unverified
parent b488158abe
commit 8457533c69
58 changed files with 1526 additions and 1113 deletions
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import AsyncIterable, Awaitable, Sequence
from typing import Any, ClassVar, Literal, overload
from typing import Any, Literal, TypedDict, overload
from agent_framework import (
AgentMiddlewareTypes,
@@ -17,23 +17,21 @@ from agent_framework import (
ResponseStream,
normalize_messages,
)
from agent_framework._pydantic import AFBaseSettings
from agent_framework._settings import load_settings
from agent_framework.exceptions import ServiceException, ServiceInitializationError
from microsoft_agents.copilotstudio.client import AgentType, ConnectionSettings, CopilotClient, PowerPlatformCloud
from pydantic import ValidationError
from ._acquire_token import acquire_token
class CopilotStudioSettings(AFBaseSettings):
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'. If the settings are not found in the .env file, the settings
are ignored; however, validation will fail alerting that the settings are missing.
with the encoding 'utf-8'.
Keyword Args:
Keys:
environmentid: Environment ID of environment with the Copilot Studio App.
Can be set via environment variable COPILOTSTUDIOAGENT__ENVIRONMENTID.
schemaname: The agent identifier or schema name of the Copilot to use.
@@ -42,32 +40,12 @@ class CopilotStudioSettings(AFBaseSettings):
Can be set via environment variable COPILOTSTUDIOAGENT__AGENTAPPID.
tenantid: The tenant ID of the App Registration used to login.
Can be set via environment variable COPILOTSTUDIOAGENT__TENANTID.
env_file_path: If provided, the .env settings are read from this file path location.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
from agent_framework_copilotstudio import CopilotStudioSettings
# Using environment variables
# Set COPILOTSTUDIOAGENT__ENVIRONMENTID=env-123
# Set COPILOTSTUDIOAGENT__SCHEMANAME=my-agent
settings = CopilotStudioSettings()
# Or passing parameters directly
settings = CopilotStudioSettings(environmentid="env-123", schemaname="my-agent")
# Or loading from a .env file
settings = CopilotStudioSettings(env_file_path="path/to/.env")
"""
env_prefix: ClassVar[str] = "COPILOTSTUDIOAGENT__"
environmentid: str | None = None
schemaname: str | None = None
agentappid: str | None = None
tenantid: str | None = None
environmentid: str | None
schemaname: str | None
agentappid: str | None
tenantid: str | None
class CopilotStudioAgent(BaseAgent):
@@ -144,54 +122,53 @@ class CopilotStudioAgent(BaseAgent):
middleware=middleware,
)
if not client:
try:
copilot_studio_settings = CopilotStudioSettings(
environmentid=environment_id,
schemaname=agent_identifier,
agentappid=client_id,
tenantid=tenant_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create Copilot Studio settings.", ex) from ex
copilot_studio_settings = load_settings(
CopilotStudioSettings,
env_prefix="COPILOTSTUDIOAGENT__",
environmentid=environment_id,
schemaname=agent_identifier,
agentappid=client_id,
tenantid=tenant_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
if not settings:
if not copilot_studio_settings.environmentid:
if not copilot_studio_settings["environmentid"]:
raise ServiceInitializationError(
"Copilot Studio environment ID is required. Set via 'environment_id' parameter "
"or 'COPILOTSTUDIOAGENT__ENVIRONMENTID' environment variable."
)
if not copilot_studio_settings.schemaname:
if not copilot_studio_settings["schemaname"]:
raise ServiceInitializationError(
"Copilot Studio agent identifier/schema name is required. Set via 'agent_identifier' parameter "
"or 'COPILOTSTUDIOAGENT__SCHEMANAME' environment variable."
)
settings = ConnectionSettings(
environment_id=copilot_studio_settings.environmentid,
agent_identifier=copilot_studio_settings.schemaname,
environment_id=copilot_studio_settings["environmentid"],
agent_identifier=copilot_studio_settings["schemaname"],
cloud=cloud,
copilot_agent_type=agent_type,
custom_power_platform_cloud=custom_power_platform_cloud,
)
if not token:
if not copilot_studio_settings.agentappid:
if not copilot_studio_settings["agentappid"]:
raise ServiceInitializationError(
"Copilot Studio client ID is required. Set via 'client_id' parameter "
"or 'COPILOTSTUDIOAGENT__AGENTAPPID' environment variable."
)
if not copilot_studio_settings.tenantid:
if not copilot_studio_settings["tenantid"]:
raise ServiceInitializationError(
"Copilot Studio tenant ID is required. Set via 'tenant_id' parameter "
"or 'COPILOTSTUDIOAGENT__TENANTID' environment variable."
)
token = acquire_token(
client_id=copilot_studio_settings.agentappid,
tenant_id=copilot_studio_settings.tenantid,
client_id=copilot_studio_settings["agentappid"],
tenant_id=copilot_studio_settings["tenantid"],
username=username,
token_cache=token_cache,
scopes=scopes,
@@ -38,49 +38,57 @@ class TestCopilotStudioAgent:
return MagicMock(spec=CopilotClient)
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
@patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
def test_init_missing_environment_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
@patch("agent_framework_copilotstudio._agent.load_settings")
def test_init_missing_environment_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
mock_settings.return_value.environmentid = None
mock_settings.return_value.schemaname = "test-bot"
mock_settings.return_value.tenantid = "test-tenant"
mock_settings.return_value.agentappid = "test-client"
mock_load_settings.return_value = {
"environmentid": None,
"schemaname": "test-bot",
"tenantid": "test-tenant",
"agentappid": "test-client",
}
with pytest.raises(ServiceInitializationError, match="environment ID is required"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
@patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
def test_init_missing_bot_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
@patch("agent_framework_copilotstudio._agent.load_settings")
def test_init_missing_bot_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
mock_settings.return_value.environmentid = "test-env"
mock_settings.return_value.schemaname = None
mock_settings.return_value.tenantid = "test-tenant"
mock_settings.return_value.agentappid = "test-client"
mock_load_settings.return_value = {
"environmentid": "test-env",
"schemaname": None,
"tenantid": "test-tenant",
"agentappid": "test-client",
}
with pytest.raises(ServiceInitializationError, match="agent identifier"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
@patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
def test_init_missing_tenant_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
@patch("agent_framework_copilotstudio._agent.load_settings")
def test_init_missing_tenant_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
mock_settings.return_value.environmentid = "test-env"
mock_settings.return_value.schemaname = "test-bot"
mock_settings.return_value.tenantid = None
mock_settings.return_value.agentappid = "test-client"
mock_load_settings.return_value = {
"environmentid": "test-env",
"schemaname": "test-bot",
"tenantid": None,
"agentappid": "test-client",
}
with pytest.raises(ServiceInitializationError, match="tenant ID is required"):
CopilotStudioAgent()
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
@patch("agent_framework_copilotstudio._agent.CopilotStudioSettings")
def test_init_missing_client_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
@patch("agent_framework_copilotstudio._agent.load_settings")
def test_init_missing_client_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
mock_settings.return_value.environmentid = "test-env"
mock_settings.return_value.schemaname = "test-bot"
mock_settings.return_value.tenantid = "test-tenant"
mock_settings.return_value.agentappid = None
mock_load_settings.return_value = {
"environmentid": "test-env",
"schemaname": "test-bot",
"tenantid": "test-tenant",
"agentappid": None,
}
with pytest.raises(ServiceInitializationError, match="client ID is required"):
CopilotStudioAgent()
@@ -93,11 +101,13 @@ class TestCopilotStudioAgent:
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
def test_init_empty_environment_id(self, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
with patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") as mock_settings:
mock_settings.return_value.environmentid = ""
mock_settings.return_value.schemaname = "test-bot"
mock_settings.return_value.tenantid = "test-tenant"
mock_settings.return_value.agentappid = "test-client"
with patch("agent_framework_copilotstudio._agent.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"environmentid": "",
"schemaname": "test-bot",
"tenantid": "test-tenant",
"agentappid": "test-client",
}
with pytest.raises(ServiceInitializationError, match="environment ID is required"):
CopilotStudioAgent()
@@ -105,11 +115,13 @@ class TestCopilotStudioAgent:
@patch("agent_framework_copilotstudio._acquire_token.acquire_token")
def test_init_empty_schema_name(self, mock_acquire_token: MagicMock) -> None:
mock_acquire_token.return_value = "fake-token"
with patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") as mock_settings:
mock_settings.return_value.environmentid = "test-env"
mock_settings.return_value.schemaname = ""
mock_settings.return_value.tenantid = "test-tenant"
mock_settings.return_value.agentappid = "test-client"
with patch("agent_framework_copilotstudio._agent.load_settings") as mock_load_settings:
mock_load_settings.return_value = {
"environmentid": "test-env",
"schemaname": "",
"tenantid": "test-tenant",
"agentappid": "test-client",
}
with pytest.raises(ServiceInitializationError, match="agent identifier"):
CopilotStudioAgent()