diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java index 8358c54be..d2b678751 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java @@ -5,6 +5,7 @@ package software.amazon.smithy.python.aws.codegen; import static software.amazon.smithy.python.aws.codegen.AwsConfiguration.REGION; +import static software.amazon.smithy.python.aws.codegen.AwsConfiguration.RETRY_STRATEGY; import java.util.List; import java.util.Set; @@ -64,6 +65,7 @@ public List getClientPlugins(GenerationContext context) { .nullable(true) .build()) .addConfigProperty(REGION) + .addConfigProperty(RETRY_STRATEGY) .addConfigProperty(ConfigProperty.builder() .name("aws_access_key_id") .type(Symbol.builder().name("str").build()) diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfigPropertyMetadata.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfigPropertyMetadata.java new file mode 100644 index 000000000..035d757c5 --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfigPropertyMetadata.java @@ -0,0 +1,61 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.aws.codegen; + +import java.util.Optional; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * AWS-specific config resolution metadata for a config property. + * Holds validators, custom resolvers, and default values. + */ +@SmithyInternalApi +public record AwsConfigPropertyMetadata( + Symbol validator, + Symbol customResolver, + String defaultValue +) { + public Optional validatorOpt() { + return Optional.ofNullable(validator); + } + + public Optional customResolverOpt() { + return Optional.ofNullable(customResolver); + } + + public Optional defaultValueOpt() { + return Optional.ofNullable(defaultValue); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private Symbol validator; + private Symbol customResolver; + private String defaultValue; + + public Builder validator(Symbol validator) { + this.validator = validator; + return this; + } + + public Builder customResolver(Symbol customResolver) { + this.customResolver = customResolver; + return this; + } + + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public AwsConfigPropertyMetadata build() { + return new AwsConfigPropertyMetadata(validator, customResolver, defaultValue); + } + } +} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfigResolutionIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfigResolutionIntegration.java new file mode 100644 index 000000000..45161c410 --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfigResolutionIntegration.java @@ -0,0 +1,254 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.aws.codegen; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import software.amazon.smithy.python.codegen.ConfigProperty; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.integrations.PythonIntegration; +import software.amazon.smithy.python.codegen.sections.ConfigSection; +import software.amazon.smithy.python.codegen.writer.PythonWriter; +import software.amazon.smithy.utils.CodeInterceptor; +import software.amazon.smithy.utils.CodeSection; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Intercepts the generated Config class to add AWS-specific descriptor-based + * config resolution, keeping the generic ConfigGenerator unchanged. + */ +@SmithyInternalApi +public class AwsConfigResolutionIntegration implements PythonIntegration { + + // Metadata for properties that use descriptors, keyed by property name. + private static final Map DESCRIPTOR_METADATA = Map.of( + "region", AwsConfiguration.REGION_METADATA, + "retry_strategy", AwsConfiguration.RETRY_STRATEGY_METADATA, + "sdk_ua_app_id", AwsUserAgentIntegration.SDK_UA_APP_ID_METADATA + ); + + @Override + public List> interceptors( + GenerationContext context + ) { + return List.of( + new PropertyDeclarationInterceptor(context), + new PropertyInitInterceptor(), + new PreDeclarationsInterceptor(), + new PreInitInterceptor(), + new ConfigTailInterceptor() + ); + } + + // Replaces plain field declarations with descriptor assignments for properties + // that have AWS metadata registered. + private static final class PropertyDeclarationInterceptor + implements CodeInterceptor { + + private final GenerationContext context; + + PropertyDeclarationInterceptor(GenerationContext context) { + this.context = context; + } + + @Override + public Class sectionType() { + return ConfigSection.PropertyDeclarationSection.class; + } + + @Override + public void write( + PythonWriter writer, + String previousText, + ConfigSection.PropertyDeclarationSection section + ) { + ConfigProperty prop = section.property(); + AwsConfigPropertyMetadata meta = DESCRIPTOR_METADATA.get(prop.name()); + + if (meta == null) { + writer.write(previousText.stripTrailing()); + return; + } + + String typeHint = prop.type().getName(); + if (prop.isNullable() && !typeHint.endsWith("| None")) { + typeHint = typeHint + " | None"; + } + writer.write("$L: $L = _descriptors['$L'] # type: ignore[assignment]", + prop.name(), typeHint, prop.name()); + writer.writeDocs(prop.documentation(), context); + } + } + + // Skips `self.X = X` initialization for descriptor properties since the + // descriptor handles resolution. + private static final class PropertyInitInterceptor + implements CodeInterceptor { + + @Override + public Class sectionType() { + return ConfigProperty.InitializeConfigPropertySection.class; + } + + @Override + public void write( + PythonWriter writer, + String previousText, + ConfigProperty.InitializeConfigPropertySection section + ) { + if (DESCRIPTOR_METADATA.containsKey(section.property().name())) { + return; + } + writer.write(previousText.stripTrailing()); + } + } + + // Injects _descriptors dict and _resolver field before property declarations. + private static final class PreDeclarationsInterceptor + implements CodeInterceptor { + + @Override + public Class sectionType() { + return ConfigSection.PrePropertyDeclarationsSection.class; + } + + @Override + public void write( + PythonWriter writer, + String previousText, + ConfigSection.PrePropertyDeclarationsSection section + ) { + List descriptorProps = section.properties().stream() + .filter(p -> DESCRIPTOR_METADATA.containsKey(p.name())) + .collect(Collectors.toList()); + + if (descriptorProps.isEmpty()) { + return; + } + + addImports(writer, descriptorProps); + + writer.write("# Config properties using descriptors"); + writer.openBlock("_descriptors = {"); + for (ConfigProperty prop : descriptorProps) { + AwsConfigPropertyMetadata meta = DESCRIPTOR_METADATA.get(prop.name()); + StringBuilder sb = new StringBuilder(); + sb.append("'").append(prop.name()).append("': ConfigProperty('") + .append(prop.name()).append("'"); + if (meta != null) { + meta.validatorOpt().ifPresent(sym -> + sb.append(", validator=").append(sym.getName())); + meta.customResolverOpt().ifPresent(sym -> + sb.append(", resolver_func=").append(sym.getName())); + meta.defaultValueOpt().ifPresent(val -> + sb.append(", default_value=").append(val)); + } + sb.append("),"); + writer.write(sb.toString()); + } + writer.closeBlock("}"); + writer.write(""); + writer.write("_resolver: ConfigResolver"); + } + + private void addImports(PythonWriter writer, List descriptorProps) { + writer.addImport("smithy_aws_core.config.property", "ConfigProperty"); + writer.addImport("smithy_aws_core.config.resolver", "ConfigResolver"); + writer.addImport("smithy_aws_core.config.sources", "EnvironmentSource"); + writer.addImport("smithy_aws_core.config.source_info", "SourceInfo"); + + for (ConfigProperty prop : descriptorProps) { + AwsConfigPropertyMetadata meta = DESCRIPTOR_METADATA.get(prop.name()); + if (meta == null) { + continue; + } + meta.validatorOpt().ifPresent(sym -> + writer.addImport(sym.getNamespace(), sym.getName())); + meta.customResolverOpt().ifPresent(sym -> + writer.addImport(sym.getNamespace(), sym.getName())); + meta.defaultValueOpt().ifPresent(val -> { + if (val.contains("RetryStrategyOptions")) { + writer.addImport("smithy_core.retries", "RetryStrategyOptions"); + } + }); + } + } + } + + // Injects resolver initialization at the start of __init__. + private static final class PreInitInterceptor + implements CodeInterceptor { + + @Override + public Class sectionType() { + return ConfigSection.PreInitializePropertiesSection.class; + } + + @Override + public void write( + PythonWriter writer, + String previousText, + ConfigSection.PreInitializePropertiesSection section + ) { + boolean hasDescriptors = section.properties().stream() + .anyMatch(p -> DESCRIPTOR_METADATA.containsKey(p.name())); + + if (!hasDescriptors) { + return; + } + + writer.write("self._resolver = ConfigResolver(sources=[EnvironmentSource()])"); + writer.write(""); + writer.write("# Only set if provided (not None) to allow resolution from sources"); + writer.write("for key in self.__class__._descriptors.keys():"); + writer.indent(); + writer.write("value = locals().get(key)"); + writer.write("if value is not None:"); + writer.indent(); + writer.write("setattr(self, key, value)"); + writer.dedent(); + writer.dedent(); + } + } + + // Appends get_source() method to the Config class. + private static final class ConfigTailInterceptor + implements CodeInterceptor { + + @Override + public Class sectionType() { + return ConfigSection.class; + } + + @Override + public void write(PythonWriter writer, String previousText, ConfigSection section) { + boolean hasDescriptors = section.properties().stream() + .anyMatch(p -> DESCRIPTOR_METADATA.containsKey(p.name())); + + writer.write(previousText.stripTrailing()); + + if (!hasDescriptors) { + return; + } + + writer.write(""" + + def get_source(self, key: str) -> SourceInfo | None: + \"""Get the source that provided a configuration value. + + Args: + key: The configuration key (e.g., 'region', 'retry_strategy') + + Returns: + The source info (SimpleSource or ComplexSource), + or None if the key hasn't been resolved yet. + \""" + cached = self.__dict__.get(f'_cache_{key}') + return cached[1] if cached else None + """); + } + } +} diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfiguration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfiguration.java index 5d3ba035f..361a23301 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfiguration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfiguration.java @@ -18,7 +18,54 @@ private AwsConfiguration() {} public static final ConfigProperty REGION = ConfigProperty.builder() .name("region") .type(Symbol.builder().name("str").build()) - .documentation(" The AWS region to connect to. The configured region is used to " + .documentation("The AWS region to connect to. The configured region is used to " + "determine the service endpoint.") + .nullable(false) + .build(); + + public static final ConfigProperty RETRY_STRATEGY = ConfigProperty.builder() + .name("retry_strategy") + .type(Symbol.builder() + .name("RetryStrategy | RetryStrategyOptions") + .addReference(Symbol.builder() + .name("RetryStrategy") + .namespace("smithy_core.interfaces.retries", ".") + .addDependency(software.amazon.smithy.python.codegen.SmithyPythonDependency.SMITHY_CORE) + .build()) + .addReference(Symbol.builder() + .name("RetryStrategyOptions") + .namespace("smithy_core.retries", ".") + .addDependency(software.amazon.smithy.python.codegen.SmithyPythonDependency.SMITHY_CORE) + .build()) + .build()) + .documentation( + "The retry strategy or options for configuring retry behavior. Can be either a configured RetryStrategy or RetryStrategyOptions to create one.") + .build(); + + /** + * AWS-specific metadata for descriptor-based config properties. + */ + public static final AwsConfigPropertyMetadata REGION_METADATA = AwsConfigPropertyMetadata.builder() + .validator(Symbol.builder() + .name("validate_region") + .namespace("smithy_aws_core.config.validators", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build()) + .build(); + /** + * AWS-specific metadata for descriptor-based config properties. + */ + public static final AwsConfigPropertyMetadata RETRY_STRATEGY_METADATA = AwsConfigPropertyMetadata.builder() + .validator(Symbol.builder() + .name("validate_retry_strategy") + .namespace("smithy_aws_core.config.validators", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build()) + .customResolver(Symbol.builder() + .name("resolve_retry_strategy") + .namespace("smithy_aws_core.config.custom_resolvers", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build()) + .defaultValue("RetryStrategyOptions(retry_mode=\"standard\")") .build(); } diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java index 423296913..ade4a5740 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java @@ -21,6 +21,14 @@ @SmithyInternalApi public class AwsUserAgentIntegration implements PythonIntegration { + public static final AwsConfigPropertyMetadata SDK_UA_APP_ID_METADATA = AwsConfigPropertyMetadata.builder() + .validator(Symbol.builder() + .name("validate_ua_string") + .namespace("smithy_aws_core.config.validators", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build()) + .build(); + public static final String USER_AGENT_PLUGIN = """ def aws_user_agent_plugin(config: $1T): config.interceptors.append( diff --git a/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration b/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration index a338df30c..4780e099b 100644 --- a/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration +++ b/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration @@ -8,3 +8,4 @@ software.amazon.smithy.python.aws.codegen.AwsProtocolsIntegration software.amazon.smithy.python.aws.codegen.AwsServiceIdIntegration software.amazon.smithy.python.aws.codegen.AwsUserAgentIntegration software.amazon.smithy.python.aws.codegen.AwsStandardRegionalEndpointsIntegration +software.amazon.smithy.python.aws.codegen.AwsConfigResolutionIntegration diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java index de03d42d0..dbcbdd88a 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/ConfigGenerator.java @@ -349,28 +349,41 @@ class $L: ${C|} + ${C|} + def __init__( self, *, ${C|} ): ${C|} + ${C|} """, configSymbol.getName(), serviceId, + writer.consumer(w -> { + w.pushState(new ConfigSection.PrePropertyDeclarationsSection(finalProperties)); + w.popState(); + }), writer.consumer(w -> writePropertyDeclarations(w, finalProperties)), writer.consumer(w -> writeInitParams(w, finalProperties)), + writer.consumer(w -> { + w.pushState(new ConfigSection.PreInitializePropertiesSection(finalProperties)); + w.popState(); + }), writer.consumer(w -> initializeProperties(w, finalProperties))); writer.popState(); } private void writePropertyDeclarations(PythonWriter writer, Collection properties) { for (ConfigProperty property : properties) { + writer.pushState(new ConfigSection.PropertyDeclarationSection(property)); var formatString = property.isNullable() ? "$L: $T | None" : "$L: $T"; writer.write(formatString, property.name(), property.type()); writer.writeDocs(property.documentation(), context); + writer.popState(); writer.write(""); } } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/sections/ConfigSection.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/sections/ConfigSection.java index 13ae6110d..ccf9dc80a 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/sections/ConfigSection.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/sections/ConfigSection.java @@ -15,4 +15,14 @@ * @param properties The list of properties that need to be present on the config. */ @SmithyInternalApi -public record ConfigSection(List properties) implements CodeSection {} +public record ConfigSection(List properties) implements CodeSection { + + /** Section for a single config property's class-level field declaration. */ + public record PropertyDeclarationSection(ConfigProperty property) implements CodeSection {} + + /** Section before property declarations, for injecting class-level fields. */ + public record PrePropertyDeclarationsSection(List properties) implements CodeSection {} + + /** Section before property initializations in __init__, for injecting setup code. */ + public record PreInitializePropertiesSection(List properties) implements CodeSection {} +} 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..e4ac5fd5f --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py @@ -0,0 +1,7 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from .sources import EnvironmentSource + +__all__ = [ + "EnvironmentSource", +] 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 000000000..5aa7de408 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/custom_resolvers.py @@ -0,0 +1,58 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from smithy_core.retries import RetryStrategyOptions + +from smithy_aws_core.config.resolver import ConfigResolver +from smithy_aws_core.config.source_info import ComplexSource, SourceName +from smithy_aws_core.config.validators import validate_max_attempts, validate_retry_mode + + +def resolve_retry_strategy( + resolver: ConfigResolver, +) -> tuple[RetryStrategyOptions | None, ComplexSource | 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": "default"} + """ + + 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 or "standard", # type: ignore + max_attempts=max_attempts, + ) + + # Construct mixed source string showing where each component came from + source = ComplexSource( + { + "retry_mode": mode_source.name if mode_source else SourceName.DEFAULT, + "max_attempts": attempts_source.name + if attempts_source + else SourceName.DEFAULT, + } + ) + + return (options, source) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/property.py b/packages/smithy-aws-core/src/smithy_aws_core/config/property.py new file mode 100644 index 000000000..56e136981 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/property.py @@ -0,0 +1,112 @@ +# 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_aws_core.config.resolver import ConfigResolver +from smithy_aws_core.config.source_info import SimpleSource, SourceInfo, SourceName + + +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, SourceInfo | None], Any] | None = None, + resolver_func: Callable[[ConfigResolver], tuple[Any, SourceInfo | 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 = SimpleSource(SourceName.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 = ( + SimpleSource(SourceName.IN_CODE) + if hasattr(obj, self.cache_attr) + else SimpleSource(SourceName.INSTANCE) + ) + if self.validator: + value = self.validator(value, source) + + setattr(obj, self.cache_attr, (value, source)) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/resolver.py b/packages/smithy-aws-core/src/smithy_aws_core/config/resolver.py new file mode 100644 index 000000000..3444e987c --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/resolver.py @@ -0,0 +1,37 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from collections.abc import Sequence +from typing import Any + +from smithy_aws_core.config.source_info import SimpleSource +from smithy_aws_core.interfaces.config import ConfigSource + + +class ConfigResolver: + """Resolves configuration values from multiple sources. + + The resolver iterates through sources in precedence order, returning + the first non-None value found for a given configuration key. + """ + + def __init__(self, sources: Sequence[ConfigSource]) -> None: + """Initialize the resolver with sources in precedence order. + + :param sources: List of configuration sources in precedence order. The first + source in the list has the highest priority. + """ + self._sources = sources + + def get(self, key: str) -> tuple[Any, SimpleSource | None]: + """Resolve a configuration value from sources by iterating through them in precedence order. + + :param key: The configuration key to resolve (e.g., 'retry_mode') + + :returns: A tuple of (value, source_name). If no source provides a value, + returns (None, None). + """ + for source in self._sources: + value = source.get(key) + if value is not None: + return (value, SimpleSource(source.name)) + return (None, None) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/source_info.py b/packages/smithy-aws-core/src/smithy_aws_core/config/source_info.py new file mode 100644 index 000000000..64e5d0cc2 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/source_info.py @@ -0,0 +1,48 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +from dataclasses import dataclass +from enum import StrEnum + + +class SourceName(StrEnum): + """Known source names for config value provenance tracking.""" + + INSTANCE = "instance" # value provided via Config constructor + + IN_CODE = "in-code" # value set via setter after Config construction + + ENVIRONMENT = "environment" # value resolved from environment variable + + DEFAULT = "default" # value fall back to default + + +@dataclass(frozen=True) +class SimpleSource: + """Source info for a config value resolved from a single source. + + Examples: region from environment, max_attempts from config file. + """ + + # TODO: Currently only environment variable is implemented as a config + # source. Tests use raw strings (e.g., "environment", "config_file") as + # source names to simulate multi-source scenarios. Once additional + # config sources are implemented, update the `name` parameter type + # from `str` to `SourceName` and replace raw strings in tests with + # the corresponding enum values. + name: str + + +@dataclass(frozen=True) +class ComplexSource: + """Source info for a config value resolved from multiple sources. + + Used when a config property is composed of multiple sources. + Example: retry_strategy is composed of retry_mode and max_attempts and they both + could be from different sources: {"retry_mode": "environment", "max_attempts": "config_file"} + """ + + components: dict[str, str] + + +SourceInfo = SimpleSource | ComplexSource diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/sources.py b/packages/smithy-aws-core/src/smithy_aws_core/config/sources.py new file mode 100644 index 000000000..26c5f3550 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/sources.py @@ -0,0 +1,37 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import os + +from smithy_aws_core.config.source_info import SourceName + + +class EnvironmentSource: + """Configuration from environment variables.""" + + def __init__(self, prefix: str = "AWS_"): + """Initialize the EnvironmentSource with environment variable prefix. + + :param prefix: Prefix for environment variables (default: 'AWS_') + """ + self._prefix = prefix + + @property + def name(self) -> str: + """Returns the source name.""" + return SourceName.ENVIRONMENT + + def get(self, key: str) -> str | None: + """Returns a configuration value from environment variables. + + The key is transformed to uppercase and prefixed (e.g., 'region' → 'AWS_REGION'). + + :param key: The standard configuration key (e.g., 'region', 'retry_mode'). + + :returns: The value from the environment variable (or empty string if set to empty), + or None if the variable is not set. + """ + env_var = f"{self._prefix}{key.upper()}" + config_value = os.environ.get(env_var) + if config_value is None: + return None + return config_value.strip() 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 000000000..a2766ecbe --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py @@ -0,0 +1,168 @@ +# 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 + +from smithy_aws_core.config.source_info import SourceInfo + + +class ConfigValidationError(ValueError): + """Raised when a configuration value fails validation.""" + + def __init__( + self, key: str, value: Any, reason: str, source: SourceInfo | 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 | None, source: SourceInfo | 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 + """ + if region is None: + raise ConfigValidationError( + "region", + region, + "region not found. It is required and must be explicitly set.", + source, + ) + + pattern = r"^(?![0-9]+$)(?!-)[a-zA-Z0-9-]{1,63}(? str: + """Validate retry mode. + + Valid values: 'standard' + + :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 + """ + # NOTE: RetryStrategyType supports 'simple' as a direct config value, but the valid + # string modes here are restricted to align with the standard AWS retry modes: + # 'standard' and 'adaptive'. 'legacy' is intentionally excluded as it is not + # recommended. A simple retry strategy can still be provided directly via the config. + all_modes = list(get_args(RetryStrategyType)) + if "simple" in all_modes: + all_modes.remove("simple") + valid_modes = tuple(all_modes) + + 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: SourceInfo | 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: SourceInfo | 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, + ) + + +def validate_ua_string(value: Any, source: SourceInfo | None = None) -> str | None: + """Validate a User-Agent string component. + + :param value: The UA string value to validate + :param source: The source that provided this value + + :returns: The UA string or None if value is None + + :raises ConfigValidationError: If the value is not a string + """ + if value is None: + return None + if not isinstance(value, str): + raise ConfigValidationError( + "sdk_ua_app_id", + value, + f"UA string must be a string, got {type(value).__name__}", + source, + ) + return value diff --git a/packages/smithy-aws-core/src/smithy_aws_core/interfaces/config.py b/packages/smithy-aws-core/src/smithy_aws_core/interfaces/config.py new file mode 100644 index 000000000..add120a02 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/interfaces/config.py @@ -0,0 +1,27 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class ConfigSource(Protocol): + """Protocol for configuration sources that provide values from various locations + like environment variables and configuration files. + """ + + @property + def name(self) -> str: + """Returns a string identifying the source. + + :returns: A string identifier for this source. + """ + ... + + def get(self, key: str) -> Any | None: + """Returns a configuration value from the source. + + :param key: The configuration key to retrieve (e.g., 'region') + + :returns: The value associated with the key, or None if not found. + """ + ... 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 000000000..37657598a --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_custom_resolver.py @@ -0,0 +1,108 @@ +# 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_aws_core.config.resolver import ConfigResolver +from smithy_aws_core.config.source_info import ComplexSource +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 == ComplexSource( + {"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 == ComplexSource( + {"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" + # None for max_attempts means the RetryStrategy will use its + # own default max_attempts value for the set retry_mode + assert result.max_attempts is None + assert source_name == ComplexSource( + {"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 result.retry_mode == "standard" + assert source_name == ComplexSource( + {"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_property.py b/packages/smithy-aws-core/tests/unit/config/test_property.py new file mode 100644 index 000000000..4246d0602 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_property.py @@ -0,0 +1,324 @@ +# 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_aws_core.config.property import ConfigProperty +from smithy_aws_core.config.resolver import ConfigResolver +from smithy_aws_core.config.source_info import SimpleSource, SourceInfo +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 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: + retry_strategy = ConfigProperty( + "retry_strategy", + default_value=RetryStrategyOptions(retry_mode="standard"), + ) + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithDefault(resolver) + + result = config.retry_strategy + + assert result.retry_mode == "standard" + assert result.max_attempts is None + assert getattr(config, "_cache_retry_strategy") == ( + RetryStrategyOptions(retry_mode="standard"), + SimpleSource("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, SourceInfo | 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, SourceInfo | None]] = [] + + def mock_validator(value: Any, source: SourceInfo | 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", SimpleSource("environment")) + + def test_validator_exception_propagates(self) -> None: + def failing_validator(value: Any, source: SourceInfo | 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_complex_resolver_falls_back_to_default(self) -> None: + def mock_resolver(resolver: ConfigResolver) -> tuple[None, None]: + # Simulates resolve_retry_strategy returning (None, None) when no sources have values + return (None, None) + + class ConfigWithComplexResolver: + retry_strategy = ConfigProperty( + "retry_strategy", + resolver_func=mock_resolver, + default_value=RetryStrategyOptions(retry_mode="standard"), + ) + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithComplexResolver(resolver) + + result = config.retry_strategy + cached = getattr(config, "_cache_retry_strategy", None) + source_info = cached[1] if cached else None + + assert isinstance(result, RetryStrategyOptions) + assert result.retry_mode == "standard" + assert result.max_attempts is None + assert source_info == SimpleSource("default") + + def test_validator_not_called_on_cached_access(self) -> None: + call_count = 0 + + def counting_validator(value: Any, source: SourceInfo | 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", + SimpleSource("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", + SimpleSource("in-code"), + ) + + def test_validator_is_called_when_setting_values(self) -> None: + call_log: list[tuple[Any, SourceInfo | None]] = [] + + def mock_validator(value: Any, source: SourceInfo | 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", SimpleSource("instance")) + + def test_validator_throws_exception_when_setting_invalid_value(self) -> None: + def mock_failing_validation(value: Any, source: SourceInfo | 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", SimpleSource("environment")) + + def test_validator_called_on_default_value(self) -> None: + call_log: list[tuple[Any, SourceInfo | None]] = [] + + def mock_validator(value: Any, source: SourceInfo | None) -> Any: + call_log.append((value, source)) + return value + + class ConfigWithDefault: + retry_strategy = ConfigProperty( + "retry_strategy", + default_value=RetryStrategyOptions(retry_mode="standard"), + validator=mock_validator, + ) + + def __init__(self, resolver: ConfigResolver) -> None: + self._resolver = resolver + + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + config = ConfigWithDefault(resolver) + + config.retry_strategy + + assert call_log == [ + (RetryStrategyOptions(retry_mode="standard"), SimpleSource("default")) + ] diff --git a/packages/smithy-aws-core/tests/unit/config/test_resolver.py b/packages/smithy-aws-core/tests/unit/config/test_resolver.py new file mode 100644 index 000000000..55180b1b4 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_resolver.py @@ -0,0 +1,115 @@ +# 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.resolver import ConfigResolver +from smithy_aws_core.config.source_info import SimpleSource + + +class StubSource: + """A simple ConfigSource implementation for testing. + + Returns values from a provided dictionary, or None if the key + is not present. + """ + + def __init__(self, source_name: str, data: dict[str, Any] | 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 TestConfigResolver: + def test_returns_value_from_single_source(self): + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + + result = resolver.get("region") + + assert result == ("us-west-2", SimpleSource("environment")) + + def test_returns_None_when_source_has_no_value(self): + source = StubSource("environment", {}) + resolver = ConfigResolver(sources=[source]) + + result = resolver.get("region") + + assert result == (None, None) + + def test_returns_None_with_empty_source_list(self): + resolver = ConfigResolver(sources=[]) + + result = resolver.get("region") + + assert result == (None, None) + + def test_first_source_takes_precedence(self): + first_priority_source = StubSource("source_one", {"region": "us-east-1"}) + second_priority_source = StubSource("source_two", {"region": "eu-west-1"}) + resolver = ConfigResolver( + sources=[first_priority_source, second_priority_source] + ) + + result = resolver.get("region") + + assert result == ("us-east-1", SimpleSource("source_one")) + + def test_skips_source_returning_none_and_uses_next(self): + empty_source = StubSource("source_one", {}) + fallback_source = StubSource("source_two", {"region": "ap-south-1"}) + resolver = ConfigResolver(sources=[empty_source, fallback_source]) + + result = resolver.get("region") + + assert result == ("ap-south-1", SimpleSource("source_two")) + + def test_resolves_different_keys_from_different_sources(self): + instance = StubSource("source_one", {"region": "us-west-2"}) + environment = StubSource("source_two", {"retry_mode": "adaptive"}) + resolver = ConfigResolver(sources=[instance, environment]) + + region = resolver.get("region") + retry_mode = resolver.get("retry_mode") + + assert region == ("us-west-2", SimpleSource("source_one")) + assert retry_mode == ("adaptive", SimpleSource("source_two")) + + def test_returns_non_string_values(self): + source = StubSource( + "default", + { + "max_retries": 3, + "use_ssl": True, + }, + ) + resolver = ConfigResolver(sources=[source]) + + assert resolver.get("max_retries") == (3, SimpleSource("default")) + assert resolver.get("use_ssl") == (True, SimpleSource("default")) + + def test_get_is_idempotent(self): + source = StubSource("environment", {"region": "us-west-2"}) + resolver = ConfigResolver(sources=[source]) + + result1 = resolver.get("region") + result2 = resolver.get("region") + result3 = resolver.get("region") + + assert ( + result1 == result2 == result3 == ("us-west-2", SimpleSource("environment")) + ) + + def test_treats_empty_string_as_valid_value(self): + source = StubSource("test", {"region": ""}) + resolver = ConfigResolver(sources=[source]) + + value, source_name = resolver.get("region") + + assert value == "" + assert source_name == SimpleSource("test") diff --git a/packages/smithy-aws-core/tests/unit/config/test_sources.py b/packages/smithy-aws-core/tests/unit/config/test_sources.py new file mode 100644 index 000000000..5db84f38e --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_sources.py @@ -0,0 +1,86 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import os +from unittest.mock import patch + +from smithy_aws_core.config.sources import EnvironmentSource + + +class TestEnvironmentSource: + def test_source_name(self): + source = EnvironmentSource() + assert source.name == "environment" + + def test_get_region_from_aws_region(self): + with patch.dict(os.environ, {"AWS_REGION": "us-west-2"}, clear=True): + source = EnvironmentSource() + value = source.get("region") + assert value == "us-west-2" + + def test_get_returns_none_when_env_var_not_set(self): + with patch.dict(os.environ, {}, clear=True): + source = EnvironmentSource() + value = source.get("region") + assert value is None + + def test_get_returns_none_for_unknown_key(self): + source = EnvironmentSource() + value = source.get("unknown_config_key") + assert value is None + + def test_get_handles_empty_string_env_var(self): + with patch.dict(os.environ, {"AWS_REGION": ""}, clear=True): + source = EnvironmentSource() + value = source.get("region") + assert value == "" + + def test_get_handles_whitespace_env_var(self): + with patch.dict(os.environ, {"AWS_REGION": " us-west-2 "}, clear=True): + source = EnvironmentSource() + value = source.get("region") + # Whitespaces should be stripped + assert value == "us-west-2" + + def test_get_handles_whole_whitespace_env_var(self): + with patch.dict(os.environ, {"AWS_REGION": " "}, clear=True): + source = EnvironmentSource() + value = source.get("region") + # Whitespaces should be stripped + assert value == "" + + def test_multiple_keys_with_different_env_vars(self): + env_vars = {"AWS_REGION": "eu-west-1", "AWS_RETRY_MODE": "standard"} + with patch.dict(os.environ, env_vars, clear=True): + source = EnvironmentSource() + + region = source.get("region") + retry_mode = source.get("retry_mode") + + assert region == "eu-west-1" + assert retry_mode == "standard" + + def test_get_is_idempotent(self): + with patch.dict(os.environ, {"AWS_REGION": "ap-south-1"}, clear=True): + source = EnvironmentSource() + # Calling get on source multiple times should return the same value + value1 = source.get("region") + value2 = source.get("region") + value3 = source.get("region") + + assert value1 == value2 == value3 == "ap-south-1" + + def test_source_does_not_cache_env_vars(self): + source = EnvironmentSource() + + # First read + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + value1 = source.get("region") + assert value1 == "us-east-1" + + # Environment changes + with patch.dict(os.environ, {"AWS_REGION": "us-west-2"}, clear=False): + value2 = source.get("region") + assert value2 == "us-west-2" + + # Source reads from os.environ and not from cache + assert value1 != value2 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 000000000..dbdbb7b60 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_validators.py @@ -0,0 +1,69 @@ +# 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, + validate_ua_string, +) + + +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"]) + 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 ('standard',), got random_mode" in str(exc_info.value) + ) + + +class TestValidateUaString: + def test_allows_string(self) -> None: + assert validate_ua_string("abc123") == "abc123" + + def test_none_returns_none(self) -> None: + assert validate_ua_string(None) is None + + def test_empty_string_passthrough(self) -> None: + assert validate_ua_string("") == "" + + def test_rejects_non_string(self) -> None: + with pytest.raises(ConfigValidationError) as exc_info: + validate_ua_string(123) + + assert exc_info.value.key == "sdk_ua_app_id"