Skip to content

Commit dce5fa7

Browse files
authored
Integrate identity chain into SDK client construction (#771)
1 parent 3addba0 commit dce5fa7

9 files changed

Lines changed: 213 additions & 22 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.python.aws.codegen;
6+
7+
import java.util.List;
8+
import software.amazon.smithy.aws.traits.auth.SigV4Trait;
9+
import software.amazon.smithy.codegen.core.Symbol;
10+
import software.amazon.smithy.python.codegen.GenerationContext;
11+
import software.amazon.smithy.python.codegen.SmithyPythonDependency;
12+
import software.amazon.smithy.python.codegen.integrations.PythonIntegration;
13+
import software.amazon.smithy.python.codegen.sections.ClientSetupSection;
14+
import software.amazon.smithy.python.codegen.writer.PythonWriter;
15+
import software.amazon.smithy.utils.CodeInterceptor;
16+
import software.amazon.smithy.utils.CodeSection;
17+
import software.amazon.smithy.utils.SmithyInternalApi;
18+
19+
/**
20+
* Sets up the default AWS credentials identity chain during client setup.
21+
*/
22+
@SmithyInternalApi
23+
public class AwsIdentityIntegration implements PythonIntegration {
24+
25+
@Override
26+
public List<? extends CodeInterceptor<? extends CodeSection, PythonWriter>> interceptors(
27+
GenerationContext context
28+
) {
29+
var service = context.settings().service(context.model());
30+
if (!service.hasTrait(SigV4Trait.class)) {
31+
return List.of();
32+
}
33+
return List.of(new CredentialsIdentitySetupInterceptor());
34+
}
35+
36+
/**
37+
* Initializes the default AWS credentials identity chain during client setup.
38+
*/
39+
private static final class CredentialsIdentitySetupInterceptor
40+
implements CodeInterceptor<ClientSetupSection, PythonWriter> {
41+
42+
@Override
43+
public Class<ClientSetupSection> sectionType() {
44+
return ClientSetupSection.class;
45+
}
46+
47+
@Override
48+
public void write(PythonWriter writer, String previousText, ClientSetupSection section) {
49+
writer.write(previousText);
50+
writer.addStdlibImport("typing", "cast");
51+
writer.write("""
52+
if self._config.aws_credentials_identity_resolver is None:
53+
config_context = self._config.resolution_context()
54+
config_file = None
55+
profile_name = None
56+
if config_context is not None:
57+
config_file = await config_context.parsed_profiles()
58+
if config_context.profile_source is $4T.OVERRIDE:
59+
profile_name = config_context.profile_name
60+
self._config.aws_credentials_identity_resolver = await $1T.create(
61+
$2T,
62+
config_file=config_file,
63+
profile_name=profile_name,
64+
region_override=self._config.region,
65+
http_client=cast($3T | None, self._config.transport),
66+
)""",
67+
Symbol.builder()
68+
.name("IdentityChain")
69+
.namespace("smithy_aws_core.identity.chain", ".")
70+
.addDependency(AwsPythonDependency.SMITHY_AWS_CORE)
71+
.build(),
72+
Symbol.builder()
73+
.name("AWSCredentialsIdentity")
74+
.namespace("smithy_aws_core.identity", ".")
75+
.addDependency(AwsPythonDependency.SMITHY_AWS_CORE)
76+
.build(),
77+
Symbol.builder()
78+
.name("HTTPClient")
79+
.namespace("smithy_http.aio.interfaces", ".")
80+
.addDependency(SmithyPythonDependency.SMITHY_HTTP)
81+
.build(),
82+
Symbol.builder()
83+
.name("ConfigSource")
84+
.namespace("smithy_aws_core.config", ".")
85+
.addDependency(AwsPythonDependency.SMITHY_AWS_CORE)
86+
.build());
87+
}
88+
}
89+
}

codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
software.amazon.smithy.python.aws.codegen.customizations.apigateway.ApiGatewayIntegration
77
software.amazon.smithy.python.aws.codegen.AwsAuthIntegration
8+
software.amazon.smithy.python.aws.codegen.AwsIdentityIntegration
89
software.amazon.smithy.python.aws.codegen.AwsProtocolsIntegration
910
software.amazon.smithy.python.aws.codegen.AwsServiceIdIntegration
1011
software.amazon.smithy.python.aws.codegen.AwsUserAgentIntegration

codegen/core/src/main/java/software/amazon/smithy/python/codegen/ClientGenerator.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import software.amazon.smithy.model.traits.StringTrait;
2323
import software.amazon.smithy.python.codegen.integrations.PythonIntegration;
2424
import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin;
25+
import software.amazon.smithy.python.codegen.sections.ClientSetupSection;
2526
import software.amazon.smithy.python.codegen.writer.PythonWriter;
2627
import software.amazon.smithy.utils.SmithyInternalApi;
2728

@@ -113,6 +114,7 @@ async def _ensure_setup(self) -> None:
113114
for plugin in self._plugins:
114115
plugin(config)
115116
self._config = config
117+
${7C|}
116118
self._setup_done = True
117119
""",
118120
configSym,
@@ -126,6 +128,10 @@ async def _ensure_setup(self) -> None:
126128
} else {
127129
w.write("config = $T()", configSym);
128130
}
131+
}),
132+
writer.consumer(w -> {
133+
w.pushState(new ClientSetupSection());
134+
w.popState();
129135
}));
130136

131137
var topDownIndex = TopDownIndex.of(model);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.python.codegen.sections;
6+
7+
import software.amazon.smithy.utils.CodeSection;
8+
9+
/**
10+
* Section for service-specific lazy client setup.
11+
*/
12+
public record ClientSetupSection() implements CodeSection {}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "enhancement",
3+
"description": "Updated `SharedConfigContext` to track the source of the active profile as a `ConfigSource`."
4+
}

packages/smithy-aws-core/src/smithy_aws_core/config/aws_config.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
from collections.abc import Mapping
5+
from copy import deepcopy
56
from dataclasses import dataclass, field, fields
67
from typing import TYPE_CHECKING, Any, ClassVar, Self, TypedDict, Unpack
78

@@ -272,11 +273,11 @@ async def _resolve(
272273
credentials_file_path=credentials_file_path,
273274
)
274275

275-
# Fail fast on a bad profile
276-
profile_origin = ctx.profile_origin
277-
if profile_origin is not None:
276+
# Fail fast on a bad profile when one was provided (not the default)
277+
profile_source = ctx.profile_source
278+
if profile_source is not ConfigSource.DEFAULT:
278279
config_file = await ctx.parsed_profiles()
279-
validate_profile(ctx.profile_name, config_file.profiles, profile_origin)
280+
validate_profile(ctx.profile_name, config_file.profiles, profile_source)
280281

281282
# Create the instance without calling the blocked constructor
282283
instance = cls._create_instance()
@@ -439,3 +440,18 @@ def __setattr__(self, name: str, value: Any) -> None:
439440
spec.validator(value)
440441
self._sources[name] = ConfigSource.OVERRIDE
441442
super().__setattr__(name, value)
443+
444+
def __deepcopy__(self, memo: dict[int, Any]) -> Self:
445+
"""Deep-copy the config while sharing resources that must not be duplicated."""
446+
for shared in (
447+
self.aws_credentials_identity_resolver,
448+
self.transport,
449+
self.retry_strategy,
450+
):
451+
if shared is not None:
452+
memo[id(shared)] = shared
453+
new = self._create_instance()
454+
memo[id(self)] = new
455+
for f in fields(self):
456+
object.__setattr__(new, f.name, deepcopy(getattr(self, f.name), memo))
457+
return new

packages/smithy-aws-core/src/smithy_aws_core/config/context.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from .filesystem import DefaultFileSystem, FileSystem
1515
from .merged_config import MergedConfig
16+
from .types import ConfigSource
1617

1718
logger = logging.getLogger(__name__)
1819

@@ -136,7 +137,7 @@ def __init__(
136137
"""
137138
self._fs: FileSystem = fs if fs is not None else DefaultFileSystem()
138139
self._http_client: Any | None = http_client
139-
self._profile_name, self._profile_origin = self._resolve_profile_name(
140+
self._profile_name, self._profile_source = self._resolve_profile_name(
140141
profile_name
141142
)
142143
self._config_file_path: Path | None = (
@@ -153,9 +154,9 @@ def profile_name(self) -> str:
153154
return self._profile_name
154155

155156
@property
156-
def profile_origin(self) -> str | None:
157-
"""Where the active profile name came from, or None if it defaulted."""
158-
return self._profile_origin
157+
def profile_source(self) -> ConfigSource:
158+
"""The source of the active profile name."""
159+
return self._profile_source
159160

160161
@property
161162
def fs(self) -> FileSystem:
@@ -197,19 +198,20 @@ async def parsed_profiles(self) -> MergedConfig:
197198

198199
def _resolve_profile_name(
199200
self, explicit_profile: str | None
200-
) -> tuple[str, str | None]:
201+
) -> tuple[str, ConfigSource]:
201202
"""Determine the active profile name and where it came from.
202203
203204
Priority: explicit argument > AWS_PROFILE env var > "default"
204205
205-
:returns: Tuple of (profile_name, origin), where origin describes the
206-
source for error messages and is None when the name was defaulted.
206+
:returns: Tuple of (profile_name, source), where source is the
207+
provenance of the name: ``OVERRIDE`` for the explicit argument,
208+
``ENV`` for ``AWS_PROFILE``, and ``DEFAULT`` for the fallback.
207209
"""
208210
if explicit_profile is not None:
209-
return explicit_profile, "the profile argument"
211+
return explicit_profile, ConfigSource.OVERRIDE
210212

211213
env_profile = os.environ.get(_PROFILE_ENV_VAR)
212-
if env_profile is not None:
213-
return env_profile, _PROFILE_ENV_VAR
214+
if env_profile:
215+
return env_profile, ConfigSource.ENV
214216

215-
return _DEFAULT_PROFILE, None
217+
return _DEFAULT_PROFILE, ConfigSource.DEFAULT

packages/smithy-aws-core/src/smithy_aws_core/config/validators.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from smithy_core.retries import RetryStrategyType
99

1010
from .exceptions import ConfigValidationError, ProfileNotFoundError
11+
from .types import ConfigSource
1112

1213
_REGION_PATTERN = re.compile(r"^(?![0-9]+$)(?!-)[a-zA-Z0-9-]{1,63}(?<!-)$")
1314

@@ -84,16 +85,22 @@ def validate_max_attempts(
8485
def validate_profile(
8586
profile_name: str,
8687
available_profiles: Collection[str],
87-
origin: str,
88+
source: ConfigSource,
8889
) -> None:
8990
"""Validate that a requested profile exists in the config files.
9091
9192
:param profile_name: The active profile name to check.
9293
:param available_profiles: Profile names defined in the config files.
93-
:param origin: Where the profile name came from, used in the error message.
94+
:param source: Where the profile name came from, used to format the error.
9495
:raises ProfileNotFoundError: If the profile is not defined.
9596
"""
9697
if profile_name not in available_profiles:
98+
# Only OVERRIDE and ENV reach here; a DEFAULT profile is never validated.
99+
source_str = (
100+
"profile argument"
101+
if source is ConfigSource.OVERRIDE
102+
else "AWS_PROFILE environment variable"
103+
)
97104
raise ProfileNotFoundError(
98-
f"Profile {profile_name!r} (from {origin}) not found in config file."
105+
f"Profile {profile_name!r} from {source_str} was not found in config file."
99106
)

packages/smithy-aws-core/tests/unit/config/test_resolver.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
resolve_sdk_ua_app_id,
3030
)
3131
from smithy_aws_core.config.types import UNSET, ConfigSource
32+
from smithy_aws_core.identity.environment import EnvironmentCredentialsResolver
3233
from smithy_aws_core.identity.static import StaticCredentialsResolver
34+
from smithy_core.aio.retries import StandardRetryStrategy
35+
from smithy_http.interfaces import HTTPRequestConfiguration
36+
from smithy_http.testing import MockHTTPClient
3337

3438

3539
class NullFileSystem:
@@ -190,7 +194,8 @@ async def test_invalid_override_triggers_validator(self):
190194
async def test_invalid_profile_raises_error(self):
191195
with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True):
192196
with pytest.raises(
193-
ProfileNotFoundError, match="'FOOBAR' \\(from the profile argument\\)"
197+
ProfileNotFoundError,
198+
match="Profile 'FOOBAR' from profile argument was not found in config file",
194199
):
195200
await AsyncAwsConfig.resolve(
196201
profile="FOOBAR",
@@ -205,7 +210,8 @@ async def test_invalid_profile_set_via_env_var_raises_error(self):
205210
clear=True,
206211
):
207212
with pytest.raises(
208-
ProfileNotFoundError, match="'FOOBAR' \\(from AWS_PROFILE\\)"
213+
ProfileNotFoundError,
214+
match="Profile 'FOOBAR' from AWS_PROFILE environment variable was not found in config file",
209215
):
210216
await AsyncAwsConfig.resolve(
211217
fs=NullFileSystem(),
@@ -216,7 +222,8 @@ async def test_unknown_profile_raises_when_config_file_has_others(self):
216222
fs = FakeFileSystem({"/fake/config": "[profile work]\nregion = eu-west-1\n"})
217223
with patch.dict(os.environ, {}, clear=True):
218224
with pytest.raises(
219-
ProfileNotFoundError, match="'other' \\(from the profile argument\\)"
225+
ProfileNotFoundError,
226+
match="Profile 'other' from profile argument was not found in config file",
220227
):
221228
await AsyncAwsConfig.resolve(
222229
profile="other",
@@ -230,7 +237,8 @@ async def test_invalid_aws_profile_env_raises_profile_error(self):
230237
fs = FakeFileSystem({"/fake/config": "[profile work]\nregion = eu-west-1\n"})
231238
with patch.dict(os.environ, {"AWS_PROFILE": "wrok"}, clear=True):
232239
with pytest.raises(
233-
ProfileNotFoundError, match="'wrok' \\(from AWS_PROFILE\\)"
240+
ProfileNotFoundError,
241+
match="Profile 'wrok' from AWS_PROFILE environment variable was not found in config file",
234242
):
235243
await AsyncAwsConfig.resolve(
236244
fs=fs,
@@ -396,16 +404,25 @@ def test_default_profile_is_default(self):
396404
with patch.dict(os.environ, {}, clear=True):
397405
ctx = SharedConfigContext()
398406
assert ctx.profile_name == "default"
407+
assert ctx.profile_source is ConfigSource.DEFAULT
399408

400409
def test_profile_from_aws_profile_env(self):
401410
with patch.dict(os.environ, {"AWS_PROFILE": "work"}, clear=True):
402411
ctx = SharedConfigContext()
403412
assert ctx.profile_name == "work"
413+
assert ctx.profile_source is ConfigSource.ENV
414+
415+
def test_empty_aws_profile_env_treated_as_absent(self):
416+
with patch.dict(os.environ, {"AWS_PROFILE": ""}, clear=True):
417+
ctx = SharedConfigContext()
418+
assert ctx.profile_name == "default"
419+
assert ctx.profile_source is ConfigSource.DEFAULT
404420

405421
def test_explicit_profile_overrides_env(self):
406422
with patch.dict(os.environ, {"AWS_PROFILE": "work"}, clear=True):
407423
ctx = SharedConfigContext(profile_name="custom")
408424
assert ctx.profile_name == "custom"
425+
assert ctx.profile_source is ConfigSource.OVERRIDE
409426

410427
@pytest.mark.asyncio
411428
async def test_parsed_profiles_caches_result(self):
@@ -472,6 +489,43 @@ async def test_mutable_fields_are_isolated(self):
472489
assert first.source_of("region") is ConfigSource.OVERRIDE
473490
assert config.source_of("region") is ConfigSource.ENV
474491

492+
@pytest.mark.asyncio
493+
async def test_shared_resources_are_shared_by_identity(self):
494+
with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True):
495+
config = await AsyncAwsConfig.resolve(fs=NullFileSystem())
496+
497+
# The transport, credentials resolver, and retry strategy hold network
498+
# clients, locks, and shared retry quotas that must not be duplicated
499+
transport = MockHTTPClient()
500+
resolver = EnvironmentCredentialsResolver()
501+
retry_strategy = StandardRetryStrategy()
502+
config.transport = transport
503+
config.aws_credentials_identity_resolver = resolver
504+
config.retry_strategy = retry_strategy
505+
506+
copy = deepcopy(config)
507+
assert copy is not config
508+
assert copy.transport is transport
509+
assert copy.aws_credentials_identity_resolver is resolver
510+
assert copy.retry_strategy is retry_strategy
511+
512+
@pytest.mark.asyncio
513+
async def test_deepcopy_with_no_shared_resources(self):
514+
with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True):
515+
config = await AsyncAwsConfig.resolve(fs=NullFileSystem())
516+
config.http_request_config = HTTPRequestConfiguration(read_timeout=1.0)
517+
518+
copy = deepcopy(config)
519+
assert copy is not config
520+
# None resources are skipped by the identity-sharing shortcut.
521+
assert copy.transport is None
522+
assert copy.aws_credentials_identity_resolver is None
523+
assert copy.retry_strategy is None
524+
525+
assert copy.region == "us-east-1"
526+
assert copy.http_request_config == config.http_request_config
527+
assert copy.http_request_config is not config.http_request_config
528+
475529

476530
class TestResolveRetryMode:
477531
@pytest.mark.asyncio

0 commit comments

Comments
 (0)