Skip to content

Commit eb22199

Browse files
authored
Add a config property descriptor along with a custom resolver and validators (#642)
* Add a config property descriptor along with a custom resolver and validators
1 parent 318519f commit eb22199

File tree

6 files changed

+706
-0
lines changed

6 files changed

+706
-0
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from smithy_core.config.resolver import ConfigResolver
5+
from smithy_core.retries import RetryStrategyOptions
6+
7+
from smithy_aws_core.config.validators import validate_max_attempts, validate_retry_mode
8+
9+
10+
def resolve_retry_strategy(
11+
resolver: ConfigResolver,
12+
) -> tuple[RetryStrategyOptions | None, str | None]:
13+
"""Resolve retry strategy from multiple config keys.
14+
15+
Resolves both retry_mode and max_attempts from sources and constructs
16+
a RetryStrategyOptions object. This allows the retry strategy to be
17+
configured from multiple sources. Example: retry_mode from config file and
18+
max_attempts from environment variables.
19+
20+
:param resolver: The config resolver to use for resolution
21+
22+
:returns: Tuple of (RetryStrategyOptions, source_name) if both retry_mode and max_attempts
23+
are resolved. Returns (None, None) if both values are missing.
24+
25+
For mixed sources, the source name includes both component sources:
26+
"retry_mode=environment, max_attempts=config_file"
27+
"""
28+
29+
retry_mode, mode_source = resolver.get("retry_mode")
30+
31+
max_attempts, attempts_source = resolver.get("max_attempts")
32+
33+
if retry_mode is None and max_attempts is None:
34+
return None, None
35+
36+
if retry_mode is not None:
37+
retry_mode = validate_retry_mode(retry_mode, mode_source)
38+
39+
if max_attempts is not None:
40+
max_attempts = validate_max_attempts(max_attempts, attempts_source)
41+
42+
options = RetryStrategyOptions(
43+
retry_mode=retry_mode, # type: ignore
44+
max_attempts=max_attempts, # Can be None because strategy will use its default
45+
)
46+
47+
# Construct mixed source string showing where each component came from
48+
source = f"retry_mode={mode_source or 'default'}, max_attempts={attempts_source or 'default'}"
49+
50+
return (options, source)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
import re
5+
from typing import Any, get_args
6+
7+
from smithy_core.interfaces.retries import RetryStrategy
8+
from smithy_core.retries import RetryStrategyOptions, RetryStrategyType
9+
10+
11+
class ConfigValidationError(ValueError):
12+
"""Raised when a configuration value fails validation."""
13+
14+
def __init__(self, key: str, value: Any, reason: str, source: str | None = None):
15+
self.key = key
16+
self.value = value
17+
self.reason = reason
18+
self.source = source
19+
20+
msg = f"Invalid value for '{key}': {value!r}. {reason}"
21+
if source:
22+
msg += f" (from source: {source})"
23+
super().__init__(msg)
24+
25+
26+
def validate_region(region: str, source: str | None = None) -> str:
27+
"""Validate region name format.
28+
29+
:param region: The value to validate
30+
:param source: The config source that provided this value
31+
32+
:returns: The validated value
33+
34+
:raises ConfigValidationError: If the value format is invalid
35+
"""
36+
pattern = r"^(?![0-9]+$)(?!-)[a-zA-Z0-9-]{1,63}(?<!-)$"
37+
38+
if not re.match(pattern, region):
39+
raise ConfigValidationError(
40+
"region",
41+
region,
42+
"region doesn't match the pattern.",
43+
source,
44+
)
45+
return region
46+
47+
48+
def validate_retry_mode(retry_mode: str, source: str | None = None) -> str:
49+
"""Validate retry mode.
50+
51+
Valid values: 'standard', 'simple'
52+
53+
:param retry_mode: The retry mode value to validate
54+
:param source: The source that provided this value
55+
56+
:returns: The validated retry mode string
57+
58+
:raises: ConfigValidationError: If the retry mode is invalid
59+
"""
60+
61+
valid_modes = get_args(RetryStrategyType)
62+
63+
if retry_mode not in valid_modes:
64+
raise ConfigValidationError(
65+
"retry_mode",
66+
retry_mode,
67+
f"retry_mode must be one of {valid_modes}, got {retry_mode}",
68+
source,
69+
)
70+
71+
return retry_mode
72+
73+
74+
def validate_max_attempts(max_attempts: str | int, source: str | None = None) -> int:
75+
"""Validate and convert max_attempts to integer.
76+
77+
:param max_attempts: The max attempts value (string or int)
78+
:param source: The source that provided this value
79+
80+
:returns: The validated max_attempts as an integer
81+
82+
:raises ConfigValidationError: If the value is less than 1 or cannot be converted to an integer
83+
"""
84+
try:
85+
max_attempts = int(max_attempts)
86+
except (ValueError, TypeError):
87+
raise ConfigValidationError(
88+
"max_attempts",
89+
max_attempts,
90+
f"max_attempts must be a number, got {type(max_attempts).__name__}",
91+
source,
92+
)
93+
94+
if max_attempts < 1:
95+
raise ConfigValidationError(
96+
"max_attempts",
97+
max_attempts,
98+
f"max_attempts must be a positive integer, got {max_attempts}",
99+
source,
100+
)
101+
102+
return max_attempts
103+
104+
105+
def validate_retry_strategy(
106+
value: Any, source: str | None = None
107+
) -> RetryStrategy | RetryStrategyOptions:
108+
"""Validate retry strategy configuration.
109+
110+
:param value: The retry strategy value to validate
111+
:param source: The source that provided this value
112+
113+
:returns: The validated retry strategy (RetryStrategy or RetryStrategyOptions)
114+
115+
:raises: ConfigValidationError: If the value is not a valid retry strategy type
116+
"""
117+
118+
if isinstance(value, RetryStrategy | RetryStrategyOptions):
119+
return value
120+
121+
raise ConfigValidationError(
122+
"retry_strategy",
123+
value,
124+
f"retry_strategy must be RetryStrategy or RetryStrategyOptions, got {type(value).__name__}",
125+
source,
126+
)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from typing import Any
5+
6+
from smithy_aws_core.config.custom_resolvers import resolve_retry_strategy
7+
from smithy_core.config.resolver import ConfigResolver
8+
from smithy_core.retries import RetryStrategyOptions
9+
10+
11+
class StubSource:
12+
"""A simple ConfigSource implementation for testing."""
13+
14+
def __init__(self, source_name: str, data: dict[str, Any] | None = None) -> None:
15+
self._name = source_name
16+
self._data = data or {}
17+
18+
@property
19+
def name(self) -> str:
20+
return self._name
21+
22+
def get(self, key: str) -> Any | None:
23+
return self._data.get(key)
24+
25+
26+
class TestResolveCustomResolverRetryStrategy:
27+
"""Test suite for complex configuration resolution"""
28+
29+
def test_resolves_from_both_values(self) -> None:
30+
# When both retry mode and max attempts are set
31+
# It should use source names for both values
32+
source = StubSource(
33+
"environment", {"retry_mode": "standard", "max_attempts": "3"}
34+
)
35+
resolver = ConfigResolver(sources=[source])
36+
37+
result, source_name = resolve_retry_strategy(resolver)
38+
39+
assert isinstance(result, RetryStrategyOptions)
40+
assert result.retry_mode == "standard"
41+
assert result.max_attempts == 3
42+
assert source_name == "retry_mode=environment, max_attempts=environment"
43+
44+
def test_tracks_different_sources_for_each_component(self) -> None:
45+
source1 = StubSource("environment", {"retry_mode": "standard"})
46+
source2 = StubSource("config_file", {"max_attempts": "5"})
47+
resolver = ConfigResolver(sources=[source1, source2])
48+
49+
result, source_name = resolve_retry_strategy(resolver)
50+
51+
assert isinstance(result, RetryStrategyOptions)
52+
assert result.retry_mode == "standard"
53+
assert result.max_attempts == 5
54+
assert source_name == "retry_mode=environment, max_attempts=config_file"
55+
56+
def test_converts_max_attempts_string_to_int(self) -> None:
57+
source = StubSource(
58+
"environment", {"max_attempts": "10", "retry_mode": "standard"}
59+
)
60+
resolver = ConfigResolver(sources=[source])
61+
62+
result, _ = resolve_retry_strategy(resolver)
63+
64+
assert isinstance(result, RetryStrategyOptions)
65+
assert result.max_attempts == 10
66+
assert isinstance(result.max_attempts, int)
67+
68+
def test_returns_strategy_when_only_retry_mode_set(self) -> None:
69+
source = StubSource("environment", {"retry_mode": "standard"})
70+
resolver = ConfigResolver(sources=[source])
71+
72+
result, source_name = resolve_retry_strategy(resolver)
73+
74+
assert isinstance(result, RetryStrategyOptions)
75+
assert result.retry_mode == "standard"
76+
assert result.max_attempts is None
77+
assert source_name == "retry_mode=environment, max_attempts=default"
78+
79+
def test_returns_strategy_when_only_max_attempts_set(self) -> None:
80+
source = StubSource("environment", {"max_attempts": "5"})
81+
resolver = ConfigResolver(sources=[source])
82+
83+
result, source_name = resolve_retry_strategy(resolver)
84+
85+
assert isinstance(result, RetryStrategyOptions)
86+
assert result.max_attempts == 5
87+
assert source_name == "retry_mode=default, max_attempts=environment"
88+
89+
def test_returns_none_when_both_values_missing(self) -> None:
90+
source = StubSource("environment", {})
91+
resolver = ConfigResolver(sources=[source])
92+
93+
result, source_name = resolve_retry_strategy(resolver)
94+
95+
assert result is None
96+
assert source_name is None
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
from typing import Any
4+
5+
import pytest
6+
from smithy_aws_core.config.validators import (
7+
ConfigValidationError,
8+
validate_max_attempts,
9+
validate_region,
10+
validate_retry_mode,
11+
)
12+
13+
14+
class TestValidators:
15+
@pytest.mark.parametrize("region", ["us-east-1", "eu-west-1", "ap-south-1"])
16+
def test_validate_region_accepts_valid_values(self, region: str) -> None:
17+
assert validate_region(region) == region
18+
19+
@pytest.mark.parametrize("invalid", ["-invalid", "-east", "12345", ""])
20+
def test_validate_region_rejects_invalid_values(self, invalid: str) -> None:
21+
with pytest.raises(ConfigValidationError):
22+
validate_region(invalid)
23+
24+
@pytest.mark.parametrize("mode", ["standard", "simple"])
25+
def test_validate_retry_mode_accepts_valid_values(self, mode: str) -> None:
26+
assert validate_retry_mode(mode) == mode
27+
28+
@pytest.mark.parametrize("invalid_mode", ["some_retry", "some_retry_one", ""])
29+
def test_validate_retry_mode_rejects_invalid_values(
30+
self, invalid_mode: str
31+
) -> None:
32+
with pytest.raises(ConfigValidationError):
33+
validate_retry_mode(invalid_mode)
34+
35+
@pytest.mark.parametrize("invalid_max_attempts", ["abcd", 0, -1])
36+
def test_validate_invalid_max_attempts_raises_error(
37+
self, invalid_max_attempts: Any
38+
) -> None:
39+
with pytest.raises(
40+
ConfigValidationError,
41+
match=r"(max_attempts must be a number|max_attempts must be a positive integer)",
42+
):
43+
validate_max_attempts(invalid_max_attempts)
44+
45+
def test_invalid_retry_mode_error_message(self) -> None:
46+
with pytest.raises(ConfigValidationError) as exc_info:
47+
validate_retry_mode("random_mode")
48+
assert (
49+
"Invalid value for 'retry_mode': 'random_mode'. retry_mode must be one "
50+
"of ('simple', 'standard'), got random_mode" in str(exc_info.value)
51+
)

0 commit comments

Comments
 (0)