From 3fe40384c3aa8fd95451a03fe4729a538c4d8a50 Mon Sep 17 00:00:00 2001 From: Ujjwal <19787410+ubaskota@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:42:29 -0400 Subject: [PATCH 1/4] Add support for remaining config variables from the old to-be-deprecated Config interface and add a new config class (#758) --- .../codegen/AwsAsyncConfigIntegration.java | 298 +++++++++++ .../aws/codegen/AwsUserAgentIntegration.java | 35 +- .../AwsDynamoDbRetryIntegration.java | 3 +- ...hon.codegen.integrations.PythonIntegration | 3 +- .../python/codegen/ClientGenerator.java | 167 ++++-- .../smithy/python/codegen/CodegenUtils.java | 66 ++- .../codegen/HttpProtocolTestGenerator.java | 29 +- .../codegen/generators/ConfigGenerator.java | 98 +++- .../codegen/generators/EnumGenerator.java | 28 +- .../codegen/generators/IntEnumGenerator.java | 28 +- .../codegen/generators/UnionGenerator.java | 49 +- .../codegen/sections/AsyncConfigSection.java | 17 + .../python/codegen/writer/PythonWriter.java | 2 +- ...ture-8c3d0d1d20b84d3ea7c65a6117ccfbaa.json | 4 + .../src/smithy_aws_core/config/aws_config.py | 203 ++++++- .../src/smithy_aws_core/config/context.py | 14 + .../smithy_aws_core/config/merged_config.py | 44 ++ .../src/smithy_aws_core/config/resolvers.py | 83 +++ .../tests/unit/config/test_merged_config.py | 97 ++++ .../tests/unit/config/test_resolver.py | 494 +++++++++++++++++- ...ment-cce32f90a9d346ea8b4cdc081a23bca2.json | 4 + .../src/smithy_core/aio/retries.py | 19 +- .../tests/unit/aio/test_retries.py | 59 +++ 23 files changed, 1702 insertions(+), 142 deletions(-) create mode 100644 codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java rename codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/{ => customizations/dynamodb}/AwsDynamoDbRetryIntegration.java (97%) create mode 100644 codegen/core/src/main/java/software/amazon/smithy/python/codegen/sections/AsyncConfigSection.java create mode 100644 packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-8c3d0d1d20b84d3ea7c65a6117ccfbaa.json create mode 100644 packages/smithy-core/.changes/next-release/smithy-core-enhancement-cce32f90a9d346ea8b4cdc081a23bca2.json 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..4a82e1b8e --- /dev/null +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAsyncConfigIntegration.java @@ -0,0 +1,298 @@ +/* + * 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); + if (maybeAsyncConfigSymbol.isEmpty()) { + return; + } + var asyncConfigSymbol = maybeAsyncConfigSymbol.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 are emitted before the base class + // spread. Some duplicate fields already in AsyncAwsConfig._FIELDS (e.g., + // region, sdk_ua_app_id) — these are harmlessly overwritten by the spread + // below. Fields unique to this service (e.g., api_key from @httpApiKeyAuth) + // survive and participate in the resolution pipeline. + 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(""); + } + + 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..5989913ca 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 @@ -33,6 +33,19 @@ def aws_user_agent_plugin(config: $1T): ) """; + // Variant for services without a generated async config, which uses old Config. + private static final String USER_AGENT_PLUGIN_SYNC_ONLY = """ + def aws_user_agent_plugin(config: $1T): + config.interceptors.append( + $2T( + ua_suffix=config.user_agent_extra, + ua_app_id=config.sdk_ua_app_id, + sdk_version=$3T, + service_id=$4S, + ) + ) + """; + @Override public List getClientPlugins(GenerationContext context) { if (context.applicationProtocol().isHttpProtocol()) { @@ -96,12 +109,22 @@ 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, + asyncConfig.get(), + userAgentInterceptor, + versionSymbol, + serviceId); + } 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/java/software/amazon/smithy/python/aws/codegen/AwsDynamoDbRetryIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/dynamodb/AwsDynamoDbRetryIntegration.java similarity index 97% rename from codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsDynamoDbRetryIntegration.java rename to codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/dynamodb/AwsDynamoDbRetryIntegration.java index bf7852bf1..104ef7d54 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsDynamoDbRetryIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/dynamodb/AwsDynamoDbRetryIntegration.java @@ -2,13 +2,14 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.python.aws.codegen; +package software.amazon.smithy.python.aws.codegen.customizations.dynamodb; import java.util.List; import java.util.Set; import software.amazon.smithy.aws.traits.ServiceTrait; import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.codegen.core.SymbolReference; +import software.amazon.smithy.python.aws.codegen.AwsPythonDependency; import software.amazon.smithy.python.codegen.GenerationContext; import software.amazon.smithy.python.codegen.SmithyPythonDependency; import software.amazon.smithy.python.codegen.integrations.PythonIntegration; 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 05214b08e..d808610eb 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 @@ -9,4 +9,5 @@ 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.AwsDynamoDbRetryIntegration +software.amazon.smithy.python.aws.codegen.customizations.dynamodb.AwsDynamoDbRetryIntegration +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..4b25001ea 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,75 @@ private void generateService(PythonWriter writer) { .orElse("Client for " + service.getId().getName()); writer.writeDocs(docs, context); - var defaultPlugins = new LinkedHashSet(); + writer.addDependency(SmithyPythonDependency.SMITHY_CORE); + // Services with a generated async config resolve lazily on first use; + // the rest keep the synchronous constructor with old Config. + var asyncConfigSymbol = CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()); + // Collect service-scoped plugins (stored in __init__, applied per-call). + var servicePlugins = new LinkedHashSet(); for (PythonIntegration integration : context.integrations()) { for (RuntimeClientPlugin runtimeClientPlugin : integration.getClientPlugins(context)) { if (runtimeClientPlugin.matchesService(model, service)) { - runtimeClientPlugin.getPythonPlugin().ifPresent(defaultPlugins::add); + runtimeClientPlugin.getPythonPlugin().ifPresent(servicePlugins::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() + if (asyncConfigSymbol.isPresent()) { + writer.addStdlibImport("asyncio"); - client_plugins: list[$2T] = [ - $4C - ] - if plugins: - client_plugins.extend(plugins) - - for plugin in client_plugins: - plugin(self._config) + writer.write(""" + def __init__( + self, + config: $1T | None = None, + plugins: list[$2T] | None = None, + ): + ${3C|} + self._config = config + self._plugins = plugins + self._derive_lock = asyncio.Lock() + self._retry_strategy_resolver = $4T() + self._client_plugins: list[$2T] = [ + ${5C|} + ] + + async def _ensure_setup(self) -> None: + if self._config is None: + async with self._derive_lock: + if self._config is None: + self._config = await $1T.resolve() + """, + asyncConfigSymbol.get(), + pluginSymbol, + writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), + RuntimeTypes.RETRY_STRATEGY_RESOLVER, + writer.consumer(w -> writeDefaultPlugins(w, servicePlugins))); + } else { - self._retry_strategy_resolver = $5T() - """, - configSymbol, - pluginSymbol, - writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), - writer.consumer(w -> writeDefaultPlugins(w, defaultPlugins)), - RuntimeTypes.RETRY_STRATEGY_RESOLVER); + 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() + self._client_plugins: list[$2T] = [ + ${5C|} + ] + + async def _ensure_setup(self) -> None: + pass + """, + configSymbol, + pluginSymbol, + writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), + RuntimeTypes.RETRY_STRATEGY_RESOLVER, + writer.consumer(w -> writeDefaultPlugins(w, servicePlugins))); + } var topDownIndex = TopDownIndex.of(model); var eventStreamIndex = EventStreamIndex.of(model); @@ -224,6 +261,8 @@ private void writeSharedOperationInit( """, operationDocs, inputDocs, outputDocs); }); + // Operation-scoped plugins are collected per-operation. Service-scoped plugins + // are stored in self._client_plugins (built once in __init__). var defaultPlugins = new LinkedHashSet(); for (PythonIntegration integration : context.integrations()) { for (RuntimeClientPlugin runtimeClientPlugin : integration.getClientPlugins(context)) { @@ -236,37 +275,59 @@ private void writeSharedOperationInit( writer.putContext("operation", symbolProvider.toSymbol(operation)); writer.addStdlibImport("copy", "deepcopy"); - writer.write(""" - operation_plugins: list[Plugin] = [ - $1C - ] - if plugins: - operation_plugins.extend(plugins) - config = deepcopy(self._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.") - - retry_strategy = await self._retry_strategy_resolver.resolve_retry_strategy( - retry_strategy=config.retry_strategy - ) - - pipeline = $3T( - protocol=config.protocol, - transport=config.transport - ) - call = $4T( - input=input, - operation=${operation:T}, - context=$5T({"config": config}), - interceptor=$6T(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.write( + """ + operation_plugins: list[Plugin] = [ + $1C + ] + if plugins: + operation_plugins.extend(plugins) + # deepcopy keeps plugin mutations (e.g. appending interceptors) scoped to + # this call, so applying client_plugins per-call cannot accumulate on the + # shared config. + await self._ensure_setup() + assert self._config is not None + config = deepcopy(self._config) + for plugin in self._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 + or config.endpoint_resolver is None + or config.auth_scheme_resolver is None + or config.auth_schemes is None + ): + raise $2T( + "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_mode=getattr(config, "retry_mode", None), + max_attempts=getattr(config, "max_attempts", None), + ) + + pipeline = $3T( + protocol=config.protocol, + transport=config.transport + ) + call = $4T( + input=input, + operation=${operation:T}, + context=$5T({"config": config}), + interceptor=$6T(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, 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/HttpProtocolTestGenerator.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/HttpProtocolTestGenerator.java index 0cc6d81ef..8f99d2bc3 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/HttpProtocolTestGenerator.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/HttpProtocolTestGenerator.java @@ -183,15 +183,16 @@ private void generateRequestTest(OperationShape operation, HttpRequestTestCase t path = ""; } writeClientBlock(context.symbolProvider().toSymbol(service), testCase, Optional.of(() -> { + var configPrefix = getTestConfigPrefix(); writer.write(""" - config = $T( + $L endpoint_uri="https://$L/$L", transport = $T(), retry_strategy=$T(max_attempts=1), ${C|} ) """, - CodegenUtils.getConfigSymbol(context.settings()), + configPrefix, host, path, REQUEST_TEST_ASYNC_HTTP_CLIENT_SYMBOL, @@ -441,8 +442,9 @@ private void generateResponseTest(OperationShape operation, HttpResponseTestCase testFilter.test(operation, testCase), () -> { writeClientBlock(context.symbolProvider().toSymbol(service), testCase, Optional.of(() -> { + var configPrefix = getTestConfigPrefix(); writer.write(""" - config = $T( + $L endpoint_uri="https://example.com", transport = $T( status=$L, @@ -452,7 +454,7 @@ private void generateResponseTest(OperationShape operation, HttpResponseTestCase ${C|} ) """, - CodegenUtils.getConfigSymbol(context.settings()), + configPrefix, RESPONSE_TEST_ASYNC_HTTP_CLIENT_SYMBOL, testCase.getCode(), CodegenUtils.toTuples(testCase.getHeaders()), @@ -496,8 +498,9 @@ private void generateErrorResponseTest( testFilter.test(error, testCase), () -> { writeClientBlock(context.symbolProvider().toSymbol(service), testCase, Optional.of(() -> { + var configPrefix = getTestConfigPrefix(); writer.write(""" - config = $T( + $L endpoint_uri="https://example.com", transport = $T( status=$L, @@ -507,7 +510,7 @@ private void generateErrorResponseTest( ${C|} ) """, - CodegenUtils.getConfigSymbol(context.settings()), + configPrefix, RESPONSE_TEST_ASYNC_HTTP_CLIENT_SYMBOL, testCase.getCode(), CodegenUtils.toTuples(testCase.getHeaders()), @@ -621,6 +624,20 @@ private void writeClientBlock( }); } + /** + * Returns the config construction prefix for test code. + * For AWS services: "config = await AsyncConfig.resolve(" + * For non-AWS services: "config = Config(" + */ + private String getTestConfigPrefix() { + var configSymbol = CodegenUtils.getAsyncConfigSymbol(context.settings(), model) + .orElse(CodegenUtils.getConfigSymbol(context.settings())); + writer.addImport(configSymbol.getNamespace(), configSymbol.getName()); + return CodegenUtils.getAsyncConfigSymbol(context.settings(), model).isPresent() + ? "config = await %s.resolve(".formatted(configSymbol.getName()) + : "config = %s(".formatted(configSymbol.getName()); + } + private void writeSigV4TestConfig() { if (!service.hasTrait(SigV4Trait.class)) { return; 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 2b57736d9..334eb8c65 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; @@ -264,19 +265,41 @@ private static void writeDefaultAuthSchemes(GenerationContext context, PythonWri @Override public void run() { var config = CodegenUtils.getConfigSymbol(context.settings()); + var asyncConfigForPlugin = CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()); + context.writerDelegator().useFileWriter(config.getDefinitionFile(), config.getNamespace(), writer -> { writeInterceptorsType(writer); - generateConfig(context, writer); + + // For AWS services, old Config is no longer generated — only the async + // config subclass (emitted by AwsAsyncConfigIntegration via section interceptor). + // For non-AWS services, generate old Config as usual. + if (asyncConfigForPlugin.isEmpty()) { + 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, the Plugin type accepts only the async config. + // For non-AWS services, the Plugin type accepts only Config. var plugin = CodegenUtils.getPluginSymbol(context.settings()); 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], None]", + plugin.getName(), + 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); }); } @@ -344,25 +367,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/codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/PythonWriter.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/PythonWriter.java index fa369d54e..2bebb598e 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/PythonWriter.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/writer/PythonWriter.java @@ -158,7 +158,7 @@ public PythonWriter writeDocs(String docs, GenerationContext context) { if (formatted.contains("\n")) { writeMultiLineDocs(() -> write(formatted)); } else { - writeSingleLineDocs(() -> write(formatted)); + writeSingleLineDocs(() -> writeInline(formatted)); } return this; } diff --git a/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-8c3d0d1d20b84d3ea7c65a6117ccfbaa.json b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-8c3d0d1d20b84d3ea7c65a6117ccfbaa.json new file mode 100644 index 000000000..d3f96af35 --- /dev/null +++ b/packages/smithy-aws-core/.changes/next-release/smithy-aws-core-feature-8c3d0d1d20b84d3ea7c65a6117ccfbaa.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Implement async config resolution mechanism that supports multiple config sources. Replaces the traditional Config class with AsyncConfig for AWS services." +} \ No newline at end of file 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/.changes/next-release/smithy-core-enhancement-cce32f90a9d346ea8b4cdc081a23bca2.json b/packages/smithy-core/.changes/next-release/smithy-core-enhancement-cce32f90a9d346ea8b4cdc081a23bca2.json new file mode 100644 index 000000000..865ff25f8 --- /dev/null +++ b/packages/smithy-core/.changes/next-release/smithy-core-enhancement-cce32f90a9d346ea8b4cdc081a23bca2.json @@ -0,0 +1,4 @@ +{ + "type": "enhancement", + "description": "Update RetryStrategyResolver.resolve_retry_strategy to accept retry_mode and max_attempts as fallback parameters when retry_strategy is not explicitly set in config." +} \ No newline at end of file diff --git a/packages/smithy-core/src/smithy_core/aio/retries.py b/packages/smithy-core/src/smithy_core/aio/retries.py index 6fd41d223..46c172b2c 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 dd181141b..ce7059bb9 100644 --- a/packages/smithy-core/tests/unit/aio/test_retries.py +++ b/packages/smithy-core/tests/unit/aio/test_retries.py @@ -306,6 +306,65 @@ async def test_retry_strategy_resolver_rejects_invalid_type() -> 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 + + async def test_resolver_no_service_defaults_uses_strategy_defaults() -> None: resolver = RetryStrategyResolver() From c71cb25dadc6d9740f9afcf5f0640fb633e0270d Mon Sep 17 00:00:00 2001 From: jonathan343 <43360731+jonathan343@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:39:24 -0400 Subject: [PATCH 2/4] Improve async AWS config typing and plugin lifecycle (#774) --- .../python/codegen/test/AwsCodegenTest.java | 45 ++++- .../codegen/AwsAsyncConfigIntegration.java | 187 +++++++++++++----- .../aws/codegen/AwsAuthIntegration.java | 2 +- .../apigateway/ApiGatewayIntegration.java | 3 +- .../dynamodb/AwsDynamoDbRetryIntegration.java | 28 +-- .../codegen/test/PythonCodegenTest.java | 10 +- .../python/codegen/ClientGenerator.java | 134 +++++++------ .../codegen/generators/ConfigGenerator.java | 88 +++------ .../src/smithy_aws_core/config/__init__.py | 3 +- .../src/smithy_aws_core/config/aws_config.py | 66 ++++++- .../src/smithy_aws_core/config/resolvers.py | 3 - .../tests/unit/config/test_resolver.py | 35 ++-- 12 files changed, 368 insertions(+), 236 deletions(-) diff --git a/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsCodegenTest.java b/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsCodegenTest.java index 5bfbdba22..0b6534204 100644 --- a/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsCodegenTest.java +++ b/codegen/aws/core/src/it/java/software/amazon/smithy/python/codegen/test/AwsCodegenTest.java @@ -4,6 +4,11 @@ */ package software.amazon.smithy.python.codegen.test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -19,7 +24,7 @@ public class AwsCodegenTest { @Test - public void testCodegen(@TempDir Path tempDir) { + public void testCodegen(@TempDir Path tempDir) throws IOException { PythonClientCodegenPlugin plugin = new PythonClientCodegenPlugin(); Model model = Model.assembler(AwsCodegenTest.class.getClassLoader()) .discoverModels(AwsCodegenTest.class.getClassLoader()) @@ -36,6 +41,44 @@ public void testCodegen(@TempDir Path tempDir) { .model(model) .build(); plugin.execute(context); + + var config = Files.readString(tempDir.resolve("src/restjson/config.py")); + assertTrue(config.contains("Overrides(AwsConfigOverrides, total=False):")); + assertTrue(config.contains("api_key: str | None")); + assertTrue(config.contains("@dataclass(kw_only=True, repr=False, init=False)")); + assertTrue(config.contains("**overrides: Unpack[")); + assertTrue(config.contains( + "interceptors: list[_ServiceInterceptor] = field(default_factory=lambda: [])")); + assertTrue(config.contains( + "def set_auth_scheme(self, scheme: AuthScheme[Any, Any, Any, Any]) -> None:")); + assertTrue(config.contains("auth_schemes = dict(self.auth_schemes or {})")); + assertTrue(config.contains("auth_schemes[scheme.scheme_id] = scheme")); + assertTrue(config.contains("self.auth_schemes = auth_schemes")); + + var client = Files.readString(tempDir.resolve("src/restjson/client.py")); + assertInOrder( + client, + "config = await AsyncRESTJSONConfig.resolve()", + "for plugin in self._client_plugins:", + "for plugin in self._plugins:", + "self._config = config", + "if operation_plugins:", + "config = deepcopy(self._config)", + "for plugin in operation_plugins:", + "config = self._config"); + assertFalse(client.contains("plugin(self._config)")); + assertTrue(client.contains("retry_mode=config.retry_mode")); + assertTrue(client.contains("max_attempts=config.max_attempts")); + assertFalse(client.contains("getattr(config, \"retry_mode\"")); + assertFalse(client.contains("getattr(config, \"max_attempts\"")); } + private static void assertInOrder(String value, String... fragments) { + var index = 0; + for (String fragment : fragments) { + index = value.indexOf(fragment, index); + assertTrue(index >= 0, "Missing or out-of-order fragment: " + fragment); + index += fragment.length(); + } + } } 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 index 4a82e1b8e..df1423cce 100644 --- 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 @@ -4,9 +4,10 @@ */ package software.amazon.smithy.python.aws.codegen; -import java.util.LinkedHashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Set; import software.amazon.smithy.aws.traits.ServiceTrait; import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.model.knowledge.EventStreamIndex; @@ -34,6 +35,27 @@ */ @SmithyInternalApi public class AwsAsyncConfigIntegration implements PythonIntegration { + // Keep base fields synchronized with AwsConfigOverrides. The remaining fields are + // generated explicitly below. + private static final Set PREDEFINED_CONFIG_FIELDS = Set.of( + "region", + "retry_mode", + "max_attempts", + "endpoint_uri", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_credentials_identity_resolver", + "sdk_ua_app_id", + "user_agent_extra", + "interceptors", + "http_request_config", + "transport", + "retry_strategy", + "endpoint_resolver", + "protocol", + "auth_schemes", + "auth_scheme_resolver"); @Override public List> interceptors( @@ -77,29 +99,90 @@ public void write(PythonWriter writer, String previousText, AsyncConfigSection s .map(ServiceTrait::getSdkId) .orElse(context.settings().service().getName()); - // Import AsyncAwsConfig base class + var serviceIndex = ServiceIndex.of(context.model()); + var hasAuth = !serviceIndex.getAuthSchemes(context.settings().service()).isEmpty(); + // Preserve the first declaration when plugins contribute duplicate properties. + var pluginProperties = new LinkedHashMap(); + for (PythonIntegration integration : context.integrations()) { + for (RuntimeClientPlugin plugin : integration.getClientPlugins(context)) { + if (plugin.matchesService(model, service)) { + for (ConfigProperty property : plugin.getConfigProperties()) { + pluginProperties.putIfAbsent(property.name(), property); + } + } + } + } + var asyncAwsConfigSymbol = Symbol.builder() .name("AsyncAwsConfig") .namespace("smithy_aws_core.config.aws_config", ".") .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) .build(); - - // Import FieldSpec and ClassVar + var awsConfigOverridesSymbol = Symbol.builder() + .name("AwsConfigOverrides") + .namespace("smithy_aws_core.config", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build(); + var fileSystemSymbol = Symbol.builder() + .name("FileSystem") + .namespace("smithy_aws_core.config", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build(); var fieldSpecSymbol = Symbol.builder() .name("FieldSpec") .namespace("smithy_aws_core.config.types", ".") .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) .build(); + var protocolSymbol = Symbol.builder() + .name("ClientProtocol[Any, Any]") + .addReference(Symbol.builder() + .name("ClientProtocol") + .namespace("smithy_core.aio.interfaces", ".") + .addDependency(SmithyPythonDependency.SMITHY_CORE) + .build()) + .build(); + var authSchemeSymbol = 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(); + var authSchemeResolverSymbol = CodegenUtils.getHttpAuthSchemeResolverSymbol(context.settings()); + var overridesTypeName = "_" + asyncConfigSymbol.getName() + "Overrides"; + writer.addStdlibImport("typing", "ClassVar"); writer.addStdlibImport("typing", "Any"); + writer.addStdlibImport("typing", "Self"); + writer.addStdlibImport("typing", "Unpack"); writer.addStdlibImport("dataclasses", "dataclass"); + writer.addStdlibImport("dataclasses", "field"); writer.write(""); writer.write(""); + writer.openBlock("class $L($T, total=False):", overridesTypeName, awsConfigOverridesSymbol); + writer.write("endpoint_resolver: $T | None", RuntimeTypes.ENDPOINT_RESOLVER); + writer.write("protocol: $T | None", protocolSymbol); + if (hasAuth) { + writer.write("auth_schemes: dict[$T, $T] | None", + RuntimeTypes.SHAPE_ID, + authSchemeSymbol); + writer.write("auth_scheme_resolver: $T | None", authSchemeResolverSymbol); + } + for (ConfigProperty property : pluginProperties.values()) { + if (!PREDEFINED_CONFIG_FIELDS.contains(property.name())) { + // Match the nullable dataclass field and FieldSpec below. + writer.write("$L: $T | None", property.name(), property.type()); + } + } + writer.closeBlock(""); + 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.write("@dataclass(kw_only=True, repr=False, init=False)"); writer.openBlock("class $L($T):", asyncConfigSymbol.getName(), asyncAwsConfigSymbol); writer.writeDocs(serviceId + " configuration (async-resolved).", context); writer.write(""); @@ -110,61 +193,34 @@ public void write(PythonWriter writer, String previousText, AsyncConfigSection s + "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.write("protocol: $T | None = None", protocolSymbol); 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(); + writer.write("interceptors: list[_ServiceInterceptor] = field(default_factory=lambda: [])"); + writer.writeDocs( + "The list of interceptors, which are hooks that are called during the execution of a request.", + context); + writer.write(""); 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()); + authSchemeSymbol); 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.write("auth_scheme_resolver: $T | None = None", authSchemeResolverSymbol); 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(""); - } - } - } + for (ConfigProperty property : pluginProperties.values()) { + 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 @@ -175,7 +231,7 @@ public void write(PythonWriter writer, String previousText, AsyncConfigSection s // region, sdk_ua_app_id) — these are harmlessly overwritten by the spread // below. Fields unique to this service (e.g., api_key from @httpApiKeyAuth) // survive and participate in the resolution pipeline. - for (String propertyName : writtenProperties) { + for (String propertyName : pluginProperties.keySet()) { writer.write("\"$L\": $T(default=None),", propertyName, fieldSpecSymbol); } @@ -250,6 +306,47 @@ public void write(PythonWriter writer, String previousText, AsyncConfigSection s writer.write("),"); writer.closeBlock("}"); + writer.write(""); + if (hasAuth) { + writer.write("def set_auth_scheme(self, scheme: $T) -> None:", authSchemeSymbol); + writer.indent(); + writer.writeDocs(""" + Set an auth scheme implementation using its scheme ID. + + :param scheme: The auth scheme to add or replace. + """, context); + writer.write("auth_schemes = dict(self.auth_schemes or {})"); + writer.write("auth_schemes[scheme.scheme_id] = scheme"); + writer.write("self.auth_schemes = auth_schemes"); + writer.dedent(); + writer.write(""); + } + writer.write("@classmethod"); + writer.write("async def resolve( # pyright: ignore[reportIncompatibleMethodOverride]"); + writer.indent(); + writer.write("cls,"); + writer.write("*,"); + writer.write("profile: str | None = None,"); + writer.write("fs: $T | None = None,", fileSystemSymbol); + writer.write("config_file_path: str | None = None,"); + writer.write("credentials_file_path: str | None = None,"); + writer.write("**overrides: Unpack[$L],", overridesTypeName); + writer.dedent(); + writer.write(") -> Self:"); + writer.indent(); + writer.writeDocs( + "Resolve config from environment, config files, defaults, and explicit overrides.", + context); + writer.write("return await cls._resolve("); + writer.indent(); + writer.write("profile=profile,"); + writer.write("fs=fs,"); + writer.write("config_file_path=config_file_path,"); + writer.write("credentials_file_path=credentials_file_path,"); + writer.write("overrides=overrides,"); + writer.dedent(); + writer.write(")"); + writer.dedent(); writer.closeBlock(""); } diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java index 707429954..c4bfaf7fa 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java @@ -79,7 +79,7 @@ public List getClientPlugins(GenerationContext context) { .addConfigProperty(ConfigProperty.builder() .name("aws_session_token") .type(Symbol.builder().name("str").build()) - .documentation("An access key ID that identifies temporary security credentials.") + .documentation("The session token used with temporary AWS credentials.") .nullable(true) .build()) .authScheme(new Sigv4AuthScheme()) diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/apigateway/ApiGatewayIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/apigateway/ApiGatewayIntegration.java index ffb3a1fa4..2c54bc9ec 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/apigateway/ApiGatewayIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/apigateway/ApiGatewayIntegration.java @@ -100,7 +100,8 @@ public List getClientPlugins(GenerationContext context) { httpRequest, requestContext, field, - CodegenUtils.getConfigSymbol(c.settings())); + CodegenUtils.getAsyncConfigSymbol(c.settings(), c.model()) + .orElseThrow()); }); return List.of(filename); }) diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/dynamodb/AwsDynamoDbRetryIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/dynamodb/AwsDynamoDbRetryIntegration.java index 104ef7d54..39d74f324 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/dynamodb/AwsDynamoDbRetryIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/customizations/dynamodb/AwsDynamoDbRetryIntegration.java @@ -10,6 +10,7 @@ import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.codegen.core.SymbolReference; import software.amazon.smithy.python.aws.codegen.AwsPythonDependency; +import software.amazon.smithy.python.codegen.CodegenUtils; import software.amazon.smithy.python.codegen.GenerationContext; import software.amazon.smithy.python.codegen.SmithyPythonDependency; import software.amazon.smithy.python.codegen.integrations.PythonIntegration; @@ -29,11 +30,7 @@ public final class AwsDynamoDbRetryIntegration implements PythonIntegration { _DYNAMODB_DEFAULT_MAX_BACKOFF = 20 - class _RetryConfig(Protocol): - retry_strategy: $1T | $2T | None - - - def dynamodb_retry_plugin(config: _RetryConfig) -> None: + def dynamodb_retry_plugin(config: $1T) -> None: \"\"\"Apply DynamoDB's standard-mode retry defaults for any option left unset.\"\"\" retry_strategy = config.retry_strategy if retry_strategy is not None and not isinstance( @@ -46,15 +43,10 @@ def dynamodb_retry_plugin(config: _RetryConfig) -> None: retry_mode = retry_strategy.retry_mode max_attempts = retry_strategy.max_attempts else: - # Read independently resolved AsyncConfig fields when available. A legacy - # Config has no scalar retry fields, so None represents an unset value. - retry_mode = getattr(config, "retry_mode", None) or "standard" - max_attempts = getattr(config, "max_attempts", None) - source_of = getattr(config, "source_of", None) - if ( - source_of is not None - and source_of("max_attempts") == $4T.DEFAULT - ): + # Fall back to the config's independently resolved retry fields. + retry_mode = config.retry_mode or "standard" + max_attempts = config.max_attempts + if config.source_of("max_attempts") == $4T.DEFAULT: max_attempts = None if retry_mode != "standard": @@ -86,10 +78,6 @@ public List getClientPlugins(GenerationContext context) { .name("dynamodb_retry_plugin") .build()) .build(); - final Symbol retryStrategy = Symbol.builder() - .namespace("smithy_core.aio.interfaces.retries", ".") - .name("RetryStrategy") - .build(); final Symbol retryStrategyOptions = Symbol.builder() .namespace("smithy_core.retries", ".") .name("RetryStrategyOptions") @@ -126,10 +114,10 @@ public List getClientPlugins(GenerationContext context) { writer -> { writer.addDependency(SmithyPythonDependency.SMITHY_CORE); writer.addDependency(AwsPythonDependency.SMITHY_AWS_CORE); - writer.addStdlibImport("typing", "Protocol"); writer.write( DYNAMODB_RETRY_MODULE, - retryStrategy, + CodegenUtils.getAsyncConfigSymbol(c.settings(), c.model()) + .orElseThrow(), retryStrategyOptions, standardRetryStrategy, configSource, diff --git a/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/PythonCodegenTest.java b/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/PythonCodegenTest.java index 929aeb602..4ab7818b7 100644 --- a/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/PythonCodegenTest.java +++ b/codegen/core/src/it/java/software/amazon/smithy/python/codegen/test/PythonCodegenTest.java @@ -4,6 +4,10 @@ */ package software.amazon.smithy.python.codegen.test; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -20,7 +24,7 @@ public class PythonCodegenTest { @Test - public void testCodegen(@TempDir Path tempDir) { + public void testCodegen(@TempDir Path tempDir) throws IOException { // TODO: Move this to its own package once client codegen is in its own package PythonClientCodegenPlugin plugin = new PythonClientCodegenPlugin(); Model model = Model.assembler(PythonCodegenTest.class.getClassLoader()) @@ -38,5 +42,9 @@ public void testCodegen(@TempDir Path tempDir) { .model(model) .build(); plugin.execute(context); + + var client = Files.readString(tempDir.resolve("src/weather/client.py")); + assertFalse(client.contains("retry_mode=")); + assertFalse(client.contains("max_attempts=")); } } 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 4b25001ea..c451b3873 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 @@ -66,7 +66,7 @@ private void generateService(PythonWriter writer) { // the rest keep the synchronous constructor with old Config. var asyncConfigSymbol = CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()); - // Collect service-scoped plugins (stored in __init__, applied per-call). + // Collect service-scoped plugins applied once during setup. var servicePlugins = new LinkedHashSet(); for (PythonIntegration integration : context.integrations()) { for (RuntimeClientPlugin runtimeClientPlugin : integration.getClientPlugins(context)) { @@ -76,60 +76,57 @@ private void generateService(PythonWriter writer) { } } - if (asyncConfigSymbol.isPresent()) { - writer.addStdlibImport("asyncio"); + // Resolve or construct the config lazily before applying plugins once. + var isAsyncConfig = asyncConfigSymbol.isPresent(); + var configSym = asyncConfigSymbol.orElse(configSymbol); + writer.addStdlibImport("asyncio"); + writer.addStdlibImport("copy", "deepcopy"); - writer.write(""" - def __init__( - self, - config: $1T | None = None, - plugins: list[$2T] | None = None, - ): - ${3C|} - self._config = config - self._plugins = plugins - self._derive_lock = asyncio.Lock() - self._retry_strategy_resolver = $4T() - self._client_plugins: list[$2T] = [ - ${5C|} - ] - - async def _ensure_setup(self) -> None: - if self._config is None: - async with self._derive_lock: - if self._config is None: - self._config = await $1T.resolve() - """, - asyncConfigSymbol.get(), - pluginSymbol, - writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), - RuntimeTypes.RETRY_STRATEGY_RESOLVER, - writer.consumer(w -> writeDefaultPlugins(w, servicePlugins))); - } else { + writer.write(""" + def __init__( + self, + config: $1T | None = None, + plugins: list[$2T] | None = None, + ): + ${3C|} + self._config = config + self._plugins = plugins + self._derive_lock = asyncio.Lock() + self._setup_done = False + self._retry_strategy_resolver = $4T() + self._client_plugins: list[$2T] = [ + ${5C|} + ] - 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() - self._client_plugins: list[$2T] = [ - ${5C|} - ] - - async def _ensure_setup(self) -> None: - pass - """, - configSymbol, - pluginSymbol, - writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), - RuntimeTypes.RETRY_STRATEGY_RESOLVER, - writer.consumer(w -> writeDefaultPlugins(w, servicePlugins))); - } + async def _ensure_setup(self) -> None: + if not self._setup_done: + async with self._derive_lock: + if not self._setup_done: + if self._config is None: + ${6C|} + else: + # Copy so plugins don't mutate the caller's config. + config = deepcopy(self._config) + for plugin in self._client_plugins: + plugin(config) + if self._plugins: + for plugin in self._plugins: + plugin(config) + self._config = config + self._setup_done = True + """, + configSym, + pluginSymbol, + writer.consumer(w -> writeConstructorDocs(w, serviceSymbol.getName())), + RuntimeTypes.RETRY_STRATEGY_RESOLVER, + writer.consumer(w -> writeDefaultPlugins(w, servicePlugins)), + writer.consumer(w -> { + if (isAsyncConfig) { + w.write("config = await $T.resolve()", configSym); + } else { + w.write("config = $T()", configSym); + } + })); var topDownIndex = TopDownIndex.of(model); var eventStreamIndex = EventStreamIndex.of(model); @@ -184,8 +181,8 @@ private void writeConstructorDocs(PythonWriter writer, String clientName) { Optional configuration for the client. Here you can set things like the endpoint for HTTP services or auth credentials. plugins: - A list of callables that modify the configuration dynamically. These - can be used to set defaults, for example. + A list of callables applied once to the client's base configuration. + Their changes are inherited by every operation invocation. """, clientName); }); } @@ -282,19 +279,15 @@ private void writeSharedOperationInit( ] if plugins: operation_plugins.extend(plugins) - # deepcopy keeps plugin mutations (e.g. appending interceptors) scoped to - # this call, so applying client_plugins per-call cannot accumulate on the - # shared config. await self._ensure_setup() assert self._config is not None - config = deepcopy(self._config) - for plugin in self._client_plugins: - plugin(config) - if self._plugins: - for plugin in self._plugins: + if operation_plugins: + # Keep operation-plugin mutations scoped to this call. + config = deepcopy(self._config) + for plugin in operation_plugins: plugin(config) - for plugin in operation_plugins: - plugin(config) + else: + config = self._config if ( config.protocol is None or config.transport is None @@ -309,8 +302,7 @@ private void writeSharedOperationInit( retry_strategy = await self._retry_strategy_resolver.resolve_retry_strategy( retry_strategy=config.retry_strategy, - retry_mode=getattr(config, "retry_mode", None), - max_attempts=getattr(config, "max_attempts", None), + ${7C|} ) pipeline = $3T( @@ -333,7 +325,13 @@ private void writeSharedOperationInit( RuntimeTypes.REQUEST_PIPELINE, RuntimeTypes.CLIENT_CALL, RuntimeTypes.TYPED_PROPERTIES, - RuntimeTypes.INTERCEPTOR_CHAIN); + RuntimeTypes.INTERCEPTOR_CHAIN, + writer.consumer(w -> { + if (CodegenUtils.getAsyncConfigSymbol(context.settings(), context.model()).isPresent()) { + w.write("retry_mode=config.retry_mode,"); + w.write("max_attempts=config.max_attempts,"); + } + })); } 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 334eb8c65..87c60d6d3 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 @@ -270,15 +270,12 @@ public void run() { context.writerDelegator().useFileWriter(config.getDefinitionFile(), config.getNamespace(), writer -> { writeInterceptorsType(writer); - // For AWS services, old Config is no longer generated — only the async - // config subclass (emitted by AwsAsyncConfigIntegration via section interceptor). - // For non-AWS services, generate old Config as usual. + // AWS services generate only the async config subclass. if (asyncConfigForPlugin.isEmpty()) { generateConfig(context, writer); } - // Emit the async config section — AWS integrations intercept this - // to generate the service-specific async config subclass. + // AWS integrations intercept this section to emit the async config. writer.pushState(new AsyncConfigSection()); writer.popState(); }); @@ -287,8 +284,7 @@ public void run() { // like have a class to implement, but that seems unnecessarily burdensome for // a single function. // - // For AWS services, the Plugin type accepts only the async config. - // For non-AWS services, the Plugin type accepts only Config. + // Plugins accept the config type generated for the service. var plugin = CodegenUtils.getPluginSymbol(context.settings()); context.writerDelegator().useFileWriter(plugin.getDefinitionFile(), plugin.getNamespace(), writer -> { writer.addStdlibImport("typing", "Callable"); @@ -300,7 +296,11 @@ public void run() { } 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); + writer.writeDocs(""" + A callable that customizes a client configuration. Service-level plugins are + applied once to the base configuration inherited by every operation. + Operation-level plugins apply only to a single operation invocation. + """, context); }); } @@ -367,66 +367,26 @@ private void generateConfig(GenerationContext context, PythonWriter writer) { writer.pushState(new ConfigSection(finalProperties)); writer.addLocallyDefinedSymbol(configSymbol); writer.addStdlibImport("dataclasses", "dataclass"); - // 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. + // Only non-AWS services reach this path. + writer.write(""" + @dataclass(init=False) + class $L: + \"""Configuration for $L.\""" - .. deprecated:: - Use :class:`$L` with ``await $L.resolve()`` instead. - \""" + ${C|} + 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|} - - 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))); - } + """, + 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/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py index 3daadb989..4e7cad1f6 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/__init__.py @@ -1,7 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -from .aws_config import AsyncAwsConfig +from .aws_config import AsyncAwsConfig, AwsConfigOverrides from .context import SharedConfigContext, load_config, shared_config_files_exist from .exceptions import ( ConfigError, @@ -15,6 +15,7 @@ __all__ = [ "AsyncAwsConfig", + "AwsConfigOverrides", "ConfigError", "ConfigParseError", "ConfigSource", 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 21841a1b1..40361cce9 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,14 +1,16 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +from collections.abc import Mapping from dataclasses import dataclass, field, fields -from typing import TYPE_CHECKING, Any, ClassVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Self, TypedDict, Unpack -from smithy_core.retries import RetryStrategyOptions +from smithy_core.retries import RetryStrategyOptions, RetryStrategyType if TYPE_CHECKING: from smithy_core.aio.interfaces import ClientTransport from smithy_core.aio.interfaces.identity import IdentityResolver + from smithy_core.aio.interfaces.retries import RetryStrategy from smithy_core.interfaces import URI from smithy_http.interfaces import HTTPRequestConfiguration @@ -38,7 +40,28 @@ _CREDENTIAL_FIELDS = ("aws_access_key_id", "aws_secret_access_key", "aws_session_token") -@dataclass(kw_only=True) +class AwsConfigOverrides(TypedDict, total=False): + """Common keyword overrides accepted by AWS config resolution.""" + + region: str | None + retry_mode: RetryStrategyType | None + max_attempts: int | None + endpoint_uri: "str | URI | None" + aws_access_key_id: str | None + aws_secret_access_key: str | None + aws_session_token: str | None + aws_credentials_identity_resolver: ( + "IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None" + ) + sdk_ua_app_id: str | None + user_agent_extra: str | None + interceptors: list[Any] + http_request_config: "HTTPRequestConfiguration | None" + transport: "ClientTransport[Any, Any] | None" + retry_strategy: "RetryStrategy | RetryStrategyOptions | None" + + +@dataclass(kw_only=True, init=False) class AsyncAwsConfig: """Base configuration class for all AWS services. @@ -53,7 +76,7 @@ class AsyncAwsConfig: """The AWS region to connect to. """ - retry_mode: str | None = None + retry_mode: RetryStrategyType | 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 @@ -82,7 +105,11 @@ class AsyncAwsConfig: """ aws_session_token: str | None = field(default=None, repr=False) - """An access key ID that identifies temporary security credentials.""" + """The session token used with temporary AWS credentials. + + Set this together with ``aws_access_key_id`` and ``aws_secret_access_key`` + when supplying temporary credentials in code. + """ aws_credentials_identity_resolver: "IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None" = None """Resolves AWS Credentials. @@ -188,8 +215,8 @@ def __repr__(self) -> str: ) return f"{type(self).__name__}({rendered})" - def __post_init__(self) -> None: - """Block direct construction. Use resolve() instead.""" + def __init__(self) -> None: + """Block direct construction without advertising config fields as parameters.""" raise ConfigError( f"{type(self).__name__} cannot be constructed directly. " f"Use `await {type(self).__name__}.resolve(...)` instead." @@ -203,7 +230,7 @@ async def resolve( fs: FileSystem | None = None, config_file_path: str | None = None, credentials_file_path: str | None = None, - **overrides: Any, + **overrides: Unpack[AwsConfigOverrides], ) -> Self: """Resolve a config object from environment, config files, and defaults. @@ -219,6 +246,25 @@ async def resolve( the ``AWS_PROFILE`` environment variable but is not defined in the config files. """ + return await cls._resolve( + profile=profile, + fs=fs, + config_file_path=config_file_path, + credentials_file_path=credentials_file_path, + overrides=overrides, + ) + + @classmethod + async def _resolve( + cls, + *, + profile: str | None, + fs: FileSystem | None, + config_file_path: str | None, + credentials_file_path: str | None, + overrides: Mapping[str, object], + ) -> Self: + """Internal resolution entry point for generated typed config factories.""" ctx = SharedConfigContext( profile_name=profile, fs=fs, @@ -232,12 +278,12 @@ async def resolve( config_file = await ctx.parsed_profiles() validate_profile(ctx.profile_name, config_file.profiles, profile_origin) - # Create the instance bypassing __post_init__ check + # Create the instance without calling the blocked constructor instance = cls._create_instance() instance._ctx = ctx # Resolve each field - await instance._resolve_fields(overrides) + await instance._resolve_fields(dict(overrides)) return instance 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 3f6a66631..a2f03c4d1 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 @@ -93,9 +93,6 @@ async def resolve_retry_mode(ctx: SharedConfigContext) -> Resolved[str | None]: 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.", 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 6008632c5..816f175c4 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_resolver.py +++ b/packages/smithy-aws-core/tests/unit/config/test_resolver.py @@ -9,10 +9,11 @@ import os from copy import deepcopy from dataclasses import dataclass +from inspect import signature from unittest.mock import patch import pytest -from smithy_aws_core.config.aws_config import AsyncAwsConfig +from smithy_aws_core.config.aws_config import AsyncAwsConfig, AwsConfigOverrides from smithy_aws_core.config.context import SharedConfigContext from smithy_aws_core.config.exceptions import ( ConfigError, @@ -335,11 +336,21 @@ def test_direct_instantiation_raises_error(self): with pytest.raises(ConfigError, match="cannot be constructed directly"): AsyncAwsConfig() + def test_direct_constructor_does_not_advertise_config_fields(self): + assert not signature(AsyncAwsConfig).parameters + + def test_typed_overrides_cover_all_base_config_fields(self): + assert set(AwsConfigOverrides.__annotations__) == set( + AsyncAwsConfig._FIELDS # pyright: ignore[reportPrivateUsage] + ) + @pytest.mark.asyncio async def test_unknown_override_field_raises_error(self): with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): with pytest.raises(ConfigValidationError, match="Unknown config field"): - await AsyncAwsConfig.resolve(reigon="us-west-2") + await AsyncAwsConfig.resolve( + reigon="us-west-2" # pyright: ignore[reportCallIssue] + ) @pytest.mark.parametrize( "field_name,invalid_value,match", @@ -545,24 +556,6 @@ 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 @@ -839,7 +832,7 @@ async def test_subclass_repr_does_not_leak_secrets(self): This mirrors what codegen emits for service-specific async configs. """ - @dataclass(kw_only=True, repr=False) + @dataclass(kw_only=True, repr=False, init=False) class ServiceConfig(AsyncAwsConfig): aws_access_key_id: str | None = None aws_secret_access_key: str | None = None From 6de5ae66be5097a964aa1ef262a6138f2b4f9fce Mon Sep 17 00:00:00 2001 From: Antonio Aranda <102337110+arandito@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:03:21 -0400 Subject: [PATCH 3/4] Fix AsyncAwsConfig.__init__ to raise correct config error --- .../src/smithy_aws_core/config/aws_config.py | 4 ++-- .../smithy-aws-core/tests/unit/config/test_resolver.py | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) 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 40361cce9..c5b960157 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 @@ -215,8 +215,8 @@ def __repr__(self) -> str: ) return f"{type(self).__name__}({rendered})" - def __init__(self) -> None: - """Block direct construction without advertising config fields as parameters.""" + def __init__(self, *args: object, **kwargs: object) -> None: + """Block direct construction.""" raise ConfigError( f"{type(self).__name__} cannot be constructed directly. " f"Use `await {type(self).__name__}.resolve(...)` instead." 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 816f175c4..794a10439 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_resolver.py +++ b/packages/smithy-aws-core/tests/unit/config/test_resolver.py @@ -336,8 +336,15 @@ def test_direct_instantiation_raises_error(self): with pytest.raises(ConfigError, match="cannot be constructed directly"): AsyncAwsConfig() + def test_direct_instantiation_with_arguments_raises_error(self): + with pytest.raises(ConfigError, match="cannot be constructed directly"): + AsyncAwsConfig("unexpected", region="us-east-1") + def test_direct_constructor_does_not_advertise_config_fields(self): - assert not signature(AsyncAwsConfig).parameters + constructor_parameters = signature(AsyncAwsConfig).parameters + assert not set(constructor_parameters) & set( + AsyncAwsConfig._FIELDS # pyright: ignore[reportPrivateUsage] + ) def test_typed_overrides_cover_all_base_config_fields(self): assert set(AwsConfigOverrides.__annotations__) == set( From ab18990fc2ed2fea0144a8e76d6f648757b72bb6 Mon Sep 17 00:00:00 2001 From: Antonio Aranda <102337110+arandito@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:13:55 -0400 Subject: [PATCH 4/4] Fix docstring --- .../smithy-aws-core/src/smithy_aws_core/config/aws_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c5b960157..d48f1c51e 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 @@ -216,7 +216,7 @@ def __repr__(self) -> str: return f"{type(self).__name__}({rendered})" def __init__(self, *args: object, **kwargs: object) -> None: - """Block direct construction.""" + """Block direct construction without advertising config fields as parameters.""" raise ConfigError( f"{type(self).__name__} cannot be constructed directly. " f"Use `await {type(self).__name__}.resolve(...)` instead."