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 8358c54b..d2b67875 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsAuthIntegration.java @@ -5,6 +5,7 @@ package software.amazon.smithy.python.aws.codegen; import static software.amazon.smithy.python.aws.codegen.AwsConfiguration.REGION; +import static software.amazon.smithy.python.aws.codegen.AwsConfiguration.RETRY_STRATEGY; import java.util.List; import java.util.Set; @@ -64,6 +65,7 @@ public List getClientPlugins(GenerationContext context) { .nullable(true) .build()) .addConfigProperty(REGION) + .addConfigProperty(RETRY_STRATEGY) .addConfigProperty(ConfigProperty.builder() .name("aws_access_key_id") .type(Symbol.builder().name("str").build()) diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfiguration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfiguration.java index 5d3ba035..99dde994 100644 --- a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfiguration.java +++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsConfiguration.java @@ -20,5 +20,45 @@ private AwsConfiguration() {} .type(Symbol.builder().name("str").build()) .documentation(" The AWS region to connect to. The configured region is used to " + "determine the service endpoint.") + .nullable(false) + .useDescriptor(true) + .validator(Symbol.builder() + .name("validate_region") + .namespace("smithy_aws_core.config.validators", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build()) + .defaultValue("'us-east-1'") + .build(); + + public static final ConfigProperty RETRY_STRATEGY = ConfigProperty.builder() + .name("retry_strategy") + .type(Symbol.builder() + .name("RetryStrategy | RetryStrategyOptions") + .addReference(Symbol.builder() + .name("RetryStrategy") + .namespace("smithy_core.interfaces.retries", ".") + .addDependency(software.amazon.smithy.python.codegen.SmithyPythonDependency.SMITHY_CORE) + .build()) + .addReference(Symbol.builder() + .name("RetryStrategyOptions") + .namespace("smithy_core.retries", ".") + .addDependency(software.amazon.smithy.python.codegen.SmithyPythonDependency.SMITHY_CORE) + .build()) + .build()) + .documentation( + "The retry strategy or options for configuring retry behavior. Can be either a configured RetryStrategy or RetryStrategyOptions to create one.") + .nullable(false) + .useDescriptor(true) + .validator(Symbol.builder() + .name("validate_retry_strategy") + .namespace("smithy_aws_core.config.validators", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build()) + .customResolver(Symbol.builder() + .name("resolve_retry_strategy") + .namespace("smithy_aws_core.config.custom_resolvers", ".") + .addDependency(AwsPythonDependency.SMITHY_AWS_CORE) + .build()) + .defaultValue("RetryStrategyOptions(retry_mode=\"standard\", max_attempts=3)") .build(); } diff --git a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ConfigProperty.java b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ConfigProperty.java index 7a8b1d67..bddbfe6d 100644 --- a/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ConfigProperty.java +++ b/codegen/core/src/main/java/software/amazon/smithy/python/codegen/ConfigProperty.java @@ -23,6 +23,10 @@ public final class ConfigProperty implements ToSmithyBuilder { private final boolean nullable; private final String documentation; private final Consumer initialize; + private final Symbol validator; + private final Symbol customResolver; + private final boolean useDescriptor; + private final String defaultValue; /** * Constructor. @@ -33,6 +37,10 @@ private ConfigProperty(Builder builder) { this.nullable = builder.nullable; this.documentation = Objects.requireNonNull(builder.documentation); this.initialize = Objects.requireNonNull(builder.initialize); + this.validator = builder.validator; + this.customResolver = builder.customResolver; + this.useDescriptor = builder.useDescriptor; + this.defaultValue = builder.defaultValue; } /** @@ -63,6 +71,34 @@ public String documentation() { return documentation; } + /** + * @return Returns the validator symbol for this property, if any. + */ + public java.util.Optional validator() { + return java.util.Optional.ofNullable(validator); + } + + /** + * @return Returns the custom resolver symbol for this property, if any. + */ + public java.util.Optional customResolver() { + return java.util.Optional.ofNullable(customResolver); + } + + /** + * @return Returns whether this property uses the ConfigProperty descriptor. + */ + public boolean useDescriptor() { + return useDescriptor; + } + + /** + * @return Returns the default value for this property, if any. + */ + public java.util.Optional defaultValue() { + return java.util.Optional.ofNullable(defaultValue); + } + /** * Initializes the config field on the config object. * @@ -94,7 +130,11 @@ public SmithyBuilder toBuilder() { .type(type) .nullable(nullable) .documentation(documentation) - .initialize(initialize); + .initialize(initialize) + .validator(validator) + .customResolver(customResolver) + .useDescriptor(useDescriptor) + .defaultValue(defaultValue); } /** @@ -107,6 +147,11 @@ public static final class Builder implements SmithyBuilder { private String documentation; private Consumer initialize = writer -> writer.write("self.$1L = $1L", name); + private Symbol validator; + private Symbol customResolver; + private boolean useDescriptor = false; + private String defaultValue; + @Override public ConfigProperty build() { return new ConfigProperty(this); @@ -182,5 +227,49 @@ public Builder initialize(Consumer initialize) { this.initialize = initialize; return this; } + + /** + * Sets the validator symbol for the config property. + * + * @param validator The validator function symbol. + * @return Returns the builder. + */ + public Builder validator(Symbol validator) { + this.validator = validator; + return this; + } + + /** + * Sets the custom resolver symbol for the config property. + * + * @param customResolver The custom resolver function symbol. + * @return Returns the builder. + */ + public Builder customResolver(Symbol customResolver) { + this.customResolver = customResolver; + return this; + } + + /** + * Sets whether the config property uses the ConfigProperty descriptor. + * + * @param useDescriptor Whether to use the descriptor pattern. + * @return Returns the builder. + */ + public Builder useDescriptor(boolean useDescriptor) { + this.useDescriptor = useDescriptor; + return this; + } + + /** + * Sets the default value for the config property. + * + * @param defaultValue The default value as a Python expression string. + * @return Returns the builder. + */ + public Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } } } 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 de03d42d..ff304c68 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 @@ -52,24 +52,6 @@ public final class ConfigGenerator implements Runnable { .nullable(false) .initialize(writer -> writer.write("self.interceptors = interceptors or []")) .build(), - ConfigProperty.builder() - .name("retry_strategy") - .type(Symbol.builder() - .name("RetryStrategy | RetryStrategyOptions") - .addReference(Symbol.builder() - .name("RetryStrategy") - .namespace("smithy_core.interfaces.retries", ".") - .addDependency(SmithyPythonDependency.SMITHY_CORE) - .build()) - .addReference(Symbol.builder() - .name("RetryStrategyOptions") - .namespace("smithy_core.retries", ".") - .addDependency(SmithyPythonDependency.SMITHY_CORE) - .build()) - .build()) - .documentation( - "The retry strategy or options for configuring retry behavior. Can be either a configured RetryStrategy or RetryStrategyOptions to create one.") - .build(), ConfigProperty.builder() .name("endpoint_uri") .type(Symbol.builder() @@ -98,6 +80,18 @@ public final class ConfigGenerator implements Runnable { writer.write("self.endpoint_resolver = endpoint_resolver or StaticEndpointResolver()"); writer.popState(); }) + .build(), + ConfigProperty.builder() + .name("retry_strategy") + .type(Symbol.builder() + .name("RetryStrategy") + .addReference(Symbol.builder() + .name("RetryStrategy") + .namespace("smithy_core.interfaces.retries", ".") + .addDependency(SmithyPythonDependency.SMITHY_CORE) + .build()) + .build()) + .documentation("The retry strategy for the client.") .build()); // This list contains any properties that must be added to any http-based @@ -310,8 +304,20 @@ private void writeInterceptorsType(PythonWriter writer) { private void generateConfig(GenerationContext context, PythonWriter writer) { var configSymbol = CodegenUtils.getConfigSymbol(context.settings()); - // Initialize a set of config properties with our base properties. + // Initialize a set of config properties. var properties = new TreeSet<>(Comparator.comparing(ConfigProperty::name)); + + var model = context.model(); + var service = context.settings().service(model); + + for (PythonIntegration integration : context.integrations()) { + for (RuntimeClientPlugin plugin : integration.getClientPlugins(context)) { + if (plugin.matchesService(model, service)) { + properties.addAll(plugin.getConfigProperties()); + } + } + } + properties.addAll(BASE_PROPERTIES); properties.addAll(getProtocolProperties(context)); @@ -322,19 +328,44 @@ private void generateConfig(GenerationContext context, PythonWriter writer) { writer.onSection(new AddAuthHelper()); } - var model = context.model(); - var service = context.settings().service(model); + writer.onSection(new AddGetSourceHelper()); - // Add any relevant config properties from plugins. - for (PythonIntegration integration : context.integrations()) { - for (RuntimeClientPlugin plugin : integration.getClientPlugins(context)) { - if (plugin.matchesService(model, service)) { - properties.addAll(plugin.getConfigProperties()); + var finalProperties = List.copyOf(properties); + + // Check if any properties use descriptors + boolean hasDescriptors = finalProperties.stream().anyMatch(ConfigProperty::useDescriptor); + + // Add config resolution imports only if there are descriptor properties + // So not for generic clients + if (hasDescriptors) { + writer.addDependency(SmithyPythonDependency.SMITHY_CORE); + writer.addImport("smithy_core.config.property", "ConfigProperty"); + writer.addImport("smithy_core.config.resolver", "ConfigResolver"); + writer.addImport("smithy_aws_core.config.sources", "EnvironmentSource"); + + // Add validator and resolver imports for properties that use descriptors + for (ConfigProperty property : finalProperties) { + if (property.useDescriptor()) { + if (property.validator().isPresent()) { + var validatorSymbol = property.validator().get(); + writer.addImport(validatorSymbol.getNamespace(), validatorSymbol.getName()); + } + if (property.customResolver().isPresent()) { + var resolverSymbol = property.customResolver().get(); + writer.addImport(resolverSymbol.getNamespace(), resolverSymbol.getName()); + } + // Add imports for types referenced in default values + if (property.defaultValue().isPresent()) { + var defaultValue = property.defaultValue().get(); + if (defaultValue.contains("RetryStrategyOptions")) { + writer.addDependency(SmithyPythonDependency.SMITHY_CORE); + writer.addImport("smithy_core.retries", "RetryStrategyOptions"); + } + } } } } - var finalProperties = List.copyOf(properties); final String serviceId = context.settings() .service(context.model()) .getTrait(ServiceTrait.class) @@ -349,6 +380,8 @@ class $L: ${C|} + ${C|} + def __init__( self, *, @@ -358,14 +391,73 @@ def __init__( """, configSymbol.getName(), serviceId, + writer.consumer(w -> writeDescriptorDeclarations(w, finalProperties)), writer.consumer(w -> writePropertyDeclarations(w, finalProperties)), writer.consumer(w -> writeInitParams(w, finalProperties)), writer.consumer(w -> initializeProperties(w, finalProperties))); writer.popState(); } + // Write descriptor declarations for properties using ConfigProperty descriptor + private void writeDescriptorDeclarations(PythonWriter writer, Collection properties) { + boolean hasDescriptors = properties.stream().anyMatch(ConfigProperty::useDescriptor); + + if (!hasDescriptors) { + return; + } + + writer.write("# Config properties using descriptors (lazy resolution with caching)"); + writer.write("# Dictionary approach avoids duplicating property names"); + writer.write("_descriptors = {"); + writer.indent(); + + for (ConfigProperty property : properties) { + if (property.useDescriptor()) { + writer.writeInline("'$L': ConfigProperty('$L'", + property.name(), + property.name()); + + if (property.validator().isPresent()) { + writer.writeInline(", validator=$L", property.validator().get().getName()); + } + + if (property.customResolver().isPresent()) { + writer.writeInline(", resolver_func=$L", property.customResolver().get().getName()); + } + + if (property.defaultValue().isPresent()) { + writer.writeInline(", default_value=$L", property.defaultValue().get()); + } + + writer.write("),"); + } + } + + writer.dedent(); + writer.write("}"); + writer.write(""); + + for (ConfigProperty property : properties) { + if (property.useDescriptor()) { + var typeHint = property.isNullable() + ? "$T | None" + : "$T"; + writer.write("$L: " + typeHint + " = _descriptors['$L'] # type: ignore[assignment]", + property.name(), + property.type(), + property.name()); + } + } + writer.write(""); + } + private void writePropertyDeclarations(PythonWriter writer, Collection properties) { for (ConfigProperty property : properties) { + // Skip descriptor properties - they're declared above + if (property.useDescriptor()) { + continue; + } + var formatString = property.isNullable() ? "$L: $T | None" : "$L: $T"; @@ -373,6 +465,10 @@ private void writePropertyDeclarations(PythonWriter writer, Collection properties) { @@ -382,8 +478,34 @@ private void writeInitParams(PythonWriter writer, Collection pro } private void initializeProperties(PythonWriter writer, Collection properties) { + // Initialize the resolver + writer.write("# Create resolver with environment source"); + writer.write("self._resolver = ConfigResolver(sources=[EnvironmentSource()])"); + writer.write(""); + + var descriptorProperties = properties.stream() + .filter(ConfigProperty::useDescriptor) + .toList(); + + if (!descriptorProperties.isEmpty()) { + writer.write("# Set instance values for descriptor properties"); + writer.write("# Only set if provided (not None) to allow resolution from sources"); + writer.write("for key in self.__class__._descriptors.keys():"); + writer.indent(); + writer.write("value = locals().get(key)"); + writer.write("if value is not None:"); + writer.indent(); + writer.write("setattr(self, key, value)"); + writer.dedent(); + writer.dedent(); + writer.write(""); + } + + // Finally, initialize non-descriptor properties normally for (ConfigProperty property : properties) { - property.initialize(writer); + if (!property.useDescriptor()) { + property.initialize(writer); + } } } @@ -418,4 +540,44 @@ def set_auth_scheme(self, scheme: AuthScheme[Any, Any, Any, Any]) -> None: """); } } + + // Helper to add get_source method for descriptor properties + private static final class AddGetSourceHelper implements CodeInterceptor { + @Override + public Class sectionType() { + return ConfigSection.class; + } + + @Override + public void write(PythonWriter writer, String previousText, ConfigSection section) { + // Check if there are any descriptor properties + boolean hasDescriptors = section.properties() + .stream() + .anyMatch(ConfigProperty::useDescriptor); + + if (!hasDescriptors) { + // No descriptor properties, just write previous text + writer.write(previousText); + return; + } + + writer.write(previousText); + + writer.write(""" + + def get_source(self, key: str) -> str | None: + \"""Get the source that provided a configuration value. + + Args: + key: The configuration key (e.g., 'region', 'retry_strategy') + + Returns: + The source name ('instance', 'environment', etc.), + or None if the key hasn't been resolved yet. + \""" + cached = self.__dict__.get(f'_cache_{key}') + return cached[1] if cached else None + """); + } + } }