From fed7a98c3b6279f933c5cea4d25ffb0c12692123 Mon Sep 17 00:00:00 2001 From: SamRemis Date: Thu, 6 Nov 2025 00:17:44 -0500 Subject: [PATCH 1/2] Initial commit --- .../src/smithy_aws_core/config/__init__.py | 6 + .../src/smithy_aws_core/config/config.py | 449 ++++++++++++++++++ .../tests/unit/config/__init__.py | 2 + .../tests/unit/config/test_config.py | 338 +++++++++++++ 4 files changed, 795 insertions(+) create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py create mode 100644 packages/smithy-aws-core/src/smithy_aws_core/config/config.py create mode 100644 packages/smithy-aws-core/tests/unit/config/__init__.py create mode 100644 packages/smithy-aws-core/tests/unit/config/test_config.py diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py new file mode 100644 index 000000000..0f5574986 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py @@ -0,0 +1,6 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from .config import AWSClientConfig, ConfigValue + +__all__ = ["AWSClientConfig", "ConfigValue"] diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/config.py b/packages/smithy-aws-core/src/smithy_aws_core/config/config.py new file mode 100644 index 000000000..2c0f3a506 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/config.py @@ -0,0 +1,449 @@ +import asyncio +import configparser +import os +from collections.abc import Awaitable, Callable, Mapping +from pathlib import Path +from typing import Any, ClassVar, Literal + +from smithy_core.aio.interfaces import ( + EndpointResolver, +) +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.interfaces import URI +from smithy_core.interfaces.retries import RetryStrategy +from smithy_core.retries import SimpleRetryStrategy +from smithy_http.interfaces import HTTPRequestConfiguration + +from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties + +SOURCE_CONSTRUCTOR = "constructor" +SOURCE_ENVIRONMENT = "environment" +SOURCE_CREDENTIALS_FILE = "credentials_file" +SOURCE_CONFIG_FILE = "config_file" +SOURCE_DEFAULT = "default" +SOURCE_IN_CODE_UPDATE = "in_code_update" + +SourceType = Literal[ + "constructor", + "environment", + "credentials_file", + "config_file", + "default", + "in_code_update", +] + + +class ConfigValue: + """Configuration value with metadata about its source""" + + def __init__(self, value: Any, source: SourceType): + self.value = value + self.source = source + + +class AWSClientConfig: + """ + AWS Client Configuration with precedence-based resolution. + + The constructor uses explicit parameters with sentinel values (...) to provide + IDE autocomplete while preserving precedence chain detection. The sentinel + value (...) is Python's Ellipsis object, which allows us to distinguish + between "not provided" vs "explicitly set to None". + + HOW TO ADD A NEW CONFIG FIELD: + + 1. Add the parameter to the __init__ method with sentinel default: + my_field: str | None = ..., # type: ignore[assignment] + + 2a. For standard resolution, add to CONFIG_FIELDS dictionary: + "my_field": { + "default": None, # required + "type": str | None, # required - the expected type for type safety + "env_var": "MY_ENV_VAR", # optional environment variable name + "config_key": "my_config_key", # optional config/credentials file key + "validator": "_validate_string" # optional validation method + } + + 2b. For custom resolution, add to CONFIG_FIELDS dictionary AND a custom resolver method: + "my_field": { + "default": None, # required + "type": str | None, # required - the expected type for type safety + "validator": "_validate_string" # optional validation method + # Note: omit env_var and config_key since custom resolver handles resolution + } + + async def _resolve_my_field(self, constructor_values, env_values, + config_file_values, credentials_file_values, + default_value, validator): + # Custom resolution logic here + return ConfigValue(resolved_value, source) + + 3. Add property getter and setter: + @property + def my_field(self) -> str | None: + return self._my_field.value + + @my_field.setter + def my_field(self, value: str | None) -> None: + self._my_field = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + 4. If custom validation is needed, add a validator method: + def _validate_my_field(self, value: Any, field_name: str) -> None: + # Custom validation logic here + """ + + CONFIG_FIELDS: ClassVar[dict[str, dict[str, Any]]] = { + "aws_credentials_identity_resolver": { + "default": None, + "type": IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] + | None, + }, + "endpoint_resolver": { + "default": None, + "type": EndpointResolver | None, + }, + "http_request_config": { + "default": None, + "type": HTTPRequestConfiguration | None, + }, + "retry_strategy": { + "default": SimpleRetryStrategy(), + "type": RetryStrategy, + }, + "aws_access_key_id": { + "env_var": "AWS_ACCESS_KEY_ID", + "config_key": "aws_access_key_id", + "default": None, + "type": str | None, + }, + "aws_secret_access_key": { + "env_var": "AWS_SECRET_ACCESS_KEY", + "config_key": "aws_secret_access_key", + "default": None, + "type": str | None, + }, + "aws_session_token": { + "env_var": "AWS_SESSION_TOKEN", + "config_key": "aws_session_token", + "default": None, + "type": str | None, + }, + "endpoint_uri": { + "env_var": "AWS_ENDPOINT_URL", + "config_key": "endpoint_url", + "default": None, + "validator": "_validate_endpoint_uri", + }, + "region": { + "env_var": "AWS_REGION", + "config_key": "region", + "default": None, + "type": str | None, + }, + "sdk_ua_app_id": { + "default": None, + "type": str | None, + }, + "user_agent_extra": { + "default": None, + "type": str | None, + }, + } + + def __init__( + self, + *, + aws_access_key_id: str | None = ..., # type: ignore[assignment] + aws_secret_access_key: str | None = ..., # type: ignore[assignment] + aws_session_token: str | None = ..., # type: ignore[assignment] + endpoint_uri: str | URI | None = ..., # type: ignore[assignment] + region: str | None = ..., # type: ignore[assignment] + sdk_ua_app_id: str | None = ..., # type: ignore[assignment] + user_agent_extra: str | None = ..., # type: ignore[assignment] + aws_credentials_identity_resolver: IdentityResolver[ + AWSCredentialsIdentity, AWSIdentityProperties + ] + | None = ..., # type: ignore[assignment] + endpoint_resolver: EndpointResolver | None = ..., # type: ignore[assignment] + http_request_config: HTTPRequestConfiguration | None = ..., # type: ignore[assignment] + retry_strategy: RetryStrategy = ..., # type: ignore[assignment] + ): + self._constructor_values = { + k: v for k, v in locals().items() if k != "self" and v is not ... + } + self._resolved = False + + async def resolve( + self, + *, + environment_loader: Callable[[], Awaitable[dict[str, Any]]] | None = None, + config_file_loader: Callable[[], Awaitable[dict[str, Any]]] | None = None, + credentials_file_loader: Callable[[], Awaitable[dict[str, Any]]] | None = None, + ): + """Resolve configuration from all sources + + Args: + environment_loader: Custom environment loader function + config_file_loader: Custom config file loader function + credentials_file_loader: Custom credentials file loader function + """ + + if self._resolved: + raise RuntimeError( + "Config has already been resolved. Multiple calls to resolve() are not allowed." + ) + + env_task = (environment_loader or self._load_environment_values)() + config_task = (config_file_loader or self._load_config_file_values)() + creds_task = (credentials_file_loader or self._load_credentials_file_values)() + + env_values, config_file_values, credentials_file_values = await asyncio.gather( + env_task, config_task, creds_task + ) + + for field_name, field_info in self.CONFIG_FIELDS.items(): + validator = field_info.get("validator") + + resolved_value = await self._resolve_field( + field_name, + self._constructor_values, + env_values, + config_file_values, + credentials_file_values, + field_info["default"], + validator, + ) + setattr(self, f"_{field_name}", resolved_value) + + self._resolved = True + + async def _load_environment_values(self) -> Mapping[str, str]: + return os.environ + + # TODO: implement full config/credential file support + async def _load_config_file_values(self) -> dict[str, Any]: + def _read_config() -> dict[str, str]: + config_path = Path.home() / ".aws" / "config" + if not config_path.exists(): + return {} + + parser = configparser.ConfigParser() + parser.read(config_path) + + profile = os.environ.get("AWS_PROFILE", "default") + section_name = f"profile {profile}" if profile != "default" else "default" + + if section_name not in parser: + return {} + + return dict(parser[section_name]) + + return await asyncio.to_thread(_read_config) + + async def _load_credentials_file_values(self) -> dict[str, Any]: + def _read_credentials() -> dict[str, str]: + credentials_path = Path.home() / ".aws" / "credentials" + if not credentials_path.exists(): + return {} + + parser = configparser.ConfigParser() + parser.read(credentials_path) + + profile = os.environ.get("AWS_PROFILE", "default") + + if profile not in parser: + return {} + + return dict(parser[profile]) + + return await asyncio.to_thread(_read_credentials) + + async def _resolve_field( + self, + field_name: str, + constructor_values: dict[str, Any], + env_values: Mapping[str, Any], + config_file_values: dict[str, Any], + credentials_file_values: dict[str, Any], + default_value: Any, + validator: str | None, + ) -> ConfigValue: + custom_resolver = getattr(self, f"_resolve_{field_name}", None) + if custom_resolver: + return await custom_resolver( + constructor_values, + env_values, + config_file_values, + credentials_file_values, + default_value, + validator, + ) + + field_config = self.CONFIG_FIELDS.get(field_name, {}) + env_var = field_config.get("env_var") + config_key = field_config.get("config_key") + + if field_name in constructor_values: + value = constructor_values[field_name] # type: ignore[reportUnknownVariableType] + source = SOURCE_CONSTRUCTOR + elif env_var and env_var in env_values: + value = env_values[env_var] # type: ignore[reportUnknownVariableType] + source = SOURCE_ENVIRONMENT + elif config_key and config_key in config_file_values: + value = config_file_values[config_key] # type: ignore[reportUnknownVariableType] + source = SOURCE_CONFIG_FILE + elif config_key and config_key in credentials_file_values: + value = credentials_file_values[config_key] # type: ignore[reportUnknownVariableType] + source = SOURCE_CREDENTIALS_FILE + else: + value = default_value # type: ignore[reportUnknownVariableType] + source = SOURCE_DEFAULT + + if validator: + getattr(self, validator)(value, field_name) + else: + expected_type = field_config["type"] + + # Skip type checking for protocol types (they can't be runtime checked) + if self._is_protocol_type(expected_type): + return ConfigValue(value, source) + + if not isinstance(value, expected_type): + actual_name = type(value).__name__ + expected_name = getattr(expected_type, "__name__", str(expected_type)) + raise TypeError( + f"{field_name} must be {expected_name}, got {actual_name}" + ) + + return ConfigValue(value, source) + + def _is_protocol_type(self, type_hint: Any) -> bool: + """Check if a type hint contains protocol types that can't be runtime checked""" + if hasattr(type_hint, "__args__"): + return any(self._is_protocol_type(arg) for arg in type_hint.__args__) + return getattr(type_hint, "_is_protocol", False) + + def _validate_endpoint_uri(self, value: Any, field_name: str) -> None: + if ( + value is not None + and not isinstance(value, str) + and not (hasattr(value, "scheme") and hasattr(value, "host")) + ): + raise TypeError(f"{field_name} must be a string or URI") + + async def _resolve_aws_credentials_identity_resolver( + self, + constructor_values: dict[str, Any], + env_values: dict[str, Any], + config_file_values: dict[str, Any], + credentials_file_values: dict[str, Any], + default_value: Any, + validator: str | None, + ) -> ConfigValue: + if "aws_credentials_identity_resolver" in constructor_values: + return ConfigValue( + constructor_values["aws_credentials_identity_resolver"], + SOURCE_CONSTRUCTOR, + ) + return ConfigValue(default_value, SOURCE_DEFAULT) + + def get_config_value_object(self, field_name: str) -> ConfigValue: + """Get the raw ConfigValue object for a field""" + if not self._resolved: + raise RuntimeError("Config must be resolved before accessing values") + return getattr(self, f"_{field_name}") + + @property + def aws_access_key_id(self) -> str | None: + return self._aws_access_key_id.value + + @aws_access_key_id.setter + def aws_access_key_id(self, value: str | None) -> None: + self._aws_access_key_id = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def aws_credentials_identity_resolver( + self, + ) -> IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None: + return self._aws_credentials_identity_resolver.value + + @aws_credentials_identity_resolver.setter + def aws_credentials_identity_resolver( + self, + value: IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None, + ) -> None: + self._aws_credentials_identity_resolver = ConfigValue( + value, SOURCE_IN_CODE_UPDATE + ) + + @property + def aws_secret_access_key(self) -> str | None: + return self._aws_secret_access_key.value + + @aws_secret_access_key.setter + def aws_secret_access_key(self, value: str | None) -> None: + self._aws_secret_access_key = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def aws_session_token(self) -> str | None: + return self._aws_session_token.value + + @aws_session_token.setter + def aws_session_token(self, value: str | None) -> None: + self._aws_session_token = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def endpoint_resolver(self) -> EndpointResolver | None: + return self._endpoint_resolver.value + + @endpoint_resolver.setter + def endpoint_resolver(self, value: EndpointResolver | None) -> None: + self._endpoint_resolver = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def endpoint_uri(self) -> str | URI | None: + return self._endpoint_uri.value + + @endpoint_uri.setter + def endpoint_uri(self, value: str | URI | None) -> None: + self._endpoint_uri = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def http_request_config(self) -> HTTPRequestConfiguration | None: + return self._http_request_config.value + + @http_request_config.setter + def http_request_config(self, value: HTTPRequestConfiguration | None) -> None: + self._http_request_config = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def region(self) -> str | None: + return self._region.value + + @region.setter + def region(self, value: str | None) -> None: + self._region = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def retry_strategy(self) -> RetryStrategy: + return self._retry_strategy.value + + @retry_strategy.setter + def retry_strategy(self, value: RetryStrategy) -> None: + self._retry_strategy = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def sdk_ua_app_id(self) -> str | None: + return self._sdk_ua_app_id.value + + @sdk_ua_app_id.setter + def sdk_ua_app_id(self, value: str | None) -> None: + self._sdk_ua_app_id = ConfigValue(value, SOURCE_IN_CODE_UPDATE) + + @property + def user_agent_extra(self) -> str | None: + return self._user_agent_extra.value + + @user_agent_extra.setter + def user_agent_extra(self, value: str | None) -> None: + self._user_agent_extra = ConfigValue(value, SOURCE_IN_CODE_UPDATE) diff --git a/packages/smithy-aws-core/tests/unit/config/__init__.py b/packages/smithy-aws-core/tests/unit/config/__init__.py new file mode 100644 index 000000000..33cbe867a --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-aws-core/tests/unit/config/test_config.py b/packages/smithy-aws-core/tests/unit/config/test_config.py new file mode 100644 index 000000000..d64693850 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_config.py @@ -0,0 +1,338 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import os +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from smithy_aws_core.config.config import ( + SOURCE_CONFIG_FILE, + SOURCE_CONSTRUCTOR, + SOURCE_CREDENTIALS_FILE, + SOURCE_DEFAULT, + SOURCE_ENVIRONMENT, + SOURCE_IN_CODE_UPDATE, + AWSClientConfig, +) + + +@pytest.fixture +async def empty_loaders() -> dict[str, Callable[[], Awaitable[dict[str, Any]]]]: + """Fixture providing empty loaders for testing defaults""" + + async def empty_env_loader() -> dict[str, Any]: + return {} + + async def empty_config_loader() -> dict[str, Any]: + return {} + + async def empty_credentials_loader() -> dict[str, Any]: + return {} + + return { + "environment_loader": empty_env_loader, + "config_file_loader": empty_config_loader, + "credentials_file_loader": empty_credentials_loader, + } + + +class TestAWSClientConfig: + @pytest.mark.asyncio + async def test_basic_resolve(self): + config = AWSClientConfig(region="us-east-1") + await config.resolve() + assert config.region == "us-east-1" + assert config.get_config_value_object("region").source == SOURCE_CONSTRUCTOR + + @pytest.mark.asyncio + async def test_resolve_with_defaults( + self, empty_loaders: dict[str, Callable[[], Awaitable[dict[str, Any]]]] + ): + config = AWSClientConfig() + await config.resolve(**empty_loaders) + assert config.region is None + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "field_name,env_var,value", + [ + ("aws_access_key_id", "AWS_ACCESS_KEY_ID", "AKIATEST"), + ("aws_secret_access_key", "AWS_SECRET_ACCESS_KEY", "secret123"), + ("aws_session_token", "AWS_SESSION_TOKEN", "token456"), + ("region", "AWS_REGION", "us-west-2"), + ("endpoint_uri", "AWS_ENDPOINT_URL", "https://example.com"), + ], + ) + async def test_environment_precedence( + self, field_name: str, env_var: str, value: str + ): + with patch.dict(os.environ, {env_var: value}, clear=False): + config = AWSClientConfig() + await config.resolve() + assert getattr(config, field_name) == value + assert ( + config.get_config_value_object(field_name).source == SOURCE_ENVIRONMENT + ) + + @pytest.mark.asyncio + async def test_config_file_precedence(self): + async def mock_config_loader(): + return {"region": "us-central-1", "aws_access_key_id": "AKIACONFIG"} + + config = AWSClientConfig() + await config.resolve(config_file_loader=mock_config_loader) + assert config.region == "us-central-1" + assert config.aws_access_key_id == "AKIACONFIG" + assert config.get_config_value_object("region").source == SOURCE_CONFIG_FILE + + @pytest.mark.asyncio + async def test_credentials_file_precedence(self): + async def mock_credentials_loader(): + return { + "aws_access_key_id": "AKIACREDS", + "aws_secret_access_key": "credsecret", + } + + config = AWSClientConfig() + await config.resolve(credentials_file_loader=mock_credentials_loader) + assert config.aws_access_key_id == "AKIACREDS" + assert config.aws_secret_access_key == "credsecret" + assert ( + config.get_config_value_object("aws_access_key_id").source + == SOURCE_CREDENTIALS_FILE + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "constructor_value,env_value,config_value,creds_value,expected_value,expected_source", + [ + ( + "CONSTRUCTOR", + "ENV", + "CONFIG", + "CREDS", + "CONSTRUCTOR", + SOURCE_CONSTRUCTOR, + ), + (None, "ENV", "CONFIG", "CREDS", "ENV", SOURCE_ENVIRONMENT), + (None, None, "CONFIG", "CREDS", "CONFIG", SOURCE_CONFIG_FILE), + (None, None, None, "CREDS", "CREDS", SOURCE_CREDENTIALS_FILE), + (None, None, None, None, None, SOURCE_DEFAULT), + ], + ) + async def test_precedence_chain( + self, + constructor_value: str | None, + env_value: str | None, + config_value: str | None, + creds_value: str | None, + expected_value: str | None, + expected_source: str, + ): + """Test precedence: constructor > env > config > credentials > default""" + + async def env_loader(): + return {"AWS_REGION": env_value} if env_value else {} + + async def config_loader(): + return {"region": config_value} if config_value else {} + + async def creds_loader(): + return {"region": creds_value} if creds_value else {} + + kwargs = { + "environment_loader": env_loader, + "config_file_loader": config_loader, + "credentials_file_loader": creds_loader, + } + + config_kwargs: dict[str, Any] = {} + if constructor_value: + config_kwargs["region"] = constructor_value + + config = AWSClientConfig(**config_kwargs) + await config.resolve(**kwargs) + assert config.region == expected_value + assert config.get_config_value_object("region").source == expected_source + + @pytest.mark.asyncio + async def test_custom_loaders(self): + async def custom_env_loader(): + return {"AWS_REGION": "custom-env"} + + async def custom_config_loader(): + return {"region": "custom-config"} + + config = AWSClientConfig() + await config.resolve( + environment_loader=custom_env_loader, + config_file_loader=custom_config_loader, + ) + assert config.region == "custom-env" + + @pytest.mark.asyncio + async def test_multiple_resolve_calls_raise_error( + self, empty_loaders: dict[str, Callable[[], Awaitable[dict[str, Any]]]] + ): + config = AWSClientConfig(region="us-west-2") + + await config.resolve(**empty_loaders) + assert config.region == "us-west-2" + + with pytest.raises(RuntimeError, match="Config has already been resolved"): + await config.resolve(**empty_loaders) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "field_name,invalid_value,expected_error", + [ + ("aws_access_key_id", 123, "must be str \\| None, got int"), + ("region", 456, "must be str \\| None, got int"), + ("endpoint_uri", 123, "must be a string or URI"), + ], + ) + async def test_validation_errors( + self, field_name: str, invalid_value: Any, expected_error: str + ): + kwargs: dict[str, Any] = {field_name: invalid_value} + config = AWSClientConfig(**kwargs) + with pytest.raises(TypeError, match=expected_error): + await config.resolve() + + @pytest.mark.asyncio + async def test_endpoint_uri_valid_string(self): + config = AWSClientConfig(endpoint_uri="https://example.com") + await config.resolve() + assert config.endpoint_uri == "https://example.com" + + def test_unresolved_config_errors(self): + config = AWSClientConfig() + + with pytest.raises( + RuntimeError, match="Config must be resolved before accessing values" + ): + config.get_config_value_object("region") + + @pytest.mark.asyncio + async def test_property_setters(self): + config = AWSClientConfig(region="us-east-1") + await config.resolve() + + config.region = "us-west-2" + assert config.region == "us-west-2" + assert config.get_config_value_object("region").source == SOURCE_IN_CODE_UPDATE + + @pytest.mark.asyncio + async def test_custom_resolver_aws_credentials_identity_resolver(self): + mock_resolver = AsyncMock() + config = AWSClientConfig(aws_credentials_identity_resolver=mock_resolver) + await config.resolve() + + assert config.aws_credentials_identity_resolver is mock_resolver + assert ( + config.get_config_value_object("aws_credentials_identity_resolver").source + == SOURCE_CONSTRUCTOR + ) + + @pytest.mark.asyncio + async def test_custom_resolver_aws_credentials_fallback_to_default( + self, empty_loaders: dict[str, Callable[[], Awaitable[dict[str, Any]]]] + ): + config = AWSClientConfig() + await config.resolve(**empty_loaders) + assert config.aws_credentials_identity_resolver is None + assert ( + config.get_config_value_object("aws_credentials_identity_resolver").source + == SOURCE_DEFAULT + ) + + def test_config_fields_no_none_values(self): + for field_name, field_info in AWSClientConfig.CONFIG_FIELDS.items(): + for key in ["validator", "env_var", "config_key"]: + if key in field_info: + assert field_info[key] is not None, ( + f"Field {field_name} has {key} set to None - it should be omitted entirely" + ) + + def test_config_fields_env_var_and_config_key_valid(self): + for field_name, field_info in AWSClientConfig.CONFIG_FIELDS.items(): + if "env_var" in field_info: + env_var = field_info["env_var"] + assert isinstance(env_var, str) and len(env_var) > 0, ( + f"Field {field_name} has invalid env_var: {env_var!r} - should be non-empty string or omitted entirely" + ) + + if "config_key" in field_info: + config_key = field_info["config_key"] + assert isinstance(config_key, str) and len(config_key) > 0, ( + f"Field {field_name} has invalid config_key: {config_key!r} - should be non-empty string or omitted entirely" + ) + + @pytest.mark.asyncio + async def test_config_file_loader_with_profile(self): + async def mock_config_loader(): + return {"region": "test-region"} + + with patch.dict(os.environ, {"AWS_PROFILE": "test"}, clear=False): + config = AWSClientConfig() + await config.resolve(config_file_loader=mock_config_loader) + assert config.region == "test-region" + + @pytest.mark.asyncio + async def test_all_properties_accessible( + self, empty_loaders: dict[str, Callable[[], Awaitable[dict[str, Any]]]] + ): + """Test that all CONFIG_FIELDS have working property accessors""" + config = AWSClientConfig() + await config.resolve(**empty_loaders) + + for field_name in AWSClientConfig.CONFIG_FIELDS: + value = getattr(config, field_name) + setattr(config, field_name, value) + assert getattr(config, field_name) == value + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "field_name,valid_value", + [ + ("region", "us-east-1"), + ("region", None), + ("aws_access_key_id", "AKIATEST"), + ("aws_access_key_id", None), + ("aws_secret_access_key", "secret"), + ("aws_session_token", "token"), + ("endpoint_uri", "https://example.com"), + ("sdk_ua_app_id", "my-app"), + ("user_agent_extra", "extra"), + ], + ) + async def test_type_validation_success(self, field_name: str, valid_value: Any): + """Test that valid values pass type validation""" + kwargs = {field_name: valid_value} + config = AWSClientConfig(**kwargs) + await config.resolve() + assert getattr(config, field_name) == valid_value + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "field_name,invalid_value,expected_error", + [ + ("region", 123, "must be str \\| None, got int"), + ("aws_access_key_id", 456, "must be str \\| None, got int"), + ("aws_secret_access_key", True, "must be str \\| None, got bool"), + ("aws_session_token", [], "must be str \\| None, got list"), + ("endpoint_uri", 789, "must be a string or URI"), + ("sdk_ua_app_id", {}, "must be str \\| None, got dict"), + ("user_agent_extra", 3.14, "must be str \\| None, got float"), + ], + ) + async def test_type_validation_errors( + self, field_name: str, invalid_value: Any, expected_error: str + ): + kwargs = {field_name: invalid_value} + config = AWSClientConfig(**kwargs) + with pytest.raises(TypeError, match=expected_error): + await config.resolve() From 8b8d874e05ceb46bee9511a8a6c08ac9d6f63193 Mon Sep 17 00:00:00 2001 From: SamRemis Date: Thu, 6 Nov 2025 00:37:19 -0500 Subject: [PATCH 2/2] Update config.py --- packages/smithy-aws-core/src/smithy_aws_core/config/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/config.py b/packages/smithy-aws-core/src/smithy_aws_core/config/config.py index 2c0f3a506..3e66d0ecb 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/config.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/config.py @@ -50,7 +50,7 @@ class AWSClientConfig: value (...) is Python's Ellipsis object, which allows us to distinguish between "not provided" vs "explicitly set to None". - HOW TO ADD A NEW CONFIG FIELD: + How to add a new config field: 1. Add the parameter to the __init__ method with sentinel default: my_field: str | None = ..., # type: ignore[assignment]