diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/custom_resolvers.py b/packages/smithy-aws-core/src/smithy_aws_core/config/custom_resolvers.py new file mode 100644 index 00000000..67557069 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/custom_resolvers.py @@ -0,0 +1,50 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from smithy_core.config.resolver import ConfigResolver +from smithy_core.retries import RetryStrategyOptions + +from smithy_aws_core.config.validators import validate_max_attempts, validate_retry_mode + + +def resolve_retry_strategy( + resolver: ConfigResolver, +) -> tuple[RetryStrategyOptions | None, str | None]: + """Resolve retry strategy from multiple config keys. + + Resolves both retry_mode and max_attempts from sources and constructs + a RetryStrategyOptions object. This allows the retry strategy to be + configured from multiple sources. Example: retry_mode from config file and + max_attempts from environment variables. + + :param resolver: The config resolver to use for resolution + + :returns: Tuple of (RetryStrategyOptions, source_name) if both retry_mode and max_attempts + are resolved. Returns (None, None) if both values are missing. + + For mixed sources, the source name includes both component sources: + "retry_mode=environment, max_attempts=config_file" + """ + + retry_mode, mode_source = resolver.get("retry_mode") + + max_attempts, attempts_source = resolver.get("max_attempts") + + if retry_mode is None and max_attempts is None: + return None, None + + if retry_mode is not None: + retry_mode = validate_retry_mode(retry_mode, mode_source) + + if max_attempts is not None: + max_attempts = validate_max_attempts(max_attempts, attempts_source) + + options = RetryStrategyOptions( + retry_mode=retry_mode, # type: ignore + max_attempts=max_attempts, # Can be None because strategy will use its default + ) + + # Construct mixed source string showing where each component came from + source = f"retry_mode={mode_source or 'default'}, max_attempts={attempts_source or 'default'}" + + return (options, source) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py b/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py new file mode 100644 index 00000000..65f9a536 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py @@ -0,0 +1,126 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import re +from typing import Any, get_args + +from smithy_core.interfaces.retries import RetryStrategy +from smithy_core.retries import RetryStrategyOptions, RetryStrategyType + + +class ConfigValidationError(ValueError): + """Raised when a configuration value fails validation.""" + + def __init__(self, key: str, value: Any, reason: str, source: str | None = None): + self.key = key + self.value = value + self.reason = reason + self.source = source + + msg = f"Invalid value for '{key}': {value!r}. {reason}" + if source: + msg += f" (from source: {source})" + super().__init__(msg) + + +def validate_region(region: str, source: str | None = None) -> str: + """Validate region name format. + + :param region: The value to validate + :param source: The config source that provided this value + + :returns: The validated value + + :raises ConfigValidationError: If the value format is invalid + """ + pattern = r"^(?![0-9]+$)(?!-)[a-zA-Z0-9-]{1,63}(? str: + """Validate retry mode. + + Valid values: 'standard', 'simple' + + :param retry_mode: The retry mode value to validate + :param source: The source that provided this value + + :returns: The validated retry mode string + + :raises: ConfigValidationError: If the retry mode is invalid + """ + + valid_modes = get_args(RetryStrategyType) + + if retry_mode not in valid_modes: + raise ConfigValidationError( + "retry_mode", + retry_mode, + f"retry_mode must be one of {valid_modes}, got {retry_mode}", + source, + ) + + return retry_mode + + +def validate_max_attempts(max_attempts: str | int, source: str | None = None) -> int: + """Validate and convert max_attempts to integer. + + :param max_attempts: The max attempts value (string or int) + :param source: The source that provided this value + + :returns: The validated max_attempts as an integer + + :raises ConfigValidationError: If the value is less than 1 or cannot be converted to an integer + """ + try: + max_attempts = int(max_attempts) + except (ValueError, TypeError): + raise ConfigValidationError( + "max_attempts", + max_attempts, + f"max_attempts must be a number, got {type(max_attempts).__name__}", + source, + ) + + if max_attempts < 1: + raise ConfigValidationError( + "max_attempts", + max_attempts, + f"max_attempts must be a positive integer, got {max_attempts}", + source, + ) + + return max_attempts + + +def validate_retry_strategy( + value: Any, source: str | None = None +) -> RetryStrategy | RetryStrategyOptions: + """Validate retry strategy configuration. + + :param value: The retry strategy value to validate + :param source: The source that provided this value + + :returns: The validated retry strategy (RetryStrategy or RetryStrategyOptions) + + :raises: ConfigValidationError: If the value is not a valid retry strategy type + """ + + if isinstance(value, RetryStrategy | RetryStrategyOptions): + return value + + raise ConfigValidationError( + "retry_strategy", + value, + f"retry_strategy must be RetryStrategy or RetryStrategyOptions, got {type(value).__name__}", + source, + ) diff --git a/packages/smithy-aws-core/tests/unit/config/test_custom_resolver.py b/packages/smithy-aws-core/tests/unit/config/test_custom_resolver.py new file mode 100644 index 00000000..c9e7d23e --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_custom_resolver.py @@ -0,0 +1,96 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any + +from smithy_aws_core.config.custom_resolvers import resolve_retry_strategy +from smithy_core.config.resolver import ConfigResolver +from smithy_core.retries import RetryStrategyOptions + + +class StubSource: + """A simple ConfigSource implementation for testing.""" + + def __init__(self, source_name: str, data: dict[str, Any] | None = None) -> None: + self._name = source_name + self._data = data or {} + + @property + def name(self) -> str: + return self._name + + def get(self, key: str) -> Any | None: + return self._data.get(key) + + +class TestResolveCustomResolverRetryStrategy: + """Test suite for complex configuration resolution""" + + def test_resolves_from_both_values(self) -> None: + # When both retry mode and max attempts are set + # It should use source names for both values + source = StubSource( + "environment", {"retry_mode": "standard", "max_attempts": "3"} + ) + resolver = ConfigResolver(sources=[source]) + + result, source_name = resolve_retry_strategy(resolver) + + assert isinstance(result, RetryStrategyOptions) + assert result.retry_mode == "standard" + assert result.max_attempts == 3 + assert source_name == "retry_mode=environment, max_attempts=environment" + + def test_tracks_different_sources_for_each_component(self) -> None: + source1 = StubSource("environment", {"retry_mode": "standard"}) + source2 = StubSource("config_file", {"max_attempts": "5"}) + resolver = ConfigResolver(sources=[source1, source2]) + + result, source_name = resolve_retry_strategy(resolver) + + assert isinstance(result, RetryStrategyOptions) + assert result.retry_mode == "standard" + assert result.max_attempts == 5 + assert source_name == "retry_mode=environment, max_attempts=config_file" + + def test_converts_max_attempts_string_to_int(self) -> None: + source = StubSource( + "environment", {"max_attempts": "10", "retry_mode": "standard"} + ) + resolver = ConfigResolver(sources=[source]) + + result, _ = resolve_retry_strategy(resolver) + + assert isinstance(result, RetryStrategyOptions) + assert result.max_attempts == 10 + assert isinstance(result.max_attempts, int) + + def test_returns_strategy_when_only_retry_mode_set(self) -> None: + source = StubSource("environment", {"retry_mode": "standard"}) + resolver = ConfigResolver(sources=[source]) + + result, source_name = resolve_retry_strategy(resolver) + + assert isinstance(result, RetryStrategyOptions) + assert result.retry_mode == "standard" + assert result.max_attempts is None + assert source_name == "retry_mode=environment, max_attempts=default" + + def test_returns_strategy_when_only_max_attempts_set(self) -> None: + source = StubSource("environment", {"max_attempts": "5"}) + resolver = ConfigResolver(sources=[source]) + + result, source_name = resolve_retry_strategy(resolver) + + assert isinstance(result, RetryStrategyOptions) + assert result.max_attempts == 5 + assert source_name == "retry_mode=default, max_attempts=environment" + + def test_returns_none_when_both_values_missing(self) -> None: + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + + result, source_name = resolve_retry_strategy(resolver) + + assert result is None + assert source_name is None diff --git a/packages/smithy-aws-core/tests/unit/config/test_validators.py b/packages/smithy-aws-core/tests/unit/config/test_validators.py new file mode 100644 index 00000000..2ce809c9 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_validators.py @@ -0,0 +1,51 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from typing import Any + +import pytest +from smithy_aws_core.config.validators import ( + ConfigValidationError, + validate_max_attempts, + validate_region, + validate_retry_mode, +) + + +class TestValidators: + @pytest.mark.parametrize("region", ["us-east-1", "eu-west-1", "ap-south-1"]) + def test_validate_region_accepts_valid_values(self, region: str) -> None: + assert validate_region(region) == region + + @pytest.mark.parametrize("invalid", ["-invalid", "-east", "12345", ""]) + def test_validate_region_rejects_invalid_values(self, invalid: str) -> None: + with pytest.raises(ConfigValidationError): + validate_region(invalid) + + @pytest.mark.parametrize("mode", ["standard", "simple"]) + def test_validate_retry_mode_accepts_valid_values(self, mode: str) -> None: + assert validate_retry_mode(mode) == mode + + @pytest.mark.parametrize("invalid_mode", ["some_retry", "some_retry_one", ""]) + def test_validate_retry_mode_rejects_invalid_values( + self, invalid_mode: str + ) -> None: + with pytest.raises(ConfigValidationError): + validate_retry_mode(invalid_mode) + + @pytest.mark.parametrize("invalid_max_attempts", ["abcd", 0, -1]) + def test_validate_invalid_max_attempts_raises_error( + self, invalid_max_attempts: Any + ) -> None: + with pytest.raises( + ConfigValidationError, + match=r"(max_attempts must be a number|max_attempts must be a positive integer)", + ): + validate_max_attempts(invalid_max_attempts) + + def test_invalid_retry_mode_error_message(self) -> None: + with pytest.raises(ConfigValidationError) as exc_info: + validate_retry_mode("random_mode") + assert ( + "Invalid value for 'retry_mode': 'random_mode'. retry_mode must be one " + "of ('simple', 'standard'), got random_mode" in str(exc_info.value) + ) diff --git a/packages/smithy-core/src/smithy_core/config/property.py b/packages/smithy-core/src/smithy_core/config/property.py new file mode 100644 index 00000000..71a3f413 --- /dev/null +++ b/packages/smithy-core/src/smithy_core/config/property.py @@ -0,0 +1,106 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from collections.abc import Callable +from typing import Any + +from smithy_core.config.resolver import ConfigResolver + + +class ConfigProperty: + """Descriptor for config properties with resolution, caching, and validation. + + This descriptor handles: + - Lazy resolution from sources (only on first access) + - Custom resolution for variables requiring complex resolution + - Caching of resolved values + - Source tracking for provenance + - Validation of values + + Example: + class Config: + region = ConfigProperty('region', validator=validate_region) + + def __init__(self): + self._resolver = ConfigResolver(sources=[...]) + """ + + def __init__( + self, + key: str, + validator: Callable[[Any, str | None], Any] | None = None, + resolver_func: Callable[[ConfigResolver], tuple[Any, str | None]] | None = None, + default_value: Any = None, + ): + """Initialize config property descriptor. + + :param key: The configuration key (e.g., 'region') + :param validator: Optional validation function that takes (value, source) + and returns validated value or raises an exception + :param resolver_func: Optional custom resolver function for complex resolution. + Takes a ConfigResolver and returns (value, source) tuple. + """ + self.key = key + self.validator = validator + self.resolver_func = resolver_func + # Cache attribute name in instance __dict__ (e.g., "_cache_region") + self.cache_attr = f"_cache_{key}" + self.default_value = default_value + + def __get__(self, obj: Any, objtype: type | None = None) -> Any: + """Get the config value with lazy resolution and caching. + + On first access, the property checks if the value is already cached. If not, it resolves + the value from sources using resolver. When a validator is provided, the resolved value + is validated before use. Finally, the property caches the (value, source) tuple. On + subsequent accesses, it returns the cached value. + + :param obj: The Config instance + :param objtype: The Config class + + :returns: The resolved and validated config value + """ + # If accessed on class instead of instance, return descriptor itself + if obj is None: + return self + + cached = getattr(obj, self.cache_attr, None) + if cached is not None: + return cached[ + 0 + ] # Return value from tuple (value, source) if already cached + + # If not cached, use a resolver to go through the sources to get (value, source) + # For complex config resolutions, use a custom resolver function to resolve values + if self.resolver_func: + value, source = self.resolver_func(obj._resolver) + else: + value, source = obj._resolver.get(self.key) + + if value is None: + value = self.default_value + source = "default" + + if self.validator: + value = self.validator(value, source) + + setattr(obj, self.cache_attr, (value, source)) + return value + + def __set__(self, obj: Any, value: Any) -> None: + """Set the config value (called during __init__ or after). + + When a config value is set, the property validates the new value if a validator is provided, then + updates the cached (value, source) tuple. The source is marked as 'instance' if the value + is set during __init__, or 'in-code' if set later. + + :param obj: The Config instance + :param value: The new value to set + """ + # Determine source based on when the value was set + # If cache already exists, it means it was not set during initialization + # In that case source will be set to in-code + source = "in-code" if hasattr(obj, self.cache_attr) else "instance" + if self.validator: + value = self.validator(value, source) + + setattr(obj, self.cache_attr, (value, source)) diff --git a/packages/smithy-core/tests/unit/config/test_property.py b/packages/smithy-core/tests/unit/config/test_property.py new file mode 100644 index 00000000..88ae990a --- /dev/null +++ b/packages/smithy-core/tests/unit/config/test_property.py @@ -0,0 +1,277 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from collections.abc import Callable +from typing import Any, NoReturn + +import pytest +from smithy_core.config.property import ConfigProperty +from smithy_core.config.resolver import ConfigResolver + + +class StubSource: + """A simple ConfigSource implementation for testing.""" + + def __init__(self, source_name: str, data: dict[str, Any] | None = None) -> None: + self._name = source_name + self._data = data or {} + + @property + def name(self) -> str: + return self._name + + def get(self, key: str) -> Any | None: + return self._data.get(key) + + +class StubConfig: + """A minimal Config class for testing ConfigProperty descriptor.""" + + region = ConfigProperty("region") + retry_mode = ConfigProperty("retry_mode") + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + +class TestConfigPropertyDescriptor: + def test_resolves_value_from_resolver_on_first_access(self) -> None: + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + config = StubConfig(resolver) + + result = config.region + + assert result == "us-west-2" + + def test_caches_resolved_value(self) -> None: + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + config = StubConfig(resolver) + + # First access + result1 = config.region + # Second access + result2 = config.region + + assert result1 == result2 == "us-west-2" + # Verify it's cached + assert hasattr(config, "_cache_region") + + def test_uses_default_value_when_unresolved(self) -> None: + class ConfigWithDefault: + region = ConfigProperty("region", default_value="us-east-1") + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithDefault(resolver) + + result = config.region + + assert result == "us-east-1" + assert getattr(config, "_cache_region") == ("us-east-1", "default") + + def test_different_properties_resolve_independently(self) -> None: + source = StubSource( + "environment", {"region": "us-west-2", "retry_mode": "adaptive"} + ) + resolver = ConfigResolver(sources=[source]) + config = StubConfig(resolver) + + region = config.region + retry_mode = config.retry_mode + + assert region == "us-west-2" + assert retry_mode == "adaptive" + + +class TestConfigPropertyValidation: + """Test suite for ConfigProperty validation behavior.""" + + def _create_config_with_validator( + self, validator: Callable[[Any, str | None], Any] + ) -> type[Any]: + """Helper to create a config class with a specific validator.""" + + class ConfigWithValidator: + region = ConfigProperty("region", validator=validator) + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + return ConfigWithValidator + + def test_calls_validator_on_resolution(self) -> None: + call_log: list[tuple[Any, str | None]] = [] + + def mock_validator(value: Any, source: str | None) -> Any: + call_log.append((value, source)) + return value + + ConfigWithValidator = self._create_config_with_validator(mock_validator) + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithValidator(resolver) + + result = config.region + + assert result == "us-west-2" + assert len(call_log) == 1 + assert call_log[0] == ("us-west-2", "environment") + + def test_validator_exception_propagates(self) -> None: + def failing_validator(value: Any, source: str | None) -> NoReturn: + raise ValueError("Invalid value") + + ConfigWithValidator = self._create_config_with_validator(failing_validator) + source = StubSource("environment", {"region": "invalid-region-123"}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithValidator(resolver) + + with pytest.raises(ValueError, match="Invalid value"): + config.region + + def test_validator_not_called_on_cached_access(self) -> None: + call_count = 0 + + def counting_validator(value: Any, source: str | None) -> Any: + nonlocal call_count + call_count += 1 + return value + + ConfigWithValidator = self._create_config_with_validator(counting_validator) + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithValidator(resolver) + + # Multiple accesses + config.region + config.region + config.region + + # Only the first call accessed the validator + assert call_count == 1 # Validator called only once + + +class TestConfigPropertySetter: + """Test suite for ConfigProperty setter behavior.""" + + def test_set_value_marks_source_as_instance(self) -> None: + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = StubConfig(resolver) + + config.region = "eu-west-1" + + # Check the cached tuple + assert getattr(config, "_cache_region") == ("eu-west-1", "instance") + + def test_value_set_after_resolution_marks_source_as_in_code(self) -> None: + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + config = StubConfig(resolver) + + # First access triggers resolution from environment source + config.region + + # Modify after resolution + config.region = "eu-west-1" + + # Verify the new value is returned + assert config.region == "eu-west-1" + # Verify source is marked as 'in-code' + # Any config value modified after initialization will have 'in-code' for source + assert getattr(config, "_cache_region") == ("eu-west-1", "in-code") + + def test_validator_is_called_when_setting_values(self) -> None: + call_log: list[tuple[Any, str | None]] = [] + + def mock_validator(value: Any, source: str | None) -> Any: + call_log.append((value, source)) + return value + + class ConfigWithValidator: + region = ConfigProperty("region", validator=mock_validator) + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithValidator(resolver) + + config.region = "us-west-2" + + assert config.region == "us-west-2" + assert len(call_log) == 1 + assert call_log[0] == ("us-west-2", "instance") + + def test_validator_throws_exception_when_setting_invalid_value(self) -> None: + def mock_failing_validation(value: Any, source: str | None) -> NoReturn: + raise ValueError("Invalid value") + + class ConfigWithValidator: + region = ConfigProperty("region", validator=mock_failing_validation) + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithValidator(resolver) + + with pytest.raises(ValueError, match="Invalid value"): + config.region = "some-invalid-2" + + def test_set_overrides_resolved_value(self) -> None: + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + config = StubConfig(resolver) + + # First access resolves from environment + assert config.region == "us-west-2" + + # Setting overrides + config.region = "eu-west-1" + + assert config.region == "eu-west-1" + + +class TestConfigPropertyCaching: + """Test suite for ConfigProperty caching implementation details.""" + + def test_cache_stores_value_and_source_as_tuple(self) -> None: + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + config = StubConfig(resolver) + + config.region + + cached: Any = getattr(config, "_cache_region") + assert cached == ("us-west-2", "environment") + + def test_validator_called_on_default_value(self) -> None: + call_log: list[tuple[Any, str | None]] = [] + + def mock_validator(value: Any, source: str | None) -> Any: + call_log.append((value, source)) + return value + + class ConfigWithDefault: + region = ConfigProperty( + "region", default_value="us-default-1", validator=mock_validator + ) + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithDefault(resolver) + + config.region + + assert call_log == [("us-default-1", "default")]