diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java new file mode 100644 index 000000000..2875cd3f6 --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java @@ -0,0 +1,320 @@ +/* + * 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.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import software.amazon.smithy.aws.traits.ServiceTrait; +import software.amazon.smithy.codegen.core.Symbol; +import software.amazon.smithy.model.knowledge.EventStreamIndex; +import software.amazon.smithy.model.knowledge.ServiceIndex; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.python.codegen.CodegenUtils; +import software.amazon.smithy.python.codegen.ConfigProperty; +import software.amazon.smithy.python.codegen.GenerationContext; +import software.amazon.smithy.python.codegen.RuntimeTypes; +import software.amazon.smithy.python.codegen.SmithyPythonDependency; +import software.amazon.smithy.python.codegen.integrations.PythonIntegration; +import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin; +import software.amazon.smithy.python.codegen.sections.AsyncConfigSection; +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; + +/** + * AWS integration that generates the async config subclass (e.g., AsyncBedrockRuntimeConfig) + * inheriting from AsyncAwsConfig with service-specific fields and defaults. + */ +@SmithyInternalApi +public class AwsAsyncConfigIntegration implements PythonIntegration { + + @Override + public List> interceptors( + GenerationContext context + ) { + return List.of(new AsyncConfigInterceptor(context)); + } + + private static final class AsyncConfigInterceptor + implements CodeInterceptor { + + private final GenerationContext context; + + AsyncConfigInterceptor(GenerationContext context) { + this.context = context; + } + + @Override + public Class sectionType() { + return AsyncConfigSection.class; + } + + @Override + public void write(PythonWriter writer, String previousText, AsyncConfigSection section) { + // Write any previous content first + writer.write(previousText); + + var model = context.model(); + var service = context.settings().service(model); + + // Gate on the same source of truth the core generators use to decide whether + // to emit references to these classes. If it says no symbol is generated, we + // must not define one, or the two would disagree. + var maybeAsyncConfigSymbol = CodegenUtils.getAsyncConfigSymbol(context.settings(), model); + var maybeAsyncPluginSymbol = CodegenUtils.getAsyncPluginSymbol(context.settings(), model); + if (maybeAsyncConfigSymbol.isEmpty() || maybeAsyncPluginSymbol.isEmpty()) { + return; + } + var asyncConfigSymbol = maybeAsyncConfigSymbol.get(); + var asyncPluginSymbol = maybeAsyncPluginSymbol.get(); + + final String serviceId = service.getTrait(ServiceTrait.class) + .map(ServiceTrait::getSdkId) + .orElse(context.settings().service().getName()); + + // Import AsyncAwsConfig base class + var asyncAwsConfigSymbol = Symbol.builder() + .name("AsyncAwsConfig") + .namespace("smithy_aws_core.config.aws_config", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build(); + + // Import FieldSpec and ClassVar + var fieldSpecSymbol = Symbol.builder() + .name("FieldSpec") + .namespace("smithy_aws_core.config.types", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build(); + writer.addStdlibImport("typing", "ClassVar"); + writer.addStdlibImport("typing", "Any"); + writer.addStdlibImport("dataclasses", "dataclass"); + + writer.write(""); + writer.write(""); + // repr=False is required: AsyncAwsConfig defines a __repr__ that filters out + // credential fields, and a generated __repr__ on this subclass would shadow it + // and leak secrets. + writer.write("@dataclass(kw_only=True, repr=False)"); + writer.openBlock("class $L($T):", asyncConfigSymbol.getName(), asyncAwsConfigSymbol); + writer.writeDocs(serviceId + " configuration (async-resolved).", context); + writer.write(""); + + // Write service-specific field declarations + writer.write("endpoint_resolver: $T | None = None", RuntimeTypes.ENDPOINT_RESOLVER); + writer.writeDocs("The endpoint resolver used to resolve the final endpoint per-operation " + + "based on the configuration.", context); + writer.write(""); + + 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.writeDocs("The protocol to serialize and deserialize requests with.", context); + writer.write(""); + + var serviceIndex = ServiceIndex.of(context.model()); + var hasAuth = !serviceIndex.getAuthSchemes(context.settings().service()).isEmpty(); + + if (hasAuth) { + 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.writeDocs("A map of auth scheme ids to auth schemes.", context); + writer.write(""); + + writer.write("auth_scheme_resolver: $T | None = None", + CodegenUtils.getHttpAuthSchemeResolverSymbol(context.settings())); + writer.writeDocs("An auth scheme resolver that determines the auth scheme " + + "for each operation.", context); + writer.write(""); + } + + // Plugin-contributed field declarations (e.g., api_key for @httpApiKeyAuth). + // + // More than one plugin can contribute the same property — region, for + // instance, comes from both the auth and regional-endpoints integrations + // — so track the names already written and emit each only once. + var writtenProperties = new LinkedHashSet(); + for (PythonIntegration integration : context.integrations()) { + for (RuntimeClientPlugin plugin : integration.getClientPlugins(context)) { + if (plugin.matchesService(model, service)) { + for (ConfigProperty property : plugin.getConfigProperties()) { + if (!writtenProperties.add(property.name())) { + continue; + } + writer.write("$L: $T | None = None", property.name(), property.type()); + writer.writeDocs(property.documentation(), context); + writer.write(""); + } + } + } + } + + // Write _FIELDS class variable with service-specific defaults + writer.openBlock("_FIELDS: ClassVar[dict[str, $T]] = {", fieldSpecSymbol); + + // Plugin-contributed FieldSpec entries. + // + // These are written *before* the base class spread on purpose. Plugins + // declare config properties for the legacy Config object, which has no + // base class, so some of them duplicate fields AsyncAwsConfig already + // owns and resolves (region, credentials, sdk_ua_app_id, ...). Emitting + // them first means the spread below wins for any such duplicate, so a + // bare FieldSpec(default=None) can never clobber a base spec that + // carries a resolver or validator. Properties the base doesn't declare + // (e.g. api_key for @httpApiKeyAuth) survive untouched. + // + // Reuse the set collected above so these entries stay in step with the + // field declarations and duplicate contributions are written once. + for (String propertyName : writtenProperties) { + writer.write("\"$L\": $T(default=None),", propertyName, fieldSpecSymbol); + } + + writer.write("**$T._FIELDS,", asyncAwsConfigSymbol); + + // Everything below deliberately overrides the base class and so must + // stay after the spread. + + // endpoint_uri FieldSpec — overrides base class with service-aware resolver + var endpointUriResolverSymbol = Symbol.builder() + .name("EndpointUriResolver") + .namespace("smithy_aws_core.config.resolvers", ".") + .addDependency(AwsPythonDependency.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),", endpointUriResolverSymbol, snakeCaseServiceId); + writer.dedent(); + writer.write("),"); + + // endpoint_resolver FieldSpec + var endpointPrefix = service.getTrait(ServiceTrait.class) + .map(ServiceTrait::getEndpointPrefix) + .orElse(context.settings().service().getName()); + writer.write("\"endpoint_resolver\": $T(", fieldSpecSymbol); + writer.indent(); + writer.write("default_factory=lambda: $T(endpoint_prefix=$S),", + AwsRuntimeTypes.STANDARD_REGIONAL_ENDPOINTS_RESOLVER, + 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 + if (hasAuth) { + 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=$T,", + CodegenUtils.getHttpAuthSchemeResolverSymbol(context.settings())); + 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(""); + + // 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]", + asyncPluginSymbol.getName(), + asyncConfigSymbol.getName()); + writer.writeDocs( + "A callable that allows customizing the async config object on each request.", + context); + } + + 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 boolean usesHttp2(GenerationContext context) { + var configuration = context.applicationProtocol().configuration(); + var httpVersions = configuration.getArrayMember("http") + .orElse(ArrayNode.arrayNode()) + .getElementsAs(StringNode.class) + .stream() + .map(node -> node.getValue().toLowerCase(Locale.ENGLISH)) + .toList(); + + if (httpVersions.contains("h2")) { + return true; + } + + var eventIndex = EventStreamIndex.of(context.model()); + var topDownIndex = TopDownIndex.of(context.model()); + for (OperationShape operation : topDownIndex.getContainedOperations(context.settings().service())) { + if (eventIndex.getInputInfo(operation).isPresent() + || eventIndex.getOutputInfo(operation).isPresent()) { + return true; + } + } + + return false; + } + } +} 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..7db2f728a 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 @@ -22,6 +22,19 @@ public class AwsUserAgentIntegration implements PythonIntegration { public static final String USER_AGENT_PLUGIN = """ + def aws_user_agent_plugin(config: $1T | $5T): + config.interceptors.append( + $2T( + ua_suffix=config.user_agent_extra, + ua_app_id=config.sdk_ua_app_id, + sdk_version=$3T, + service_id=$4S, + ) + ) + """; + + // Variant for services without a generated async config, which must not be referenced. + private static final String USER_AGENT_PLUGIN_SYNC_ONLY = """ def aws_user_agent_plugin(config: $1T): config.interceptors.append( $2T( @@ -96,12 +109,23 @@ public List getClientPlugins(GenerationContext context) { filename, moduleName + ".", writer -> { - writer.write(USER_AGENT_PLUGIN, - CodegenUtils.getConfigSymbol(c.settings()), - userAgentInterceptor, - versionSymbol, - serviceId); - + var asyncConfig = CodegenUtils.getAsyncConfigSymbol( + c.settings(), + c.model()); + if (asyncConfig.isPresent()) { + writer.write(USER_AGENT_PLUGIN, + CodegenUtils.getConfigSymbol(c.settings()), + userAgentInterceptor, + versionSymbol, + serviceId, + asyncConfig.get()); + } else { + writer.write(USER_AGENT_PLUGIN_SYNC_ONLY, + CodegenUtils.getConfigSymbol(c.settings()), + userAgentInterceptor, + versionSymbol, + serviceId); + } }); return List.of(filename); }) 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..8fd7115d5 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.AwsAsyncConfigIntegration 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..08f04775b 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 @@ -61,38 +61,70 @@ private void generateService(PythonWriter writer) { .orElse("Client for " + service.getId().getName()); writer.writeDocs(docs, context); - var defaultPlugins = new LinkedHashSet(); - - for (PythonIntegration integration : context.integrations()) { - for (RuntimeClientPlugin runtimeClientPlugin : integration.getClientPlugins(context)) { - if (runtimeClientPlugin.matchesService(model, service)) { - runtimeClientPlugin.getPythonPlugin().ifPresent(defaultPlugins::add); - } - } - } - writer.addDependency(SmithyPythonDependency.SMITHY_CORE); - writer.write(""" - def __init__(self, config: $1T | None = None, plugins: list[$2T] | None = None): - $3C - self._config = config or $1T() - - client_plugins: list[$2T] = [ - $4C - ] - if plugins: - client_plugins.extend(plugins) - - for plugin in client_plugins: - plugin(self._config) - - self._retry_strategy_resolver = $5T() - """, - configSymbol, - pluginSymbol, - writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), - writer.consumer(w -> writeDefaultPlugins(w, defaultPlugins)), - RuntimeTypes.RETRY_STRATEGY_RESOLVER); + // Services with a generated async config accept either config type and resolve + // lazily on first use; the rest keep the synchronous constructor. + var asyncConfigSymbol = CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()); + if (asyncConfigSymbol.isPresent()) { + writer.addStdlibImport("asyncio"); + writer.write(""" + def __init__( + self, + config: $1T | $5T | None = None, + plugins: list[$2T] | None = None, + ): + $3C + if isinstance(config, $5T): + self._config: $1T | $5T | None = config + elif isinstance(config, $1T): + self._config = config + elif config is None: + self._config = None + else: + raise $6T( + f"config must be $5L or $1L, got {type(config).__name__}. " + f"Use 'await $5L.resolve()' instead." + ) + + self._plugins = plugins + self._derive_lock = asyncio.Lock() + self._retry_strategy_resolver = $4T() + + async def _ensure_config(self) -> $1T | $5T: + if self._config is not None: + return self._config + async with self._derive_lock: + if self._config is not None: + return self._config + self._config = await $5T.resolve() + return self._config + """, + configSymbol, + pluginSymbol, + writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), + RuntimeTypes.RETRY_STRATEGY_RESOLVER, + asyncConfigSymbol.get(), + RuntimeTypes.EXPECTATION_NOT_MET_ERROR); + } else { + writer.write(""" + def __init__( + self, + config: $1T | None = None, + plugins: list[$2T] | None = None, + ): + $3C + self._config = config or $1T() + self._plugins = plugins + self._retry_strategy_resolver = $4T() + + async def _ensure_config(self) -> $1T: + return self._config + """, + configSymbol, + pluginSymbol, + writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), + RuntimeTypes.RETRY_STRATEGY_RESOLVER); + } var topDownIndex = TopDownIndex.of(model); var eventStreamIndex = EventStreamIndex.of(model); @@ -224,9 +256,18 @@ private void writeSharedOperationInit( """, operationDocs, inputDocs, outputDocs); }); + // Service-scoped and operation-scoped plugins are collected separately because a + // RuntimeClientPlugin is always one or the other, never both: setting either + // predicate forces the other to always-false. Service-scoped plugins used to be + // applied once in the constructor, but the config may not exist until the first + // call, so they are applied here instead. + var servicePlugins = new LinkedHashSet(); var defaultPlugins = new LinkedHashSet(); for (PythonIntegration integration : context.integrations()) { for (RuntimeClientPlugin runtimeClientPlugin : integration.getClientPlugins(context)) { + if (runtimeClientPlugin.matchesService(model, service)) { + runtimeClientPlugin.getPythonPlugin().ifPresent(servicePlugins::add); + } if (runtimeClientPlugin.matchesOperation(model, service, operation)) { runtimeClientPlugin.getPythonPlugin().ifPresent(defaultPlugins::add); } @@ -237,36 +278,59 @@ private void writeSharedOperationInit( writer.addStdlibImport("copy", "deepcopy"); writer.write(""" - operation_plugins: list[Plugin] = [ + client_plugins: list[Plugin] = [ $1C ] + operation_plugins: list[Plugin] = [ + $2C + ] if plugins: operation_plugins.extend(plugins) - config = deepcopy(self._config) + # deepcopy keeps plugin mutations (e.g. appending interceptors) scoped to + # this call, so applying client_plugins per-call cannot accumulate on the + # shared config. + config = deepcopy(await self._ensure_config()) + for plugin in client_plugins: + plugin(config) + if self._plugins: + for plugin in self._plugins: + plugin(config) for plugin in operation_plugins: plugin(config) - if config.protocol is None or config.transport is None: - raise $2T("protocol and transport MUST be set on the config to make calls.") + if ( + config.protocol is None + or config.transport is None + or config.endpoint_resolver is None + or config.auth_scheme_resolver is None + or config.auth_schemes is None + ): + raise $3T( + "protocol, transport, endpoint_resolver, auth_scheme_resolver," + " and auth_schemes 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( + pipeline = $4T( protocol=config.protocol, transport=config.transport ) - call = $4T( + call = $5T( input=input, operation=${operation:T}, - context=$5T({"config": config}), - interceptor=$6T(config.interceptors), + context=$6T({"config": config}), + interceptor=$7T(config.interceptors), auth_scheme_resolver=config.auth_scheme_resolver, supported_auth_schemes=config.auth_schemes, endpoint_resolver=config.endpoint_resolver, retry_strategy=retry_strategy, ) """, + writer.consumer(w -> writeDefaultPlugins(w, servicePlugins)), writer.consumer(w -> writeDefaultPlugins(w, defaultPlugins)), RuntimeTypes.EXPECTATION_NOT_MET_ERROR, RuntimeTypes.REQUEST_PIPELINE, 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..e1f6b5f98 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,57 @@ public static Symbol getPluginSymbol(PythonSettings settings) { .build(); } + /** + * Gets the async configuration object symbol for the service, if one is generated. + * + *

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"). + * + *

The async config class lives in {@code smithy-aws-core} and is only generated + * for AWS services, so this returns an empty {@code Optional} otherwise. This is the + * single source of truth for whether the class exists: generators must not emit + * references to it when this is empty, and the integration that defines it gates + * itself on this same result. Callers that need the name unconditionally would + * reintroduce references to a class nobody defines. + * + * @param settings The client settings. + * @param model The model containing the service shape. + * @return Returns the async config symbol, or empty if none is generated. + */ + public static Optional getAsyncConfigSymbol(PythonSettings settings, Model model) { + return asyncConfigSymbolName(settings, model, "Config"); + } + + /** + * Gets the async plugin type hint symbol for the service, if one is generated. + * + * @param settings The client settings. + * @param model The model containing the service shape. + * @return Returns the async plugin symbol, or empty if none is generated. + * @see #getAsyncConfigSymbol(PythonSettings, Model) + */ + public static Optional getAsyncPluginSymbol(PythonSettings settings, Model model) { + return asyncConfigSymbolName(settings, model, "Plugin"); + } + + private static Optional asyncConfigSymbolName( + PythonSettings settings, + Model model, + String suffix + ) { + if (!isAwsService(settings, model)) { + return Optional.empty(); + } + var sdkId = settings.service(model).expectTrait(ServiceTrait.class).getSdkId(); + var name = "Async" + StringUtils.capitalize(sdkId).replace(" ", "") + suffix; + return Optional.of(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. * @@ -300,8 +352,18 @@ private static ZonedDateTime parseHttpDate(Node value) { * @return Returns true if the service is an AWS service, false otherwise. */ public static boolean isAwsService(GenerationContext context) { - var service = context.model().expectShape(context.settings().service()); - return service.hasTrait(software.amazon.smithy.aws.traits.ServiceTrait.class); + return isAwsService(context.settings(), context.model()); + } + + /** + * Determines whether the service being generated is an AWS service. + * + * @param settings The client settings. + * @param model The model containing the service shape. + * @return Returns true if the service is an AWS service, false otherwise. + */ + public static boolean isAwsService(PythonSettings settings, Model model) { + return model.expectShape(settings.service()).hasTrait(ServiceTrait.class); } /** 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..248f2fa98 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 @@ -27,6 +27,7 @@ import software.amazon.smithy.python.codegen.SymbolProperties; import software.amazon.smithy.python.codegen.integrations.PythonIntegration; import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin; +import software.amazon.smithy.python.codegen.sections.AsyncConfigSection; import software.amazon.smithy.python.codegen.sections.ConfigSection; import software.amazon.smithy.python.codegen.sections.InitDefaultEndpointResolverSection; import software.amazon.smithy.python.codegen.writer.PythonWriter; @@ -263,16 +264,31 @@ public void run() { context.writerDelegator().useFileWriter(config.getDefinitionFile(), config.getNamespace(), writer -> { writeInterceptorsType(writer); generateConfig(context, writer); + + // Emit the async config section — AWS integrations intercept this + // to generate the service-specific async config subclass. + writer.pushState(new AsyncConfigSection()); + writer.popState(); }); // Generate the plugin symbol. This is just a callable. We could do something // like have a class to implement, but that seems unnecessarily burdensome for // a single function. + // + // For AWS services that have an async config, the Plugin type accepts both + // Config and the async config. For non-AWS services without an async config, + // the Plugin type accepts only Config. var plugin = CodegenUtils.getPluginSymbol(context.settings()); + var asyncConfigForPlugin = CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()); context.writerDelegator().useFileWriter(plugin.getDefinitionFile(), plugin.getNamespace(), writer -> { writer.addStdlibImport("typing", "Callable"); writer.addStdlibImport("typing", "TypeAlias"); - writer.write("$L: TypeAlias = Callable[[$T], None]", plugin.getName(), config); + if (asyncConfigForPlugin.isPresent()) { + writer.write("$L: TypeAlias = Callable[[$T | $T], None]", + plugin.getName(), config, asyncConfigForPlugin.get()); + } else { + writer.write("$L: TypeAlias = Callable[[$T], None]", plugin.getName(), config); + } writer.writeDocs("A callable that allows customizing the config object on each request.", context); }); } @@ -340,25 +356,66 @@ private void generateConfig(GenerationContext context, PythonWriter writer) { writer.pushState(new ConfigSection(finalProperties)); writer.addLocallyDefinedSymbol(configSymbol); writer.addStdlibImport("dataclasses", "dataclass"); - writer.write(""" - @dataclass(init=False) - class $L: - \"""Configuration for $L.\""" + // This class is only deprecated where an async replacement is generated to point + // at. For services without one it remains the supported config class. + var asyncConfigSymbol = CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()); + if (asyncConfigSymbol.isPresent()) { + var asyncConfigName = asyncConfigSymbol.get().getName(); + writer.addStdlibImport("warnings"); + writer.write(""" + @dataclass(init=False) + class $L: + \"""Configuration for $L. - ${C|} + .. deprecated:: + Use :class:`$L` with ``await $L.resolve()`` instead. + \""" - def __init__( - self, - *, ${C|} - ): + + def __init__( + self, + *, + ${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))); + } else { + writer.write(""" + @dataclass(init=False) + class $L: + \"""Configuration for $L.\""" + ${C|} - """, - configSymbol.getName(), - serviceId, - writer.consumer(w -> writePropertyDeclarations(w, finalProperties)), - writer.consumer(w -> writeInitParams(w, finalProperties)), - writer.consumer(w -> initializeProperties(w, finalProperties))); + + def __init__( + self, + *, + ${C|} + ): + ${C|} + """, + configSymbol.getName(), + serviceId, + writer.consumer(w -> writePropertyDeclarations(w, finalProperties)), + writer.consumer(w -> writeInitParams(w, finalProperties)), + writer.consumer(w -> initializeProperties(w, finalProperties))); + } writer.popState(); } 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/codegen/core/src/main/java/software/amazon/smithy/python/codegen/sections/AsyncConfigSection.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/sections/AsyncConfigSection.java new file mode 100644 index 000000000..6f5eb6378 --- /dev/null +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/sections/AsyncConfigSection.java @@ -0,0 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.python.codegen.sections; + +import software.amazon.smithy.utils.CodeSection; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Section marker emitted after the legacy Config class in config.py. + * + *

AWS integrations intercept this section to generate the async config + * subclass (e.g., AsyncBedrockRuntimeConfig) that inherits from AsyncAwsConfig. + */ +@SmithyInternalApi +public record AsyncConfigSection() implements CodeSection {} 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..21841a1b1 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,31 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from dataclasses import dataclass, field -from typing import Any, ClassVar, Self +from dataclasses import dataclass, field, fields +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.components 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 +35,8 @@ validate_retry_mode, ) +_CREDENTIAL_FIELDS = ("aws_access_key_id", "aws_secret_access_key", "aws_session_token") + @dataclass(kw_only=True) class AsyncAwsConfig: @@ -35,8 +50,67 @@ class AsyncAwsConfig: """ region: str | None = None + """The AWS region to connect to. + """ + retry_mode: str | None = None + """The retry mode to use. ``standard`` is the only accepted override. + + ``legacy`` and ``adaptive`` are rejected when set here; when they come from + the environment or a config file they warn and fall back to ``standard``. + """ + max_attempts: int | None = None + """The maximum number of attempts to make per request, including the initial + attempt. Must be an integer of at least 1.""" + + endpoint_uri: "str | URI | None" = None + """A static URI to route requests to.""" + + aws_access_key_id: str | None = field(default=None, repr=False) + """The identifier for a secret access key. + + Set this together with ``aws_secret_access_key`` to supply credentials in + code. Cannot be modified after resolution; see + ``aws_credentials_identity_resolver`` to supply credentials dynamically. + """ + + aws_secret_access_key: str | None = field(default=None, repr=False) + """A secret access key that can be used to sign requests. + + Must be set together with ``aws_access_key_id``. + """ + + aws_session_token: str | None = field(default=None, repr=False) + """An access key ID that identifies temporary security credentials.""" + + aws_credentials_identity_resolver: "IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None" = None + """Resolves AWS Credentials. + + Set automatically to a ``StaticCredentialsResolver`` when + ``aws_access_key_id`` and ``aws_secret_access_key`` are supplied in code. + """ + + sdk_ua_app_id: str | None = None + """A unique and opaque application ID that is appended to the User-Agent + header.""" + + user_agent_extra: str | None = None + """Additional suffix to be added to the User-Agent header.""" + + interceptors: list[Any] = field(default_factory=list) # type: ignore + """The list of interceptors, which are hooks that are called during the + execution of a request.""" + + http_request_config: "HTTPRequestConfiguration | None" = None + """Configuration for individual HTTP requests.""" + + transport: "ClientTransport[Any, Any] | None" = None + """The transport to use to send requests""" + + retry_strategy: Any | None = None + """The retry strategy or options for configuring retry behavior. + """ _ctx: SharedConfigContext | None = field(default=None, repr=False, compare=False) _sources: dict[str, ConfigSource] = field( # type: ignore[assignment] @@ -61,8 +135,59 @@ 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 __repr__(self) -> str: + """Render the config without exposing credential material. + + Defined on the base class so that every subclass inherits the + filtering, rather than relying on each subclass to mark its own + credential fields ``repr=False``. Subclasses must be declared with + ``@dataclass(repr=False)`` so they inherit this instead of generating + their own ``__repr__``. + """ + rendered = ", ".join( + f"{f.name}={getattr(self, f.name)!r}" + for f in fields(self) + if f.repr and f.name not in _CREDENTIAL_FIELDS + ) + return f"{type(self).__name__}({rendered})" + def __post_init__(self) -> None: """Block direct construction. Use resolve() instead.""" raise ConfigError( @@ -150,6 +275,10 @@ async def _resolve_fields(self, overrides: dict[str, Any]) -> None: f"Valid fields are: {sorted(self._FIELDS)}" ) + # Validate credential overrides and auto-wire the identity resolver + # before the field loop, so the loop sees the resolver as an override. + self._resolve_credentials(overrides) + for field_name, spec in self._FIELDS.items(): # check for overrides first if field_name in overrides: @@ -183,8 +312,76 @@ def _apply_default(self, field_name: str, spec: FieldSpec) -> None: setattr(self, field_name, value) self._sources[field_name] = ConfigSource.DEFAULT + def _resolve_credentials(self, overrides: dict[str, Any]) -> None: + """Validate in-code credentials and auto-wire StaticCredentialsResolver. + + Rules: + - If both aws_access_key_id and aws_secret_access_key are overridden, + auto-set aws_credentials_identity_resolver to a + StaticCredentialsResolver (unless the caller already provided one). + Only the overridden values are used, so a session token present in a + profile is not picked up here. + - If credentials are overridden but the key/secret pair is incomplete, + raise ConfigValidationError. + - If no credential is overridden, credentials are resolved from the + remaining sources. + """ + + required = {"aws_access_key_id", "aws_secret_access_key"} + + cred_overrides = {f for f in _CREDENTIAL_FIELDS if f in overrides} + + if not cred_overrides: + return + + if not required <= cred_overrides: + 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." + ) + + # Auto-wire StaticCredentialsResolver if user didn't provide one + if overrides.get("aws_credentials_identity_resolver") is None: + # Lazy import to avoid circular dependency + from smithy_aws_core.identity.components import AWSCredentialsIdentity + from smithy_aws_core.identity.static import StaticCredentialsResolver + + identity = AWSCredentialsIdentity( + access_key_id=overrides["aws_access_key_id"], + secret_access_key=overrides["aws_secret_access_key"], + session_token=overrides.get("aws_session_token"), + ) + overrides["aws_credentials_identity_resolver"] = StaticCredentialsResolver( + identity=identity + ) + def __setattr__(self, name: str, value: Any) -> None: - """Track provenance when fields are set with plugins after construction""" + """Guard and track config fields set after resolution. + + Rejects unknown field names, blocks credential mutation, validates the + new value, and records the field as an override so ``source_of()`` + stays accurate when plugins customize a config per request. + """ + # 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. Pass credentials " + f"to `await {type(self).__name__}.resolve(...)`, or set " + "'aws_credentials_identity_resolver' to supply credentials " + "dynamically." + ) + # 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/context.py b/packages/smithy-aws-core/src/smithy_aws_core/config/context.py index ae38ff400..0df9c4971 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/context.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/context.py @@ -167,6 +167,20 @@ def http_client(self) -> Any | None: """HTTP client for network-based resolvers.""" return self._http_client + def __deepcopy__(self, memo: Any) -> "SharedConfigContext": + """Return self rather than a copy. + + The context is read-only once resolution finishes: resolvers have + already pulled their values onto the config's fields, and nothing on + the request path reads it again. Generated clients deep-copy the + config on every operation call to keep plugin mutations scoped to + that call, which would otherwise rebuild the whole parsed profile + tree per request — work proportional to the size of the caller's + shared config files. Sharing this instead keeps that cost flat + without weakening the isolation of the fields plugins actually write. + """ + return self + async def parsed_profiles(self) -> MergedConfig: """Get the parsed and merged config/credentials file data. 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..3f6a66631 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 @@ -92,6 +92,10 @@ async def resolve_retry_mode(ctx: SharedConfigContext) -> Resolved[str | None]: env_vars=("AWS_RETRY_MODE",), profile_keys=("retry_mode",), ) + + if result.value is not UNSET: + result = Resolved(value=result.value.lower(), source=result.source) + if result.value == "legacy": warnings.warn( "'legacy' retry mode is not supported, using 'standard' instead.", @@ -120,3 +124,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..6008632c5 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_resolver.py +++ b/packages/smithy-aws-core/tests/unit/config/test_resolver.py @@ -7,6 +7,8 @@ """ import os +from copy import deepcopy +from dataclasses import dataclass from unittest.mock import patch import pytest @@ -18,11 +20,15 @@ ProfileNotFoundError, ) from smithy_aws_core.config.resolvers import ( + EndpointUriResolver, + resolve_endpoint_uri, resolve_max_attempts, resolve_region, resolve_retry_mode, + resolve_sdk_ua_app_id, ) from smithy_aws_core.config.types import UNSET, ConfigSource +from smithy_aws_core.identity.static import StaticCredentialsResolver class NullFileSystem: @@ -177,7 +183,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 +263,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 +365,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): @@ -365,6 +402,58 @@ async def test_parsed_profiles_caches_result(self): result2 = await ctx.parsed_profiles() assert result1 is result2 + def test_deepcopy_returns_same_instance(self): + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + assert deepcopy(ctx) is ctx + + +class TestConfigDeepCopy: + """Generated clients deep-copy the config on every operation call. + + The copy exists to keep plugin mutations scoped to a single call, so the + fields plugins write must be independent per copy. The resolution context + is read-only afterwards and is shared instead, which keeps the per-request + cost from scaling with the size of the caller's shared config files. + """ + + @pytest.mark.asyncio + async def test_resolution_context_is_shared(self): + fs = FakeFileSystem({"/fake/config": "[profile default]\nregion = us-east-1\n"}) + with patch.dict(os.environ, {}, clear=True): + config = await AsyncAwsConfig.resolve( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + # Sanity check: there is a context to share, so the assertion below + # is meaningful. + assert config.resolution_context() is not None + assert deepcopy(config).resolution_context() is config.resolution_context() + + @pytest.mark.asyncio + async def test_mutable_fields_are_isolated(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + + first = deepcopy(config) + second = deepcopy(config) + + # A plugin appending an interceptor must not affect the shared config + # or any other in-flight call. + first.interceptors.append("first-only") + second.interceptors.append("second-only") + assert first.interceptors == ["first-only"] + assert second.interceptors == ["second-only"] + assert config.interceptors == [] + + # Scalar overrides and their provenance stay per-copy too. + first.region = "eu-west-2" + assert first.region == "eu-west-2" + assert config.region == "us-east-1" + assert first.source_of("region") is ConfigSource.OVERRIDE + assert config.source_of("region") is ConfigSource.ENV + class TestResolveRetryMode: @pytest.mark.asyncio @@ -430,6 +519,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): @@ -455,6 +545,24 @@ async def test_adaptive_warns_and_maps_to_standard(self): assert result.value == "standard" assert result.source == ConfigSource.ENV + @pytest.mark.asyncio + @pytest.mark.parametrize( + "env_value,expected", + [ + ("STANDARD", "standard"), + ("Standard", "standard"), + ("LEGACY", "standard"), + ("Legacy", "standard"), + ("ADAPTIVE", "standard"), + ("Adaptive", "standard"), + ], + ) + async def test_retry_mode_is_case_insensitive(self, env_value: str, expected: str): + with patch.dict(os.environ, {"AWS_RETRY_MODE": env_value}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + result = await resolve_retry_mode(ctx) + assert result.value == expected + class TestResolveMaxAttempts: @pytest.mark.asyncio @@ -512,3 +620,387 @@ 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"}, clear=True): + config = await AsyncAwsConfig.resolve( + fs=NullFileSystem(), + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + aws_session_token="FwoGZXIvYXdzEBYaDHqa0AP", + ) + + # Sanity check: the credentials really are populated, so the + # assertions below are meaningful. + assert config.aws_access_key_id == "AKIAIOSFODNN7EXAMPLE" + + config_repr = repr(config) + assert "AKIAIOSFODNN7EXAMPLE" not in config_repr + assert "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" not in config_repr + assert "FwoGZXIvYXdzEBYaDHqa0AP" not in config_repr + assert "region='us-east-1'" in config_repr + + @pytest.mark.asyncio + async def test_subclass_repr_does_not_leak_secrets(self): + """Subclasses declared with repr=False inherit the filtered __repr__. + + This mirrors what codegen emits for service-specific async configs. + """ + + @dataclass(kw_only=True, repr=False) + class ServiceConfig(AsyncAwsConfig): + aws_access_key_id: str | None = None + aws_secret_access_key: str | None = None + aws_session_token: str | None = None + + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await ServiceConfig.resolve( + fs=NullFileSystem(), + aws_access_key_id="AKIAIOSFODNN7EXAMPLE", + aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ) + + assert config.aws_access_key_id == "AKIAIOSFODNN7EXAMPLE" + + config_repr = repr(config) + assert config_repr.startswith("ServiceConfig(") + assert "AKIAIOSFODNN7EXAMPLE" not in config_repr + assert "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" not in config_repr + + +class TestIncodeStaticCredentialResolution: + """Credentials must be resolved from a single source — never mixed.""" + + @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.""" + 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(), + 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"}, clear=True): + config = await AsyncAwsConfig.resolve( + fs=NullFileSystem(), + aws_access_key_id="AKID", + aws_secret_access_key="SECRET", + ) + 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_key_and_secret_auto_wires_static_resolver(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve( + fs=NullFileSystem(), + aws_access_key_id="AKID", + aws_secret_access_key="SECRET", + ) + assert config.aws_access_key_id == "AKID" + assert config.aws_secret_access_key == "SECRET" + assert config.aws_credentials_identity_resolver is not None + identity = await config.aws_credentials_identity_resolver.get_identity( + properties={} + ) + assert identity.access_key_id == "AKID" + assert identity.secret_access_key == "SECRET" + + @pytest.mark.asyncio + async def test_explicit_resolver_not_overwritten(self): + custom_resolver = StaticCredentialsResolver() + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve( + fs=NullFileSystem(), + aws_access_key_id="AKID", + aws_secret_access_key="SECRET", + aws_credentials_identity_resolver=custom_resolver, + ) + assert config.aws_credentials_identity_resolver is custom_resolver + + @pytest.mark.asyncio + async def test_no_credentials_leaves_resolver_none(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.aws_credentials_identity_resolver is None + + +class TestResolveSdkUaAppId: + @pytest.mark.asyncio + async def test_resolves_from_env(self): + with patch.dict(os.environ, {"AWS_SDK_UA_APP_ID": "my-app"}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + result = await resolve_sdk_ua_app_id(ctx) + assert result.value == "my-app" + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_resolves_from_profile(self): + fs = FakeFileSystem( + {"/fake/config": "[profile default]\nsdk_ua_app_id = profile-app\n"} + ) + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext(fs=fs, config_file_path="/fake/config") + result = await resolve_sdk_ua_app_id(ctx) + assert result.value == "profile-app" + assert result.source == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_returns_unset_when_not_configured(self): + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + result = await resolve_sdk_ua_app_id(ctx) + assert result.value is UNSET + + +class TestResolveEndpointUri: + @pytest.mark.asyncio + async def test_resolves_from_env(self): + with patch.dict( + os.environ, {"AWS_ENDPOINT_URL": "https://custom.endpoint"}, clear=True + ): + ctx = SharedConfigContext(fs=NullFileSystem()) + result = await resolve_endpoint_uri(ctx) + assert result.value == "https://custom.endpoint" + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_resolves_from_profile(self): + fs = FakeFileSystem( + { + "/fake/config": "[profile default]\nendpoint_url = https://profile.endpoint\n" + } + ) + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext(fs=fs, config_file_path="/fake/config") + result = await resolve_endpoint_uri(ctx) + assert result.value == "https://profile.endpoint" + assert result.source == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_returns_unset_when_not_configured(self): + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + result = await resolve_endpoint_uri(ctx) + assert result.value is UNSET 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