diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java index e9f5d7a35..ceda9dc55 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java @@ -72,10 +72,23 @@ private void generateService(PythonWriter writer) { } writer.addDependency(SmithyPythonDependency.SMITHY_CORE); + var asyncConfigSymbol = CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()); writer.write(""" - def __init__(self, config: $1T | None = None, plugins: list[$2T] | None = None): + def __init__( + self, + config: $1T | $6T | None = None, + plugins: list[$2T] | None = None, + ): $3C - self._config = config or $1T() + if isinstance(config, $6T): + self._config: $1T = config # type: ignore[assignment] + elif isinstance(config, $1T) or config is None: + self._config = config or $1T() + else: + raise $7T( + f"config must be $6L or $1L, got {type(config).__name__}. " + f"Use 'await $6L.resolve()' instead." + ) client_plugins: list[$2T] = [ $4C @@ -92,7 +105,9 @@ def __init__(self, config: $1T | None = None, plugins: list[$2T] | None = None): pluginSymbol, writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), writer.consumer(w -> writeDefaultPlugins(w, defaultPlugins)), - RuntimeTypes.RETRY_STRATEGY_RESOLVER); + RuntimeTypes.RETRY_STRATEGY_RESOLVER, + asyncConfigSymbol, + RuntimeTypes.EXPECTATION_NOT_MET_ERROR); var topDownIndex = TopDownIndex.of(model); var eventStreamIndex = EventStreamIndex.of(model); @@ -249,7 +264,9 @@ private void writeSharedOperationInit( raise $2T("protocol and transport MUST be set on the config to make calls.") retry_strategy = await self._retry_strategy_resolver.resolve_retry_strategy( - retry_strategy=config.retry_strategy + retry_strategy=config.retry_strategy, + retry_mode=getattr(config, "retry_mode", None), + max_attempts=getattr(config, "max_attempts", None), ) pipeline = $3T( diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java index a6def8968..1e803f33d 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/CodegenUtils.java @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.Set; import java.util.logging.Logger; +import software.amazon.smithy.aws.traits.ServiceTrait; import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.model.Model; @@ -87,6 +88,48 @@ public static Symbol getPluginSymbol(PythonSettings settings) { .build(); } + /** + * Gets the async configuration object symbol for the service. + * + *

This is the new async-resolved config class that inherits from AsyncAwsConfig. + * Derives the name from the SDK ID (e.g., "Bedrock Runtime" becomes + * "AsyncBedrockRuntimeConfig"). Falls back to "AsyncConfig" for non-AWS services. + * + * @param settings The client settings. + * @param model The model containing the service shape. + * @return Returns the async config symbol. + */ + public static Symbol getAsyncConfigSymbol(PythonSettings settings, Model model) { + var service = settings.service(model); + var name = service.getTrait(ServiceTrait.class) + .map(trait -> "Async" + StringUtils.capitalize(trait.getSdkId()).replace(" ", "") + "Config") + .orElse("AsyncConfig"); + return Symbol.builder() + .name(name) + .namespace(String.format("%s.config", settings.moduleName()), ".") + .definitionFile(String.format("./src/%s/config.py", settings.moduleName())) + .build(); + } + + /** + * Gets the async plugin type hint symbol for the service. + * + * @param settings The client settings. + * @param model The model containing the service shape. + * @return Returns the async plugin type hint symbol. + */ + public static Symbol getAsyncPluginSymbol(PythonSettings settings, Model model) { + var service = settings.service(model); + var name = service.getTrait(ServiceTrait.class) + .map(trait -> "Async" + StringUtils.capitalize(trait.getSdkId()).replace(" ", "") + "Plugin") + .orElse("AsyncPlugin"); + return Symbol.builder() + .name(name) + .namespace(String.format("%s.config", settings.moduleName()), ".") + .definitionFile(String.format("./src/%s/config.py", settings.moduleName())) + .build(); + } + /** * Gets the service error symbol. * 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 30d2b07ef..359e77371 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 @@ -275,6 +275,24 @@ public void run() { writer.write("$L: TypeAlias = Callable[[$T], None]", plugin.getName(), config); writer.writeDocs("A callable that allows customizing the config object on each request.", context); }); + + // Generate the async config subclass and its plugin type + var model = context.model(); + var asyncConfig = CodegenUtils.getAsyncConfigSymbol(context.settings(), model); + var asyncPlugin = CodegenUtils.getAsyncPluginSymbol(context.settings(), model); + context.writerDelegator().useFileWriter(asyncConfig.getDefinitionFile(), asyncConfig.getNamespace(), writer -> { + generateAsyncConfig(context, writer, asyncConfig); + + // Generate the async plugin type alias + writer.addStdlibImport("typing", "Callable"); + writer.addStdlibImport("typing", "TypeAlias"); + writer.write(""); + writer.write(""); + writer.write("$L: TypeAlias = Callable[[$L], None]", asyncPlugin.getName(), asyncConfig.getName()); + writer.writeDocs( + "A callable that allows customizing the async config object on each request.", + context); + }); } private void writeInterceptorsType(PythonWriter writer) { @@ -340,10 +358,16 @@ private void generateConfig(GenerationContext context, PythonWriter writer) { writer.pushState(new ConfigSection(finalProperties)); writer.addLocallyDefinedSymbol(configSymbol); writer.addStdlibImport("dataclasses", "dataclass"); + writer.addStdlibImport("warnings"); + var asyncConfigName = CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()).getName(); writer.write(""" @dataclass(init=False) class $L: - \"""Configuration for $L.\""" + \"""Configuration for $L. + + .. deprecated:: + Use :class:`$L` with ``await $L.resolve()`` instead. + \""" ${C|} @@ -352,12 +376,22 @@ def __init__( *, ${C|} ): + warnings.warn( + "$L is deprecated, use $L.resolve() instead. " + "This class will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) ${C|} """, configSymbol.getName(), serviceId, + asyncConfigName, + asyncConfigName, writer.consumer(w -> writePropertyDeclarations(w, finalProperties)), writer.consumer(w -> writeInitParams(w, finalProperties)), + configSymbol.getName(), + asyncConfigName, writer.consumer(w -> initializeProperties(w, finalProperties))); writer.popState(); } @@ -385,6 +419,162 @@ private void initializeProperties(PythonWriter writer, CollectionThis class uses the FieldSpec-based resolution pipeline and adds + * service-specific fields (endpoint_resolver, protocol, auth_schemes, + * auth_scheme_resolver) with their defaults derived from the Smithy model. + */ + private void generateAsyncConfig(GenerationContext context, PythonWriter writer, Symbol asyncConfigSymbol) { + var model = context.model(); + var service = context.settings().service(model); + final String serviceId = service.getTrait(ServiceTrait.class) + .map(ServiceTrait::getSdkId) + .orElse(context.settings().service().getName()); + + // Import AsyncAwsConfig base class + writer.addDependency(SmithyPythonDependency.SMITHY_AWS_CORE); + var asyncAwsConfigSymbol = Symbol.builder() + .name("AsyncAwsConfig") + .namespace("smithy_aws_core.config.aws_config", ".") + .addDependency(SmithyPythonDependency.SMITHY_AWS_CORE) + .build(); + + // Import FieldSpec and ClassVar + var fieldSpecSymbol = Symbol.builder() + .name("FieldSpec") + .namespace("smithy_aws_core.config.types", ".") + .addDependency(SmithyPythonDependency.SMITHY_AWS_CORE) + .build(); + writer.addStdlibImport("typing", "ClassVar"); + writer.addStdlibImport("typing", "Any"); + writer.addStdlibImport("dataclasses", "dataclass"); + + writer.write(""); + writer.write(""); + writer.write("@dataclass(kw_only=True)"); + writer.openBlock("class $L($T):", asyncConfigSymbol.getName(), asyncAwsConfigSymbol); + writer.write("\"\"\"$L configuration (async-resolved).\"\"\"", serviceId); + writer.write(""); + + // Write service-specific field declarations + writer.write("endpoint_resolver: $T | None = None", RuntimeTypes.ENDPOINT_RESOLVER); + writer.write("protocol: $T | None = None", + Symbol.builder() + .name("ClientProtocol[Any, Any]") + .addReference(Symbol.builder() + .name("ClientProtocol") + .namespace("smithy_core.aio.interfaces", ".") + .addDependency(SmithyPythonDependency.SMITHY_CORE) + .build()) + .build()); + writer.write("auth_schemes: dict[$T, $T] | None = None", + RuntimeTypes.SHAPE_ID, + Symbol.builder() + .name("AuthScheme[Any, Any, Any, Any]") + .addReference(Symbol.builder() + .name("AuthScheme") + .namespace("smithy_core.aio.interfaces.auth", ".") + .addDependency(SmithyPythonDependency.SMITHY_CORE) + .build()) + .build()); + writer.write("auth_scheme_resolver: $T | None = None", + CodegenUtils.getHttpAuthSchemeResolverSymbol(context.settings())); + writer.write(""); + + // Write _FIELDS class variable with service-specific defaults + writer.openBlock("_FIELDS: ClassVar[dict[str, $T]] = {", fieldSpecSymbol); + writer.write("**$T._FIELDS,", asyncAwsConfigSymbol); + + // endpoint_uri FieldSpec — overrides base class with service-aware resolver + var makeEndpointResolverSymbol = Symbol.builder() + .name("EndpointUriResolver") + .namespace("smithy_aws_core.config.resolvers", ".") + .addDependency(SmithyPythonDependency.SMITHY_AWS_CORE) + .build(); + var snakeCaseServiceId = serviceId.replace(" ", "_").toLowerCase(); + writer.write("\"endpoint_uri\": $T(", fieldSpecSymbol); + writer.indent(); + writer.write("default=None,"); + writer.write("resolver=$T($S),", makeEndpointResolverSymbol, snakeCaseServiceId); + writer.dedent(); + writer.write("),"); + + // endpoint_resolver FieldSpec + var endpointPrefix = service.getTrait(ServiceTrait.class) + .map(ServiceTrait::getEndpointPrefix) + .orElse(context.settings().service().getName()); + var standardRegionalResolverSymbol = Symbol.builder() + .name("StandardRegionalEndpointsResolver") + .namespace("smithy_aws_core.endpoints.standard_regional", ".") + .addDependency(SmithyPythonDependency.SMITHY_AWS_CORE) + .build(); + writer.write("\"endpoint_resolver\": $T(", fieldSpecSymbol); + writer.indent(); + writer.write("default_factory=lambda: $T(endpoint_prefix=$S),", + standardRegionalResolverSymbol, + endpointPrefix); + writer.dedent(); + writer.write("),"); + + // protocol FieldSpec + writer.write("\"protocol\": $T(", fieldSpecSymbol); + writer.indent(); + writer.write("default_factory=lambda: ${C|},", + writer.consumer(w -> context.protocolGenerator().initializeProtocol(context, w))); + writer.dedent(); + writer.write("),"); + + // auth_schemes FieldSpec + writer.write("\"auth_schemes\": $T(", fieldSpecSymbol); + writer.indent(); + writer.write("default_factory=lambda: ${C|},", + writer.consumer(w -> writeAsyncDefaultAuthSchemes(context, w))); + writer.dedent(); + writer.write("),"); + + // auth_scheme_resolver FieldSpec + writer.write("\"auth_scheme_resolver\": $T(", fieldSpecSymbol); + writer.indent(); + writer.write("default_factory=HTTPAuthSchemeResolver,"); + writer.dedent(); + writer.write("),"); + + // transport FieldSpec + writer.write("\"transport\": $T(", fieldSpecSymbol); + writer.indent(); + if (usesHttp2(context)) { + writer.addDependency(SmithyPythonDependency.SMITHY_HTTP.withOptionalDependencies("awscrt")); + writer.write("default_factory=lambda: $T(),", RuntimeTypes.AWS_CRT_HTTP_CLIENT); + } else { + writer.addDependency(SmithyPythonDependency.SMITHY_HTTP.withOptionalDependencies("aiohttp")); + writer.write("default_factory=lambda: $T(),", RuntimeTypes.AIOHTTP_CLIENT); + } + writer.dedent(); + writer.write("),"); + + writer.closeBlock("}"); + writer.closeBlock(""); + } + + private static void writeAsyncDefaultAuthSchemes(GenerationContext context, PythonWriter writer) { + var service = context.settings().service(context.model()); + writer.openBlock("{"); + for (PythonIntegration integration : context.integrations()) { + for (RuntimeClientPlugin plugin : integration.getClientPlugins(context)) { + if (plugin.matchesService(context.model(), service) && plugin.getAuthScheme().isPresent()) { + var scheme = plugin.getAuthScheme().get(); + writer.write("$T($S): ${C|},", + RuntimeTypes.SHAPE_ID, + scheme.getAuthTrait(), + writer.consumer(w -> scheme.initializeScheme(context, writer, service))); + } + } + } + writer.closeBlock("}"); + } + private static final class AddAuthHelper implements CodeInterceptor { @Override public Class sectionType() { diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/EnumGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/EnumGenerator.java index b38106b96..85ce09be0 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/EnumGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/EnumGenerator.java @@ -41,20 +41,24 @@ public void run() { writer.addStdlibImport("enum", "StrEnum"); writer.addDependency(SmithyPythonDependency.SMITHY_CORE); writer.addLocallyDefinedSymbol(enumSymbol); - writer.openBlock("class $L($T, StrEnum):", "", enumSymbol.getName(), RuntimeTypes.UNKNOWN_ENUM_MIXIN, () -> { - shape.getTrait(DocumentationTrait.class).ifPresent(trait -> { - writer.writeDocs(trait.getValue(), context); - }); + writer.openBlock("class $L($T, StrEnum):", + "", + enumSymbol.getName(), + RuntimeTypes.UNKNOWN_ENUM_MIXIN, + () -> { + shape.getTrait(DocumentationTrait.class).ifPresent(trait -> { + writer.writeDocs(trait.getValue(), context); + }); - for (MemberShape member : shape.members()) { - var name = context.symbolProvider().toMemberName(member); - var value = member.expectTrait(EnumValueTrait.class).expectStringValue(); - writer.write("$L = $S", name, value); - member.getTrait(DocumentationTrait.class).ifPresent(trait -> { - writer.writeDocs(trait.getValue(), context); + for (MemberShape member : shape.members()) { + var name = context.symbolProvider().toMemberName(member); + var value = member.expectTrait(EnumValueTrait.class).expectStringValue(); + writer.write("$L = $S", name, value); + member.getTrait(DocumentationTrait.class).ifPresent(trait -> { + writer.writeDocs(trait.getValue(), context); + }); + } }); - } - }); }); } } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/IntEnumGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/IntEnumGenerator.java index e9fb98ecf..b29d17132 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/IntEnumGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/IntEnumGenerator.java @@ -41,20 +41,24 @@ public void run() { writer.addStdlibImport("enum", "IntEnum"); writer.addDependency(SmithyPythonDependency.SMITHY_CORE); writer.addLocallyDefinedSymbol(enumSymbol); - writer.openBlock("class $L($T, IntEnum):", "", enumSymbol.getName(), RuntimeTypes.UNKNOWN_ENUM_MIXIN, () -> { - directive.shape().getTrait(DocumentationTrait.class).ifPresent(trait -> { - writer.writeDocs(trait.getValue(), directive.context()); - }); + writer.openBlock("class $L($T, IntEnum):", + "", + enumSymbol.getName(), + RuntimeTypes.UNKNOWN_ENUM_MIXIN, + () -> { + directive.shape().getTrait(DocumentationTrait.class).ifPresent(trait -> { + writer.writeDocs(trait.getValue(), directive.context()); + }); - for (MemberShape member : directive.shape().members()) { - var name = directive.symbolProvider().toMemberName(member); - var value = member.expectTrait(EnumValueTrait.class).expectIntValue(); - writer.write("$L = $L", name, value); - member.getTrait(DocumentationTrait.class).ifPresent(trait -> { - writer.writeDocs(trait.getValue(), directive.context()); + for (MemberShape member : directive.shape().members()) { + var name = directive.symbolProvider().toMemberName(member); + var value = member.expectTrait(EnumValueTrait.class).expectIntValue(); + writer.write("$L = $L", name, value); + member.getTrait(DocumentationTrait.class).ifPresent(trait -> { + writer.writeDocs(trait.getValue(), directive.context()); + }); + } }); - } - }); }); } } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/UnionGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/UnionGenerator.java index badf2ea60..1cd2c62ea 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/UnionGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/generators/UnionGenerator.java @@ -157,30 +157,31 @@ private void generateDeserializer() { var schemaSymbol = symbol.expectProperty(SymbolProperties.SCHEMA); var unknownSymbol = symbol.expectProperty(SymbolProperties.UNION_UNKNOWN); writer.putContext("schema", schemaSymbol); - writer.write(""" - class $1L: - _result: $2T | None = None - - def deserialize(self, deserializer: ${shapeDeserializer:T}) -> $2T: - self._result = None - deserializer.read_struct($3T, self._consumer) - - if self._result is None: - raise ${serializationError:T}("Unions must have exactly one value, but found none.") - - return self._result - - def _consumer(self, schema: $4T, de: ${shapeDeserializer:T}) -> None: - match schema.expect_member_index(): - ${5C|} - case _: - self._set_result($6L(tag=schema.expect_member_name())) - - def _set_result(self, value: $2T) -> None: - if self._result is not None: - raise ${serializationError:T}("Unions must have exactly one value, but found more than one.") - self._result = value - """, + writer.write( + """ + class $1L: + _result: $2T | None = None + + def deserialize(self, deserializer: ${shapeDeserializer:T}) -> $2T: + self._result = None + deserializer.read_struct($3T, self._consumer) + + if self._result is None: + raise ${serializationError:T}("Unions must have exactly one value, but found none.") + + return self._result + + def _consumer(self, schema: $4T, de: ${shapeDeserializer:T}) -> None: + match schema.expect_member_index(): + ${5C|} + case _: + self._set_result($6L(tag=schema.expect_member_name())) + + def _set_result(self, value: $2T) -> None: + if self._result is not None: + raise ${serializationError:T}("Unions must have exactly one value, but found more than one.") + self._result = value + """, deserializerSymbol.getName(), symbol, schemaSymbol, diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/aws_config.py b/packages/smithy-aws-core/src/smithy_aws_core/config/aws_config.py index fc5b8f61c..84fcf144c 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/aws_config.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/aws_config.py @@ -1,18 +1,29 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +import os from dataclasses import dataclass, field -from typing import Any, ClassVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Self from smithy_core.retries import RetryStrategyOptions +if TYPE_CHECKING: + from smithy_core.aio.interfaces import ClientTransport + from smithy_core.aio.interfaces.identity import IdentityResolver + from smithy_core.interfaces import URI + from smithy_http.interfaces import HTTPRequestConfiguration + + from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties + from .context import SharedConfigContext from .exceptions import ConfigError, ConfigValidationError from .filesystem import FileSystem from .resolvers import ( + resolve_endpoint_uri, resolve_max_attempts, resolve_region, resolve_retry_mode, + resolve_sdk_ua_app_id, ) from .types import UNSET, ConfigSource, FieldSpec, Resolved from .validators import ( @@ -22,6 +33,8 @@ validate_retry_mode, ) +_CREDENTIAL_FIELDS = ("aws_access_key_id", "aws_secret_access_key", "aws_session_token") + @dataclass(kw_only=True) class AsyncAwsConfig: @@ -37,6 +50,17 @@ class AsyncAwsConfig: region: str | None = None retry_mode: str | None = None max_attempts: int | None = None + endpoint_uri: "str | URI | None" = None + aws_access_key_id: str | None = field(default=None, repr=False) + aws_secret_access_key: str | None = field(default=None, repr=False) + aws_session_token: str | None = field(default=None, repr=False) + aws_credentials_identity_resolver: "IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None" = None + sdk_ua_app_id: str | None = None + user_agent_extra: str | None = None + interceptors: list[Any] = field(default_factory=list) # type: ignore + http_request_config: "HTTPRequestConfiguration | None" = None + transport: "ClientTransport[Any, Any] | None" = None + retry_strategy: Any | None = None _ctx: SharedConfigContext | None = field(default=None, repr=False, compare=False) _sources: dict[str, ConfigSource] = field( # type: ignore[assignment] @@ -61,6 +85,41 @@ class AsyncAwsConfig: resolver=resolve_max_attempts, validator=validate_max_attempts, ), + "endpoint_uri": FieldSpec( + default=None, + resolver=resolve_endpoint_uri, + ), + "aws_access_key_id": FieldSpec( + default=None, + ), + "aws_secret_access_key": FieldSpec( + default=None, + ), + "aws_session_token": FieldSpec( + default=None, + ), + "aws_credentials_identity_resolver": FieldSpec( + default=None, + ), + "sdk_ua_app_id": FieldSpec( + default=None, + resolver=resolve_sdk_ua_app_id, + ), + "user_agent_extra": FieldSpec( + default=None, + ), + "interceptors": FieldSpec( + default_factory=list, + ), + "http_request_config": FieldSpec( + default=None, + ), + "transport": FieldSpec( + default=None, + ), + "retry_strategy": FieldSpec( + default=None, + ), } def __post_init__(self) -> None: @@ -150,7 +209,15 @@ async def _resolve_fields(self, overrides: dict[str, Any]) -> None: f"Valid fields are: {sorted(self._FIELDS)}" ) + # Resolve credentials atomically before the field loop + await self._resolve_credentials(overrides) + for field_name, spec in self._FIELDS.items(): + # Skip credentials — already resolved atomically above + if field_name in _CREDENTIAL_FIELDS: + if field_name in self._sources: + continue + # check for overrides first if field_name in overrides: value = overrides[field_name] @@ -183,8 +250,88 @@ def _apply_default(self, field_name: str, spec: FieldSpec) -> None: setattr(self, field_name, value) self._sources[field_name] = ConfigSource.DEFAULT + async def _resolve_credentials(self, overrides: dict[str, Any]) -> None: + """Resolve credential fields atomically from a single source. + + Rules: + - If both aws_access_key_id and aws_secret_access_key are overridden, + resolve normally + - If only one credential is overridden, raise ConfigValidationError. + - Otherwise, resolve atomically: if both key and secret are present in + env, take all three from env. If both are in the profile, take all + three from profile. Token may be None in either case. + + This prevents mixing credentials from different sources. + """ + + required = {"aws_access_key_id", "aws_secret_access_key"} + + cred_overrides = {f for f in _CREDENTIAL_FIELDS if f in overrides} + if cred_overrides: + if required <= cred_overrides: + return + else: + raise ConfigValidationError( + f"Partial credential override: {sorted(cred_overrides)}. " + "Both 'aws_access_key_id' and 'aws_secret_access_key' must be " + "provided together when overriding credentials." + ) + + # Check env vars atomically + env_creds = ( + (os.environ.get("AWS_ACCESS_KEY_ID") or "").strip() or None, + (os.environ.get("AWS_SECRET_ACCESS_KEY") or "").strip() or None, + (os.environ.get("AWS_SESSION_TOKEN") or "").strip() or None, + ) + if env_creds[0] and env_creds[1]: + self._set_credentials(_CREDENTIAL_FIELDS, env_creds, ConfigSource.ENV) + return + + # Check profile atomically + ctx = self._ctx + if ctx is None: + raise ConfigError("Resolution context not initialized") + config_file = await ctx.parsed_profiles() + profile_creds = ( + config_file.get(ctx.profile_name, "aws_access_key_id"), + config_file.get(ctx.profile_name, "aws_secret_access_key"), + config_file.get(ctx.profile_name, "aws_session_token"), + ) + if profile_creds[0] and profile_creds[1]: + self._set_credentials( + _CREDENTIAL_FIELDS, profile_creds, ConfigSource.PROFILE + ) + + def _set_credentials( + self, + fields: tuple[str, ...], + values: tuple[str | None, ...], + source: ConfigSource, + ) -> None: + """Set credential fields atomically, bypassing __setattr__ tracking.""" + for field_name, value in zip(fields, values, strict=True): + object.__setattr__(self, field_name, value or None) + self._sources[field_name] = source + def __setattr__(self, name: str, value: Any) -> None: """Track provenance when fields are set with plugins after construction""" + # Reject unknown fields + if not name.startswith("_") and name not in self.__class__._FIELDS: + raise AttributeError( + f"'{type(self).__name__}' has no config field '{name}'" + ) + + # Block override for credentials after resolution + if ( + name in _CREDENTIAL_FIELDS + and hasattr(self, "_sources") + and name in self._sources + ): + raise AttributeError( + f"'{name}' cannot be modified after resolution. " + "Create a new config with the desired credentials instead." + ) + # Mark as override only if the field is in _FIELDS and was already resolved if ( name in self.__class__._FIELDS diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/merged_config.py b/packages/smithy-aws-core/src/smithy_aws_core/config/merged_config.py index 5e550732a..f496d9aca 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/merged_config.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/merged_config.py @@ -113,3 +113,47 @@ def _merge_profiles( else: merged[name] = Section(properties=dict(section.properties)) return merged + + def get_service_config( + self, profile_name: str, service_id: str, key: str + ) -> str | None: + """Get a config value from the services section for a specific service. + + Looks up the services section referenced by the profile, then finds + the service-specific sub-property within it. + + For a config file like: + [profile default] + services = my-services + + [services my-services] + bedrock_runtime = + endpoint_url = http://localhost:5678 + + Usage: get_service_config("default", "bedrock_runtime", "endpoint_url") + + :param profile_name: The profile name to look up. + :param service_id: The service identifier (lowercase, underscored). + :param key: The property key within the service section. + + :returns: The value, or None if not found. + """ + # Get the services section name from the profile + profile = self._profiles.get(profile_name) + if profile is None: + return None + services_name = profile.properties.get("services") + if not services_name or not isinstance(services_name, str): + return None + + # Look up the services section + services_section = self._services.get(services_name) + if services_section is None: + return None + + # Get the service-specific sub-property + service_props = services_section.properties.get(service_id) + if not isinstance(service_props, dict): + return None + + return service_props.get(key.lower()) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/resolvers.py b/packages/smithy-aws-core/src/smithy_aws_core/config/resolvers.py index 7f48ff99c..f09ecb54c 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/resolvers.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/resolvers.py @@ -120,3 +120,82 @@ async def resolve_max_attempts(ctx: SharedConfigContext) -> Resolved[int | None] env_vars=("AWS_MAX_ATTEMPTS",), profile_keys=("max_attempts",), ) + + +async def resolve_endpoint_uri(ctx: SharedConfigContext) -> Resolved[str | None]: + """Resolve the endpoint URI from global environment or config file. + + This is the base resolver that only checks global sources. + For service-specific resolution, use EndpointUriResolver(). + + :param ctx: The shared resolution context. + :returns: Resolved endpoint URI value with source. + """ + return await _resolve_str( + ctx, + env_vars=("AWS_ENDPOINT_URL",), + profile_keys=("endpoint_url",), + ) + + +async def resolve_sdk_ua_app_id(ctx: SharedConfigContext) -> Resolved[str | None]: + """Resolve the SDK user-agent app ID from environment or config file. + + :param ctx: The shared resolution context. + :returns: Resolved app ID value with source. + """ + return await _resolve_str( + ctx, + env_vars=("AWS_SDK_UA_APP_ID",), + profile_keys=("sdk_ua_app_id",), + ) + + +class EndpointUriResolver: + """Service-aware endpoint URI resolver. + + Resolution order (first match wins): + 1. Service-specific env var (AWS_ENDPOINT_URL_) + 2. Global env var (AWS_ENDPOINT_URL) + 3. Service-specific config file (services section -> service_id -> endpoint_url) + 4. Global config file (profile -> endpoint_url) + """ + + def __init__(self, service_id: str): + """Initialize with a service identifier. + + :param service_id: The service identifier (e.g., "bedrock_runtime"). + Used to construct the service-specific env var and config lookup key. + """ + self._service_env_var = ( + f"AWS_ENDPOINT_URL_{service_id.replace(' ', '_').replace('-', '_').upper()}" + ) + + self._service_key = service_id.replace(" ", "_").replace("-", "_").lower() + + async def __call__(self, ctx: SharedConfigContext) -> Resolved[str | None]: + """Resolve the endpoint URI from all sources. + + :param ctx: The shared resolution context. + :returns: Resolved endpoint URI value with source. + """ + value = os.environ.get(self._service_env_var) + if value: + return Resolved(value=value, source=ConfigSource.ENV) + + value = os.environ.get("AWS_ENDPOINT_URL") + if value: + return Resolved(value=value, source=ConfigSource.ENV) + + config_file = await ctx.parsed_profiles() + value = config_file.get_service_config( + ctx.profile_name, self._service_key, "endpoint_url" + ) + if value: + return Resolved(value=value, source=ConfigSource.PROFILE) + + value = config_file.get(ctx.profile_name, "endpoint_url") + if value: + return Resolved(value=value, source=ConfigSource.PROFILE) + + return Resolved(value=UNSET, source=ConfigSource.DEFAULT) # type: ignore[arg-type] diff --git a/packages/smithy-aws-core/tests/unit/config/test_merged_config.py b/packages/smithy-aws-core/tests/unit/config/test_merged_config.py index 53322d90c..eca880cdf 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_merged_config.py +++ b/packages/smithy-aws-core/tests/unit/config/test_merged_config.py @@ -227,3 +227,100 @@ def test_services_property(self): ) assert "my-svc" in cf.services assert cf.services["my-svc"].properties == {"endpoint_url": "http://localhost"} + + +class TestGetServiceConfig: + """Tests for MergedConfig.get_service_config()""" + + def test_returns_service_specific_endpoint_url(self): + config_data = StandardizedOutput( + profiles={"default": Section(properties={"services": "my-services"})}, + services={ + "my-services": Section( + properties={ + "bedrock_runtime": {"endpoint_url": "https://custom.com"} + } + ) + }, + ) + cf = MergedConfig(config_data, StandardizedOutput()) + assert ( + cf.get_service_config("default", "bedrock_runtime", "endpoint_url") + == "https://custom.com" + ) + + def test_returns_none_when_profile_missing(self): + config_data = StandardizedOutput() + cf = MergedConfig(config_data, StandardizedOutput()) + assert ( + cf.get_service_config("default", "bedrock_runtime", "endpoint_url") is None + ) + + def test_returns_none_when_no_services_key_in_profile(self): + config_data = StandardizedOutput( + profiles={"default": Section(properties={"region": "us-east-1"})}, + ) + cf = MergedConfig(config_data, StandardizedOutput()) + assert ( + cf.get_service_config("default", "bedrock_runtime", "endpoint_url") is None + ) + + def test_returns_none_when_services_section_not_found(self): + config_data = StandardizedOutput( + profiles={"default": Section(properties={"services": "nonexistent"})}, + services={}, + ) + cf = MergedConfig(config_data, StandardizedOutput()) + assert ( + cf.get_service_config("default", "bedrock_runtime", "endpoint_url") is None + ) + + def test_returns_none_when_service_id_not_in_section(self): + config_data = StandardizedOutput( + profiles={"default": Section(properties={"services": "my-services"})}, + services={ + "my-services": Section( + properties={"dynamodb": {"endpoint_url": "https://dynamo.local"}} + ) + }, + ) + cf = MergedConfig(config_data, StandardizedOutput()) + assert ( + cf.get_service_config("default", "bedrock_runtime", "endpoint_url") is None + ) + + def test_returns_none_when_key_not_in_service(self): + config_data = StandardizedOutput( + profiles={"default": Section(properties={"services": "my-services"})}, + services={ + "my-services": Section( + properties={"bedrock_runtime": {"some_other_key": "value"}} + ) + }, + ) + cf = MergedConfig(config_data, StandardizedOutput()) + assert ( + cf.get_service_config("default", "bedrock_runtime", "endpoint_url") is None + ) + + def test_multiple_services_in_section(self): + config_data = StandardizedOutput( + profiles={"default": Section(properties={"services": "my-services"})}, + services={ + "my-services": Section( + properties={ + "bedrock_runtime": {"endpoint_url": "https://bedrock.local"}, + "dynamodb": {"endpoint_url": "https://dynamo.local"}, + } + ) + }, + ) + cf = MergedConfig(config_data, StandardizedOutput()) + assert ( + cf.get_service_config("default", "bedrock_runtime", "endpoint_url") + == "https://bedrock.local" + ) + assert ( + cf.get_service_config("default", "dynamodb", "endpoint_url") + == "https://dynamo.local" + ) diff --git a/packages/smithy-aws-core/tests/unit/config/test_resolver.py b/packages/smithy-aws-core/tests/unit/config/test_resolver.py index 5559cc35d..6e8df112f 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_resolver.py +++ b/packages/smithy-aws-core/tests/unit/config/test_resolver.py @@ -18,6 +18,7 @@ ProfileNotFoundError, ) from smithy_aws_core.config.resolvers import ( + EndpointUriResolver, resolve_max_attempts, resolve_region, resolve_retry_mode, @@ -177,7 +178,7 @@ async def test_invalid_override_triggers_validator(self): with pytest.raises( ConfigValidationError, match="Must be a valid AWS region" ): - await AsyncAwsConfig.resolve(region="bad-value!") + await AsyncAwsConfig.resolve(region="bad-value!", fs=NullFileSystem()) @pytest.mark.asyncio async def test_invalid_profile_raises_error(self): @@ -257,6 +258,30 @@ async def test_explicit_default_profile_is_validated(self): fs=NullFileSystem(), ) + @pytest.mark.asyncio + async def test_base_class_resolves_endpoint_uri_from_global_env(self): + with patch.dict( + os.environ, + {"AWS_REGION": "us-east-1", "AWS_ENDPOINT_URL": "https://localhost:4567"}, + clear=True, + ): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.endpoint_uri == "https://localhost:4567" + assert config.source_of("endpoint_uri") == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_resolve_defaults_all_non_resolved_fields(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.interceptors == [] + assert config.transport is None + assert config.retry_strategy is None + assert config.http_request_config is None + assert config.user_agent_extra is None + assert config.aws_credentials_identity_resolver is None + for name in ("interceptors", "transport", "user_agent_extra"): + assert config.source_of(name) == ConfigSource.DEFAULT + class TestProvenanceTracking: @pytest.mark.asyncio @@ -335,6 +360,13 @@ async def test_setattr_validates_during_override( with pytest.raises(ConfigValidationError, match=match): setattr(config, field_name, invalid_value) + @pytest.mark.asyncio + async def test_typo_in_field_name_raises_attribute_error(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + with pytest.raises(AttributeError, match="has no config field 'regoin'"): + config.regoin = "us-west-2" + class TestSharedConfigContext: def test_default_profile_is_default(self): @@ -430,6 +462,7 @@ async def test_returns_unset_when_not_found(self): ctx = SharedConfigContext(fs=NullFileSystem()) result = await resolve_retry_mode(ctx) assert result.value is UNSET + assert result.source is ConfigSource.DEFAULT @pytest.mark.asyncio async def test_legacy_warns_and_maps_to_standard(self): @@ -512,3 +545,464 @@ async def test_invalid_value_raises_error(self): ctx = SharedConfigContext() with pytest.raises(ConfigValidationError, match="Invalid integer value"): await resolve_max_attempts(ctx) + + +class TestEndpointUriResolver: + @pytest.fixture + def resolver(self): + + return EndpointUriResolver("bedrock_runtime") + + @pytest.mark.asyncio + async def test_service_specific_env_var_takes_precedence( + self, resolver: EndpointUriResolver + ): + fs = FakeFileSystem( + { + "/fake/config": "[profile default]\nendpoint_url = https://global-profile.com\n" + } + ) + with patch.dict( + os.environ, + {"AWS_ENDPOINT_URL_BEDROCK_RUNTIME": "https://service-env.com"}, + clear=True, + ): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value == "https://service-env.com" + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_global_env_var_when_no_service_specific( + self, resolver: EndpointUriResolver + ): + with patch.dict( + os.environ, {"AWS_ENDPOINT_URL": "https://global-env.com"}, clear=True + ): + ctx = SharedConfigContext( + fs=NullFileSystem(), + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value == "https://global-env.com" + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_service_env_beats_global_env(self, resolver: EndpointUriResolver): + with patch.dict( + os.environ, + { + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME": "https://service-env.com", + "AWS_ENDPOINT_URL": "https://global-env.com", + }, + clear=True, + ): + ctx = SharedConfigContext( + fs=NullFileSystem(), + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value == "https://service-env.com" + + @pytest.mark.asyncio + async def test_service_specific_config_file(self, resolver: EndpointUriResolver): + fs = FakeFileSystem( + { + "/fake/config": ( + "[profile default]\n" + "services = my-services\n" + "\n" + "[services my-services]\n" + "bedrock_runtime =\n" + " endpoint_url = https://service-config.com\n" + ) + } + ) + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value == "https://service-config.com" + assert result.source == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_global_config_file_fallback(self, resolver: EndpointUriResolver): + fs = FakeFileSystem( + { + "/fake/config": "[profile default]\nendpoint_url = https://global-config.com\n" + } + ) + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value == "https://global-config.com" + assert result.source == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_service_config_beats_global_config( + self, resolver: EndpointUriResolver + ): + fs = FakeFileSystem( + { + "/fake/config": ( + "[profile default]\n" + "endpoint_url = https://global-config.com\n" + "services = my-services\n" + "\n" + "[services my-services]\n" + "bedrock_runtime =\n" + " endpoint_url = https://service-config.com\n" + ) + } + ) + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value == "https://service-config.com" + + @pytest.mark.asyncio + async def test_env_beats_config_file(self, resolver: EndpointUriResolver): + fs = FakeFileSystem( + { + "/fake/config": ( + "[profile default]\n" + "endpoint_url = https://global-config.com\n" + "services = my-services\n" + "\n" + "[services my-services]\n" + "bedrock_runtime =\n" + " endpoint_url = https://service-config.com\n" + ) + } + ) + with patch.dict( + os.environ, {"AWS_ENDPOINT_URL": "https://global-env.com"}, clear=True + ): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value == "https://global-env.com" + + @pytest.mark.asyncio + async def test_returns_unset_when_nothing_found( + self, resolver: EndpointUriResolver + ): + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext( + fs=NullFileSystem(), + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value is UNSET + + @pytest.mark.asyncio + async def test_spaced_sdk_id_produces_valid_env_var_name(self): + """Passing a raw SDK ID with spaces (e.g., 'Bedrock Runtime') should + still resolve from the correctly normalized env var.""" + resolver = EndpointUriResolver("Bedrock Runtime") + with patch.dict( + os.environ, + {"AWS_ENDPOINT_URL_BEDROCK_RUNTIME": "https://from-env.com"}, + clear=True, + ): + ctx = SharedConfigContext( + fs=NullFileSystem(), + config_file_path="/fake/config", + credentials_file_path="/fake/creds", + ) + result = await resolver(ctx) + assert result.value == "https://from-env.com" + assert result.source == ConfigSource.ENV + + +class TestReprDoesNotLeakSecrets: + @pytest.mark.asyncio + async def test_repr_does_not_leak_secrets(self): + with patch.dict( + os.environ, + { + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "AWS_SESSION_TOKEN": "FwoGZXIvYXdzEBYaDHqa0AP", + }, + clear=True, + ): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + config_repr = repr(config) + + assert "AKIAIOSFODNN7EXAMPLE" not in config_repr + assert "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" not in config_repr + assert "FwoGZXIvYXdzEBYaDHqa0AP" not in config_repr + + +class TestCredentialSetIsAtomic: + """Credentials must be resolved from a single source — never mixed.""" + + @pytest.mark.asyncio + async def test_env_credentials_do_not_mix_with_profile_token(self): + """If access_key and secret come from env, token must also come from env (or be None).""" + fs = FakeFileSystem( + {"/fake/credentials": "[default]\naws_session_token = TOKEN_FROM_PROFILE\n"} + ) + with patch.dict( + os.environ, + { + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "AKID_FROM_ENV", + "AWS_SECRET_ACCESS_KEY": "SECRET_FROM_ENV", + }, + clear=True, + ): + config = await AsyncAwsConfig.resolve( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + assert config.aws_access_key_id == "AKID_FROM_ENV" + assert config.aws_secret_access_key == "SECRET_FROM_ENV" + assert config.aws_session_token is None # NOT from profile + assert config.source_of("aws_access_key_id") == ConfigSource.ENV + assert config.source_of("aws_session_token") == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_all_three_from_env_when_all_set(self): + with patch.dict( + os.environ, + { + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "AKID", + "AWS_SECRET_ACCESS_KEY": "SECRET", + "AWS_SESSION_TOKEN": "TOKEN", + }, + clear=True, + ): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.aws_access_key_id == "AKID" + assert config.aws_secret_access_key == "SECRET" + assert config.aws_session_token == "TOKEN" + assert config.source_of("aws_access_key_id") == ConfigSource.ENV + assert config.source_of("aws_secret_access_key") == ConfigSource.ENV + assert config.source_of("aws_session_token") == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_all_three_from_profile_when_no_env(self): + fs = FakeFileSystem( + { + "/fake/credentials": ( + "[default]\n" + "aws_access_key_id = AKID_PROFILE\n" + "aws_secret_access_key = SECRET_PROFILE\n" + "aws_session_token = TOKEN_PROFILE\n" + ) + } + ) + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + assert config.aws_access_key_id == "AKID_PROFILE" + assert config.aws_secret_access_key == "SECRET_PROFILE" + assert config.aws_session_token == "TOKEN_PROFILE" + assert config.source_of("aws_access_key_id") == ConfigSource.PROFILE + assert config.source_of("aws_secret_access_key") == ConfigSource.PROFILE + assert config.source_of("aws_session_token") == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_profile_token_not_used_when_env_has_key_and_secret(self): + """Even if profile has all three, env key+secret means token comes from env too.""" + fs = FakeFileSystem( + { + "/fake/credentials": ( + "[default]\n" + "aws_access_key_id = AKID_PROFILE\n" + "aws_secret_access_key = SECRET_PROFILE\n" + "aws_session_token = TOKEN_PROFILE\n" + ) + } + ) + with patch.dict( + os.environ, + { + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "AKID_ENV", + "AWS_SECRET_ACCESS_KEY": "SECRET_ENV", + }, + clear=True, + ): + config = await AsyncAwsConfig.resolve( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + # Env wins for all three — token is None because env doesn't have it + assert config.aws_access_key_id == "AKID_ENV" + assert config.aws_secret_access_key == "SECRET_ENV" + assert config.aws_session_token is None + assert config.source_of("aws_session_token") == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_no_credentials_when_nothing_set(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.aws_access_key_id is None + assert config.aws_secret_access_key is None + assert config.aws_session_token is None + assert config.source_of("aws_access_key_id") == ConfigSource.DEFAULT + + @pytest.mark.asyncio + async def test_partial_credential_override_raises_error(self): + """Overriding only one credential raises an error.""" + fs = FakeFileSystem( + { + "/fake/credentials": ( + "[default]\n" + "aws_access_key_id = AKID_PROFILE\n" + "aws_secret_access_key = SECRET_PROFILE\n" + ) + } + ) + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + with pytest.raises( + ConfigValidationError, match="Partial credential override" + ): + await AsyncAwsConfig.resolve( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + aws_access_key_id="OVERRIDE_KEY", + ) + + @pytest.mark.asyncio + async def test_credentials_cannot_be_overridden_after_resolution(self): + with patch.dict( + os.environ, + { + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "AKID", + "AWS_SECRET_ACCESS_KEY": "SECRET", + }, + clear=True, + ): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + with pytest.raises( + AttributeError, match="cannot be modified after resolution" + ): + config.aws_access_key_id = "NEW_KEY" + + @pytest.mark.asyncio + async def test_session_token_only_override_raises_error(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + with pytest.raises( + ConfigValidationError, match="Partial credential override" + ): + await AsyncAwsConfig.resolve( + fs=NullFileSystem(), + aws_session_token="FRESH_TOKEN", + ) + + @pytest.mark.asyncio + async def test_env_session_token_only_falls_through_to_profile(self): + fs = FakeFileSystem( + { + "/fake/credentials": ( + "[default]\n" + "aws_access_key_id = AKID_PROFILE\n" + "aws_secret_access_key = SECRET_PROFILE\n" + "aws_session_token = TOKEN_PROFILE\n" + ) + } + ) + with patch.dict( + os.environ, + {"AWS_REGION": "us-east-1", "AWS_SESSION_TOKEN": "TOKEN_ENV"}, + clear=True, + ): + config = await AsyncAwsConfig.resolve( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + # Token-only env doesn't trigger env path — all from profile + assert config.aws_access_key_id == "AKID_PROFILE" + assert config.aws_secret_access_key == "SECRET_PROFILE" + assert config.aws_session_token == "TOKEN_PROFILE" + assert config.source_of("aws_access_key_id") == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_env_key_only_without_secret_falls_through_to_profile(self): + fs = FakeFileSystem( + { + "/fake/credentials": ( + "[default]\n" + "aws_access_key_id = AKID_PROFILE\n" + "aws_secret_access_key = SECRET_PROFILE\n" + ) + } + ) + with patch.dict( + os.environ, + {"AWS_REGION": "us-east-1", "AWS_ACCESS_KEY_ID": "AKID_ENV"}, + clear=True, + ): + config = await AsyncAwsConfig.resolve( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + + assert config.aws_access_key_id == "AKID_PROFILE" + assert config.aws_secret_access_key == "SECRET_PROFILE" + assert config.source_of("aws_access_key_id") == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_empty_string_env_credentials_fall_through_to_profile(self): + fs = FakeFileSystem( + { + "/fake/credentials": ( + "[default]\n" + "aws_access_key_id = AKID_PROFILE\n" + "aws_secret_access_key = SECRET_PROFILE\n" + ) + } + ) + with patch.dict( + os.environ, + { + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "", + "AWS_SECRET_ACCESS_KEY": "", + }, + clear=True, + ): + config = await AsyncAwsConfig.resolve( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + assert config.aws_access_key_id == "AKID_PROFILE" + assert config.aws_secret_access_key == "SECRET_PROFILE" + assert config.source_of("aws_access_key_id") == ConfigSource.PROFILE diff --git a/packages/smithy-core/src/smithy_core/aio/retries.py b/packages/smithy-core/src/smithy_core/aio/retries.py index e3fa6340e..1ebac9495 100644 --- a/packages/smithy-core/src/smithy_core/aio/retries.py +++ b/packages/smithy-core/src/smithy_core/aio/retries.py @@ -25,16 +25,29 @@ class RetryStrategyResolver: """ async def resolve_retry_strategy( - self, *, retry_strategy: RetryStrategy | RetryStrategyOptions | None + self, + *, + retry_strategy: RetryStrategy | RetryStrategyOptions | None, + retry_mode: RetryStrategyType | None = None, + max_attempts: int | None = None, ) -> RetryStrategy: """Resolve a retry strategy from the provided options, using cache when possible. - :param retry_strategy: An explicitly configured retry strategy or options for creating one. + :param retry_strategy: An explicitly configured retry strategy or options for + creating one. Takes precedence over ``retry_mode``/``max_attempts``. + :param retry_mode: Retry mode to fall back on when ``retry_strategy`` is None, + typically resolved from the ``AWS_RETRY_MODE`` env var or a config profile. + :param max_attempts: Maximum attempts to fall back on when ``retry_strategy`` is + None, typically resolved from ``AWS_MAX_ATTEMPTS`` or a config profile. """ if isinstance(retry_strategy, RetryStrategy): return retry_strategy elif retry_strategy is None: - retry_strategy = RetryStrategyOptions() + # Fall back to the separately-resolved config values. + retry_strategy = RetryStrategyOptions( + retry_mode=retry_mode if retry_mode is not None else "standard", + max_attempts=max_attempts, + ) elif not isinstance(retry_strategy, RetryStrategyOptions): # type: ignore[reportUnnecessaryIsInstance] raise TypeError( f"retry_strategy must be RetryStrategy, RetryStrategyOptions, or None, " diff --git a/packages/smithy-core/tests/unit/aio/test_retries.py b/packages/smithy-core/tests/unit/aio/test_retries.py index f35c50750..a9710f313 100644 --- a/packages/smithy-core/tests/unit/aio/test_retries.py +++ b/packages/smithy-core/tests/unit/aio/test_retries.py @@ -166,3 +166,62 @@ async def test_retry_strategy_resolver_rejects_invalid_type() -> None: match="retry_strategy must be RetryStrategy, RetryStrategyOptions, or None", ): await resolver.resolve_retry_strategy(retry_strategy="invalid") # type: ignore + + +async def test_retry_strategy_resolver_uses_max_attempts_fallback() -> None: + resolver = RetryStrategyResolver() + + strategy = await resolver.resolve_retry_strategy( + retry_strategy=None, max_attempts=9 + ) + + assert isinstance(strategy, StandardRetryStrategy) + assert strategy.max_attempts == 9 + + +async def test_retry_strategy_resolver_uses_retry_mode_fallback() -> None: + resolver = RetryStrategyResolver() + + strategy = await resolver.resolve_retry_strategy( + retry_strategy=None, retry_mode="simple", max_attempts=4 + ) + + assert isinstance(strategy, SimpleRetryStrategy) + assert strategy.max_attempts == 4 + + +async def test_retry_strategy_resolver_fallback_defaults_when_unset() -> None: + """Omitting both fallbacks must match the prior no-argument behavior.""" + resolver = RetryStrategyResolver() + + explicit = await resolver.resolve_retry_strategy( + retry_strategy=None, retry_mode=None, max_attempts=None + ) + baseline = await resolver.resolve_retry_strategy(retry_strategy=None) + + assert explicit is baseline + assert isinstance(explicit, StandardRetryStrategy) + assert explicit.max_attempts == 3 + + +async def test_explicit_retry_strategy_options_beat_fallbacks() -> None: + resolver = RetryStrategyResolver() + retry_strategy = RetryStrategyOptions(max_attempts=2) + + strategy = await resolver.resolve_retry_strategy( + retry_strategy=retry_strategy, max_attempts=9 + ) + + assert strategy.max_attempts == 2 + + +async def test_explicit_retry_strategy_instance_beats_fallbacks() -> None: + resolver = RetryStrategyResolver() + provided = SimpleRetryStrategy(max_attempts=7) + + strategy = await resolver.resolve_retry_strategy( + retry_strategy=provided, retry_mode="standard", max_attempts=9 + ) + + assert strategy is provided + assert strategy.max_attempts == 7