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 bbb4bc282..8b73473a1 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,76 +1,8 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -import os -from pathlib import Path - -from smithy_aws_core.config.file_parser import ( - FileType, - parse_config_file, - standardize, -) +from smithy_aws_core.config.context import load_config +from smithy_aws_core.config.filesystem import DefaultFileSystem, FileSystem from smithy_aws_core.config.merged_config import MergedConfig -_DEFAULT_CONFIG_FILE = "~/.aws/config" -_DEFAULT_CREDENTIALS_FILE = "~/.aws/credentials" -_CONFIG_FILE_ENV_VAR = "AWS_CONFIG_FILE" -_CREDENTIALS_FILE_ENV_VAR = "AWS_SHARED_CREDENTIALS_FILE" - - -def _resolve_config_paths( - config_file_path: Path | None = None, - credentials_file_path: Path | None = None, -) -> tuple[Path, Path]: - """Resolve the final config and credentials file paths. - - Resolution order for each path: - 1. Explicit argument (if provided) - 2. Environment variable (AWS_CONFIG_FILE / AWS_SHARED_CREDENTIALS_FILE) - 3. Default (~/.aws/config / ~/.aws/credentials) - - The ~ is expanded to the user's home directory. - - :param config_file_path: Override path for config file. - :param credentials_file_path: Override path for credentials file. - :returns: Tuple of (resolved_config_path, resolved_credentials_path). - """ - config_path = ( - config_file_path - or Path(os.environ.get(_CONFIG_FILE_ENV_VAR, _DEFAULT_CONFIG_FILE)) - ).expanduser() - - credentials_path = ( - credentials_file_path - or Path(os.environ.get(_CREDENTIALS_FILE_ENV_VAR, _DEFAULT_CREDENTIALS_FILE)) - ).expanduser() - - return config_path, credentials_path - - -async def load_config( - config_file_path: Path | None = None, - credentials_file_path: Path | None = None, -) -> MergedConfig: - """Load and merge AWS config and credentials files. - - Parses both files, standardizes them, and returns a merged - MergedConfig ready for querying. - - :param config_file_path: Override path for config file. - Defaults to AWS_CONFIG_FILE env var or ~/.aws/config. - :param credentials_file_path: Override path for credentials file. - Defaults to AWS_SHARED_CREDENTIALS_FILE env var or ~/.aws/credentials. - :returns: A MergedConfig with merged profiles from both files. - """ - - config_path, credentials_path = _resolve_config_paths( - config_file_path, credentials_file_path - ) - - raw_config = await parse_config_file(str(config_path)) - raw_credentials = await parse_config_file(str(credentials_path)) - - std_config = standardize(raw_config, FileType.CONFIG) - std_credentials = standardize(raw_credentials, FileType.CREDENTIALS) - - return MergedConfig(std_config, std_credentials) +__all__ = ["DefaultFileSystem", "FileSystem", "MergedConfig", "load_config"] 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 new file mode 100644 index 000000000..43fbb9b7c --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/aws_config.py @@ -0,0 +1,177 @@ +# 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 smithy_core.exceptions import ConfigError, ConfigValidationError +from smithy_core.retries import RetryStrategyOptions + +from smithy_aws_core.config.context import SharedConfigContext +from smithy_aws_core.config.filesystem import FileSystem +from smithy_aws_core.config.resolvers import resolve_region, resolve_retry_config +from smithy_aws_core.config.types import UNSET, ConfigSource, FieldSpec, Resolved +from smithy_aws_core.config.validators import ( + validate_region, + validate_retry_strategy_options, +) + + +@dataclass(kw_only=True) +class AsyncAwsConfig: + """Base configuration class for all AWS services. + + Fields are resolved asynchronously from multiple sources (env vars, + config files, defaults) through the resolve() classmethod. + + Do not instantiate directly — use: + config = await AsyncAwsConfig.resolve() + """ + + region: str | None = None + retry_strategy_options: RetryStrategyOptions | None = None + + _ctx: SharedConfigContext | None = field(default=None, repr=False, compare=False) + _sources: dict[str, ConfigSource] = field( # type: ignore[assignment] + default_factory=dict, + repr=False, + compare=False, + ) + + _FIELDS: ClassVar[dict[str, FieldSpec]] = { + "region": FieldSpec( + default=None, + resolver=resolve_region, + validator=validate_region, + ), + "retry_strategy_options": FieldSpec( + default_factory=RetryStrategyOptions, + resolver=resolve_retry_config, + validator=validate_retry_strategy_options, + ), + } + + def __post_init__(self) -> None: + """Block direct construction. Use resolve() instead.""" + raise ConfigError( + f"{type(self).__name__} cannot be constructed directly. " + f"Use `await {type(self).__name__}.resolve(...)` instead." + ) + + @classmethod + async def resolve( + cls, + *, + profile: str | None = None, + fs: FileSystem | None = None, + config_file_path: str | None = None, + credentials_file_path: str | None = None, + **overrides: Any, + ) -> Self: + """Resolve a config object from environment, config files, and defaults. + + This is the only supported way to create a config instance. + + :param profile: Override the active profile name. + :param fs: Override the filesystem abstraction. + :param config_file_path: Override path for config file. + :param credentials_file_path: Override path for credentials file. + :param overrides: Explicit field values that skip resolution. + :returns: A fully-resolved config instance. + """ + ctx = SharedConfigContext( + profile_name=profile, + fs=fs, + config_file_path=config_file_path, + credentials_file_path=credentials_file_path, + ) + + # Create the instance bypassing __post_init__ check + instance = cls._create_instance() + instance._ctx = ctx + + # Resolve each field + await instance._resolve_fields(overrides) + + return instance + + def source_of(self, field_name: str) -> ConfigSource | None: + """Get the source that provided a field's value. + + :param field_name: The config field name. + :returns: The ConfigSource, or None if not tracked. + """ + return self._sources.get(field_name) + + def resolution_context(self) -> SharedConfigContext | None: + """Get the resolution context used to create this config. + + :returns: The SharedConfigContext, or None if not available. + """ + return self._ctx + + @classmethod + def _create_instance(cls) -> Self: + """Create an instance that bypasses construction blocking.""" + instance = object.__new__(cls) + object.__setattr__(instance, "_sources", {}) + object.__setattr__(instance, "_ctx", None) + for field_name in cls._FIELDS: + object.__setattr__(instance, field_name, UNSET) + return instance + + async def _resolve_fields(self, overrides: dict[str, Any]) -> None: + """Run the resolution pipeline for all fields.""" + unknown = set(overrides) - set(self._FIELDS) + if unknown: + raise ConfigValidationError( + f"Unknown config field(s): {sorted(unknown)}. " + f"Valid fields are: {sorted(self._FIELDS)}" + ) + + for field_name, spec in self._FIELDS.items(): + # check for overrides first + if field_name in overrides: + value = overrides[field_name] + setattr(self, field_name, value) + self._sources[field_name] = ConfigSource.OVERRIDE + # check in resolver + elif spec.resolver is not None: + result: Resolved[Any] = await spec.resolver(self._ctx) + if result.value is not UNSET: + setattr(self, field_name, result.value) + self._sources[field_name] = result.source + else: + # If resolver returned UNSET, fall back to default + self._apply_default(field_name, spec) + + else: + # No resolver, use default directly + self._apply_default(field_name, spec) + + # Run validator for all sources + if spec.validator is not None: + spec.validator(getattr(self, field_name)) + + def _apply_default(self, field_name: str, spec: FieldSpec) -> None: + """Apply the default value for a field.""" + if spec.default_factory is not None: + value = spec.default_factory() + else: + value = spec.default + setattr(self, field_name, value) + self._sources[field_name] = ConfigSource.DEFAULT + + def __setattr__(self, name: str, value: Any) -> None: + """Track provenance when fields are set with plugins after construction""" + # Mark as override only if the field is in _FIELDS and was already resolved + if ( + name in self.__class__._FIELDS + and hasattr(self, "_sources") + and name in self._sources + ): + spec = self.__class__._FIELDS[name] + if spec.validator is not None: + spec.validator(value) + self._sources[name] = ConfigSource.OVERRIDE + super().__setattr__(name, value) 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 new file mode 100644 index 000000000..7496cfa6c --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/context.py @@ -0,0 +1,161 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import logging +import os +from pathlib import Path +from typing import Any + +from smithy_aws_core.config.file_parser import ( + FileType, + parse_config_file, + standardize, +) +from smithy_aws_core.config.filesystem import DefaultFileSystem, FileSystem +from smithy_aws_core.config.merged_config import MergedConfig + +logger = logging.getLogger(__name__) + +_DEFAULT_CONFIG_FILE = "~/.aws/config" +_DEFAULT_CREDENTIALS_FILE = "~/.aws/credentials" +_CONFIG_FILE_ENV_VAR = "AWS_CONFIG_FILE" +_CREDENTIALS_FILE_ENV_VAR = "AWS_SHARED_CREDENTIALS_FILE" + + +def _resolve_config_paths( + config_file_path: Path | None = None, + credentials_file_path: Path | None = None, +) -> tuple[Path, Path]: + """Resolve the final config and credentials file paths. + + Resolution order for each path: + 1. Explicit argument (if provided) + 2. Environment variable (AWS_CONFIG_FILE / AWS_SHARED_CREDENTIALS_FILE) + 3. Default (~/.aws/config / ~/.aws/credentials) + + The ~ is expanded to the user's home directory. + + :param config_file_path: Override path for config file. + :param credentials_file_path: Override path for credentials file. + :returns: Tuple of (resolved_config_path, resolved_credentials_path). + """ + config_path = ( + config_file_path + or Path(os.environ.get(_CONFIG_FILE_ENV_VAR, _DEFAULT_CONFIG_FILE)) + ).expanduser() + + credentials_path = ( + credentials_file_path + or Path(os.environ.get(_CREDENTIALS_FILE_ENV_VAR, _DEFAULT_CREDENTIALS_FILE)) + ).expanduser() + + return config_path, credentials_path + + +async def load_config( + config_file_path: Path | None = None, + credentials_file_path: Path | None = None, + fs: FileSystem | None = None, +) -> MergedConfig: + """Load and merge AWS config and credentials files. + + Parses both files, standardizes them, and returns a merged + MergedConfig ready for querying. + + :param config_file_path: Override path for config file. + Defaults to AWS_CONFIG_FILE env var or ~/.aws/config. + :param credentials_file_path: Override path for credentials file. + Defaults to AWS_SHARED_CREDENTIALS_FILE env var or ~/.aws/credentials. + :param fs: FileSystem to use for reading files. + Defaults to DefaultFileSystem (real disk I/O). + :returns: A MergedConfig with merged profiles from both files. + """ + filesystem = fs or DefaultFileSystem() + config_path, credentials_path = _resolve_config_paths( + config_file_path, credentials_file_path + ) + + raw_config = await parse_config_file(str(config_path), filesystem) + raw_credentials = await parse_config_file(str(credentials_path), filesystem) + + std_config = standardize(raw_config, FileType.CONFIG) + std_credentials = standardize(raw_credentials, FileType.CREDENTIALS) + + return MergedConfig(std_config, std_credentials) + + +class SharedConfigContext: + """Resolution context shared across resolvers during config construction. + + Holds environment state and cached file data that resolvers use to + look up values. Created once per resolve() call. + """ + + def __init__( + self, + *, + profile_name: str | None = None, + fs: FileSystem | None = None, + http_client: Any | None = None, + config_file_path: str | Path | None = None, + credentials_file_path: str | Path | None = None, + ) -> None: + """Initialize the resolution context. + + :param profile_name: The active profile to use. Defaults to + AWS_PROFILE env var, then "default". + :param fs: Filesystem abstraction for reading config files. + Defaults to real disk I/O. + :param http_client: HTTP client for network-based resolvers + (e.g., IMDS). + :param config_file_path: Override path for config file. + :param credentials_file_path: Override path for credentials file. + """ + self._fs: FileSystem = fs if fs is not None else DefaultFileSystem() + self._http_client: Any | None = http_client + self._profile_name: str = self._resolve_profile_name(profile_name) + self._config_file_path: Path | None = ( + Path(config_file_path) if config_file_path is not None else None + ) + self._credentials_file_path: Path | None = ( + Path(credentials_file_path) if credentials_file_path is not None else None + ) + self._cached_config_file: MergedConfig | None = None + + @property + def profile_name(self) -> str: + """The active profile name.""" + return self._profile_name + + @property + def fs(self) -> FileSystem: + """The filesystem abstraction.""" + return self._fs + + @property + def http_client(self) -> Any | None: + """HTTP client for network-based resolvers.""" + return self._http_client + + async def parsed_profiles(self) -> MergedConfig: + """Get the parsed and merged config/credentials file data. + + The result is cached after the first call so files are only + read from disk once per context. + """ + if self._cached_config_file is None: + self._cached_config_file = await load_config( + config_file_path=self._config_file_path, + credentials_file_path=self._credentials_file_path, + fs=self._fs, + ) + return self._cached_config_file + + def _resolve_profile_name(self, explicit_profile: str | None) -> str: + """Determine the active profile name. + + Priority: explicit argument > AWS_PROFILE env var > "default" + """ + if explicit_profile is not None: + return explicit_profile + return os.environ.get("AWS_PROFILE", "default") diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/exceptions.py b/packages/smithy-aws-core/src/smithy_aws_core/config/exceptions.py deleted file mode 100644 index 3ad3ceb97..000000000 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/exceptions.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Config related exceptions""" - - -class ConfigParseError(Exception): - """Raised when a config file cannot be parsed due to invalid syntax.""" diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/file_parser.py b/packages/smithy-aws-core/src/smithy_aws_core/config/file_parser.py index 09d8b1aa2..8182ea6e1 100644 --- a/packages/smithy-aws-core/src/smithy_aws_core/config/file_parser.py +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/file_parser.py @@ -1,14 +1,14 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 -import asyncio import logging import re from dataclasses import dataclass, field from enum import Enum -from pathlib import Path -from smithy_aws_core.config.exceptions import ConfigParseError +from smithy_core.exceptions import ConfigParseError + +from smithy_aws_core.config.filesystem import FileSystem logger = logging.getLogger(__name__) @@ -43,16 +43,19 @@ class StandardizedOutput: services: SectionMap = field(default_factory=dict) # type: ignore[assignment] -async def parse_config_file(file_path: str) -> RawParsedSections: +async def parse_config_file( + file_path: str, filesystem: FileSystem +) -> RawParsedSections: """Parse an AWS config or credentials file. Reads the file asynchronously and parses it into raw sections. :param file_path: Resolved path to the file. + :param filesystem: FileSystem object to use. :returns: Raw sections dict {section_name: {key: value}}. :raises ConfigParseError: If the file has invalid syntax. """ - content = await _read_file(file_path) + content = await filesystem.read_file(file_path) if content is None: return {} try: @@ -111,23 +114,6 @@ def standardize( ) -async def _read_file(path: str) -> str | None: - """Read file content asynchronously. - - Returns None if the file doesn't exist or can't be read due to - permission/OS errors. Raises on encoding errors since they likely - indicate a misconfigured file the user should know about. - """ - try: - content = await asyncio.to_thread(Path(path).read_text, encoding="utf-8") - return content - except FileNotFoundError: - return None - except (PermissionError, OSError) as e: - logger.warning("Unable to read config file '%s': %s", path, e) - return None - - def _parse_content(content: str) -> RawParsedSections: """Parse config file content from a string into raw sections. diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/filesystem.py b/packages/smithy-aws-core/src/smithy_aws_core/config/filesystem.py new file mode 100644 index 000000000..831511191 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/filesystem.py @@ -0,0 +1,50 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import asyncio +import logging +from pathlib import Path +from typing import Protocol, runtime_checkable + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class FileSystem(Protocol): + """Protocol for filesystem operations. + + Abstraction over file I/O so tests can provide mock implementations + without touching the real filesystem. + """ + + async def read_file(self, path: str) -> str | None: + """Read a file's content as UTF-8. + + :param path: Resolved file path. + :returns: File content, or None if the file is inaccessible. + """ + ... + + +class DefaultFileSystem: + """Default filesystem implementation using real disk I/O.""" + + async def read_file(self, path: str) -> str | None: + """Read a file asynchronously from disk. + + Missing files and permission errors return None with a warning. + Encoding errors (invalid UTF-8) are raised to the caller. + + :param path: Resolved file path. + :returns: File content, or None if the file is inaccessible. + """ + try: + content: str = await asyncio.to_thread( + Path(path).read_text, encoding="utf-8" + ) + return content + except FileNotFoundError: + return None + except (PermissionError, OSError) as e: + logger.warning("Unable to read config file '%s': %s", path, e) + return None 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 new file mode 100644 index 000000000..f3b21d01b --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/resolvers.py @@ -0,0 +1,138 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +from collections.abc import Sequence +from typing import Any + +from smithy_core.exceptions import ConfigValidationError +from smithy_core.retries import RetryStrategyOptions + +from smithy_aws_core.config.context import SharedConfigContext +from smithy_aws_core.config.types import UNSET, ConfigSource, Resolved +from smithy_aws_core.config.validators import validate_max_attempts, validate_retry_mode + + +async def _resolve_str( + ctx: SharedConfigContext, + *, + env_vars: Sequence[str] = (), + profile_keys: Sequence[str] = (), +) -> Resolved[str]: + """Resolve a string value by checking providers in priority order. + + Priority: env vars (first match) > config file (profile keys, first match) > unresolved. + + :param ctx: The shared resolution context. + :param env_vars: Environment variable names to check, in order. + :param profile_keys: Config file profile keys to check, in order. + :returns: Resolved value with source, or Resolved(value=UNSET) if not found. + """ + # Check environment variables first + for var_name in env_vars: + value: str | None = os.environ.get(var_name) + if value: + return Resolved(value=value, source=ConfigSource.ENV) + + # Check config file profile keys + if profile_keys: + config_file = await ctx.parsed_profiles() + for key in profile_keys: + value = config_file.get(ctx.profile_name, key) + if value: + return Resolved(value=value, source=ConfigSource.PROFILE) + + return Resolved(value=UNSET, source=ConfigSource.DEFAULT) # type: ignore[arg-type] + + +async def _resolve_int( + ctx: SharedConfigContext, + *, + env_vars: Sequence[str] = (), + profile_keys: Sequence[str] = (), +) -> Resolved[int | None]: + """Resolve an integer value by checking providers in priority order. + + :param ctx: The shared resolution context. + :param env_vars: Environment variable names to check, in order. + :param profile_keys: Config file profile keys to check, in order. + :returns: Resolved int value with source, or Resolved(value=UNSET) if not found. + """ + result = await _resolve_str(ctx, env_vars=env_vars, profile_keys=profile_keys) + if result.value is UNSET: + return Resolved(value=UNSET, source=ConfigSource.DEFAULT) # type: ignore[arg-type] + try: + return Resolved(value=int(result.value), source=result.source) + except (ValueError, TypeError) as e: + raise ConfigValidationError( + f"Invalid integer value {result.value!r} for config key. " + "Expected a valid integer." + ) from e + + +def _strongest_source(*sources: ConfigSource) -> ConfigSource: + """Return the highest-priority source among multiple resolved values. + + Priority: ENV > PROFILE > DEFAULT + """ + priority = {ConfigSource.ENV: 3, ConfigSource.PROFILE: 2, ConfigSource.DEFAULT: 1} + return max(sources, key=lambda s: priority.get(s, 0)) + + +async def resolve_region(ctx: SharedConfigContext) -> Resolved[str | None]: + """Resolve the AWS region from environment or config file. + + :param ctx: The shared resolution context. + :returns: Resolved region value with source. + """ + return await _resolve_str( + ctx, + env_vars=("AWS_REGION", "AWS_DEFAULT_REGION"), + profile_keys=("region",), + ) + + +async def resolve_retry_config( + ctx: SharedConfigContext, +) -> Resolved[RetryStrategyOptions]: + """Resolve retry configuration from environment and config file. + + Combines retry_mode and max_attempts into a single RetryStrategyOptions. + Each sub-value is resolved independently with its own priority chain. + + :param ctx: The shared resolution context. + :returns: Resolved RetryStrategyOptions with the strongest source. + """ + mode_result = await _resolve_str( + ctx, + env_vars=("AWS_RETRY_MODE",), + profile_keys=("retry_mode",), + ) + attempts_result = await _resolve_int( + ctx, + env_vars=("AWS_MAX_ATTEMPTS",), + profile_keys=("max_attempts",), + ) + + # Build kwargs for values that were resolved + kwargs: dict[str, Any] = {} + sources_found: list[ConfigSource] = [] + + if mode_result.value is not UNSET: + validate_retry_mode(mode_result.value) + kwargs["retry_mode"] = mode_result.value + sources_found.append(mode_result.source) + + if attempts_result.value is not UNSET and attempts_result.value is not None: + validate_max_attempts(attempts_result.value) + kwargs["max_attempts"] = attempts_result.value + sources_found.append(attempts_result.source) + + source = ( + _strongest_source(*sources_found) if sources_found else ConfigSource.DEFAULT + ) + + return Resolved( + value=RetryStrategyOptions(**kwargs), + source=source, + ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/types.py b/packages/smithy-aws-core/src/smithy_aws_core/config/types.py new file mode 100644 index 000000000..f0d3fe255 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/types.py @@ -0,0 +1,70 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + + +class _UnsetType: + def __repr__(self) -> str: + return "UNSET" + + def __bool__(self) -> bool: + return False + + +UNSET = _UnsetType() + + +class ConfigSource(StrEnum): + """Where a resolved config value came from.""" + + ENV = "env" + PROFILE = "profile" + DEFAULT = "default" + OVERRIDE = "override" + + +@dataclass(frozen=True, kw_only=True) +class Resolved[T]: + """A resolved config value paired with its provenance source.""" + + value: T + source: ConfigSource + + +@dataclass(frozen=True, kw_only=True) +class FieldSpec: + """Specification for a single config field. + + Carries everything the resolution pipeline needs to resolve, + validate, and default a field. + + """ + + default: Any = UNSET + """Value used if no resolver runs or all providers return unresolved.""" + + default_factory: Callable[[], Any] | None = None + """Factory for mutable defaults (lists, dicts) that need a fresh instance per config.""" + + resolver: Callable[..., Awaitable[Resolved[Any]]] | None = None + """Async function that resolves the field from env/profile/etc.""" + + validator: Callable[[Any], None] | None = None + """Function that validates the resolved value. Raises on invalid input.""" + + def __post_init__(self) -> None: + has_default = self.default is not UNSET + has_factory = self.default_factory is not None + if has_default and has_factory: + raise ValueError( + "FieldSpec: cannot set both 'default' and 'default_factory'" + ) + if not has_default and not has_factory: + raise ValueError( + "FieldSpec: exactly one of 'default' or 'default_factory' must be set" + ) diff --git a/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py b/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py new file mode 100644 index 000000000..64426e979 --- /dev/null +++ b/packages/smithy-aws-core/src/smithy_aws_core/config/validators.py @@ -0,0 +1,86 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import re +from typing import get_args + +from smithy_core.exceptions import ConfigValidationError +from smithy_core.retries import RetryStrategyOptions, RetryStrategyType + +_REGION_PATTERN = re.compile(r"^(?![0-9]+$)(?!-)[a-zA-Z0-9-]{1,63}(? None: + """Validate that region is a non-empty string matching AWS region format. + + Region is required and must be explicitly set via env var, config file, + or override. + + :param value: The resolved region value. + :raises ConfigValidationError: If the value is None or doesn't match the pattern. + """ + if value is None: + raise ConfigValidationError( + "Invalid value for 'region': None. Region is required and must be set." + ) + if not isinstance(value, str) or not _REGION_PATTERN.match(value): + raise ConfigValidationError( + f"Invalid value for 'region': {value!r}. " + "Must be a valid AWS region identifier." + ) + + +def validate_retry_strategy_options(value: object) -> None: + """Validate that retry_strategy_options is a RetryStrategyOptions instance. + + :param value: The resolved retry strategy options value. + :raises ConfigValidationError: If the value is not a RetryStrategyOptions instance. + """ + if not isinstance(value, RetryStrategyOptions): + raise ConfigValidationError( + f"Invalid value for 'retry_strategy_options': {value!r}. " + f"Must be RetryStrategyOptions, got {type(value).__name__}." + ) + + +def validate_retry_mode(retry_mode: str): + """Validate retry mode. + + Valid values: 'standard' + + :param retry_mode: The retry mode value to validate + :raises: ConfigValidationError: If the retry mode is invalid + """ + + all_modes = list(get_args(RetryStrategyType)) + if "simple" in all_modes: + all_modes.remove("simple") + valid_modes = tuple(all_modes) + + if retry_mode not in valid_modes: + raise ConfigValidationError( + f"Invalid value for 'retry_mode': {retry_mode!r}. " + f"Must be one of {valid_modes}." + ) + + +def validate_max_attempts( + max_attempts: str | int, +): + """Validate max_attempts + + :param max_attempts: The max attempts value (string or int) + + :raises ConfigValidationError: If the value is less than 1 or cannot be converted to an integer + """ + try: + max_attempts = int(max_attempts) + except (ValueError, TypeError): + raise ConfigValidationError( + f"max_attempts must be a number, got {type(max_attempts).__name__}", + ) + + if max_attempts < 1: + raise ConfigValidationError( + f"max_attempts must be a positive integer, got {max_attempts}", + ) diff --git a/packages/smithy-aws-core/tests/unit/config/test_config_file_io.py b/packages/smithy-aws-core/tests/unit/config/test_config_file_io.py index a8db37c2c..0b24c8327 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_config_file_io.py +++ b/packages/smithy-aws-core/tests/unit/config/test_config_file_io.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest -from smithy_aws_core.config import load_config +from smithy_aws_core.config import DefaultFileSystem, load_config from smithy_aws_core.config.file_parser import parse_config_file @@ -25,6 +25,7 @@ async def test_load_config_with_missing_files(self, tmp_path: Path): @pytest.mark.asyncio async def test_permission_denied_returns_empty(self, tmp_path: Path): + fs = DefaultFileSystem() restricted_file = tmp_path / "restricted_config" restricted_file.write_text("[profile default]\nregion = us-east-1\n") # Patch read_text to raise PermissionError rather than relying on @@ -32,7 +33,7 @@ async def test_permission_denied_returns_empty(self, tmp_path: Path): with patch.object( Path, "read_text", side_effect=PermissionError("Permission denied") ): - result = await parse_config_file(str(restricted_file)) + result = await parse_config_file(str(restricted_file), fs) assert result == {} @@ -42,10 +43,11 @@ class TestEncodingErrors: @pytest.mark.asyncio async def test_bad_unicode_raises_error(self, tmp_path: Path): """A file with invalid UTF-8 bytes should raise UnicodeDecodeError.""" + fs = DefaultFileSystem() bad_file = tmp_path / "bad_config" bad_file.write_bytes(b"[default]\nregion = \xff\xfe invalid") with pytest.raises(UnicodeDecodeError): - await parse_config_file(str(bad_file)) + await parse_config_file(str(bad_file), fs) class TestMultiFileMerge: diff --git a/packages/smithy-aws-core/tests/unit/config/test_config_file_location.py b/packages/smithy-aws-core/tests/unit/config/test_config_file_location.py index 49956e9a7..68fe8d8d3 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_config_file_location.py +++ b/packages/smithy-aws-core/tests/unit/config/test_config_file_location.py @@ -8,7 +8,7 @@ from unittest.mock import patch import pytest -from smithy_aws_core.config import ( +from smithy_aws_core.config.context import ( _resolve_config_paths, # type: ignore[reportPrivateUsage] ) diff --git a/packages/smithy-aws-core/tests/unit/config/test_config_file_parser.py b/packages/smithy-aws-core/tests/unit/config/test_config_file_parser.py index b370bc741..239e69716 100644 --- a/packages/smithy-aws-core/tests/unit/config/test_config_file_parser.py +++ b/packages/smithy-aws-core/tests/unit/config/test_config_file_parser.py @@ -6,7 +6,7 @@ from typing import cast import pytest -from smithy_aws_core.config.exceptions import ConfigParseError +from smithy_aws_core.config import DefaultFileSystem from smithy_aws_core.config.file_parser import ( FileType, RawParsedSections, @@ -14,6 +14,7 @@ standardize, ) from smithy_aws_core.config.merged_config import MergedConfig +from smithy_core.exceptions import ConfigParseError _PARSER_TESTS_FILE = ( Path(__file__).parent / "test-data" / "config-file-parser-tests.json" @@ -30,10 +31,11 @@ async def _run_parse_and_standardize( ) -> dict[str, object]: """Write content to temp files, parse through public API, return merged output.""" # Write config content to temp file + fs = DefaultFileSystem() if config_content is not None: config_path = tmp_path / "config" config_path.write_text(config_content, encoding="utf-8") - raw_config = await parse_config_file(str(config_path)) + raw_config = await parse_config_file(str(config_path), fs) else: raw_config = {} @@ -41,7 +43,7 @@ async def _run_parse_and_standardize( if credentials_content is not None: credentials_path = tmp_path / "credentials" credentials_path.write_text(credentials_content, encoding="utf-8") - raw_credentials = await parse_config_file(str(credentials_path)) + raw_credentials = await parse_config_file(str(credentials_path), fs) else: raw_credentials = {} @@ -144,22 +146,24 @@ def test_credentials_section_names(section_name: str, should_be_valid: bool): @pytest.mark.asyncio async def test_parse_error_includes_file_path(tmp_path: Path): + fs = DefaultFileSystem() bad_file = tmp_path / "config" bad_file.write_text("[profile p\n") # unclosed section header with pytest.raises(ConfigParseError, match=str(bad_file)): - await parse_config_file(str(bad_file)) + await parse_config_file(str(bad_file), fs) @pytest.mark.asyncio async def test_invalid_property_continuation_not_appended_to_previous(tmp_path: Path): """Continuation lines after an invalid property should be discarded, not appended to the previous valid property.""" + fs = DefaultFileSystem() config_file = tmp_path / "config" config_file.write_text( "[profile p]\nregion = us-east-1\ninvalid key = ignored\n continuation\n" ) - raw = await parse_config_file(str(config_file)) + raw = await parse_config_file(str(config_file), fs) result = standardize(raw, FileType.CONFIG) assert result.profiles["p"].properties["region"] == "us-east-1" @@ -167,12 +171,13 @@ async def test_invalid_property_continuation_not_appended_to_previous(tmp_path: @pytest.mark.asyncio async def test_invalid_property_in_first_line_with_continuation_ignored(tmp_path: Path): """Continuation lines when the first property is invalid should be discarded""" + fs = DefaultFileSystem() config_file = tmp_path / "config" config_file.write_text( "[profile p]\ninvalid key = ignored\n continuation\n" "region = us-east-1\ninvalid key = ignored\n continuation\n" ) - raw = await parse_config_file(str(config_file)) + raw = await parse_config_file(str(config_file), fs) result = standardize(raw, FileType.CONFIG) assert result.profiles["p"].properties["region"] == "us-east-1" @@ -180,13 +185,14 @@ async def test_invalid_property_in_first_line_with_continuation_ignored(tmp_path @pytest.mark.asyncio async def test_consecutive_invalid_properties_ignored(tmp_path: Path): """Consecutive invalid properties should be discarded""" + fs = DefaultFileSystem() config_file = tmp_path / "config" config_file.write_text( "[profile p]\nregion = us-east-1\ninvalid key = ignored\n continuation\n" "invalid key = ignored\n continuation\ninvalid key = ignored\n continuation\n" "output = json\n" ) - raw = await parse_config_file(str(config_file)) + raw = await parse_config_file(str(config_file), filesystem=fs) result = standardize(raw, FileType.CONFIG) assert result.profiles["p"].properties["region"] == "us-east-1" assert result.profiles["p"].properties["output"] == "json" diff --git a/packages/smithy-aws-core/tests/unit/config/test_resolver.py b/packages/smithy-aws-core/tests/unit/config/test_resolver.py new file mode 100644 index 000000000..0aed9d7c2 --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_resolver.py @@ -0,0 +1,342 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for the config resolution pipeline. + +Tests AsyncAwsConfig.resolve(), resolver functions, SharedConfigContext, +provenance tracking, and precedence behavior. +""" + +import os +from unittest.mock import patch + +import pytest +from smithy_aws_core.config.aws_config import AsyncAwsConfig +from smithy_aws_core.config.context import SharedConfigContext +from smithy_aws_core.config.resolvers import ( + resolve_region, + resolve_retry_config, +) +from smithy_aws_core.config.types import ConfigSource +from smithy_core.exceptions import ConfigError, ConfigValidationError + + +class NullFileSystem: + async def read_file(self, path: str) -> str | None: + return None + + +class FakeFileSystem: + def __init__(self, files: dict[str, str]): + self._files = files + + async def read_file(self, path: str) -> str | None: + return self._files.get(path) + + +class TestResolveRegion: + @pytest.mark.asyncio + async def test_resolves_from_aws_region(self): + with patch.dict(os.environ, {"AWS_REGION": "us-west-2"}, clear=True): + ctx = SharedConfigContext() + result = await resolve_region(ctx) + assert result.value == "us-west-2" + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_resolves_from_aws_default_region(self): + with patch.dict(os.environ, {"AWS_DEFAULT_REGION": "eu-central-1"}, clear=True): + ctx = SharedConfigContext() + result = await resolve_region(ctx) + assert result.value == "eu-central-1" + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_aws_region_takes_precedence_over_default_region(self): + with patch.dict( + os.environ, + {"AWS_REGION": "us-west-2", "AWS_DEFAULT_REGION": "eu-west-1"}, + clear=True, + ): + ctx = SharedConfigContext() + result = await resolve_region(ctx) + assert result.value == "us-west-2" + + @pytest.mark.asyncio + async def test_resolves_from_profile_when_no_env(self): + fs = FakeFileSystem( + {"/fake/config": "[profile default]\nregion = ap-southeast-1\n"} + ) + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + result = await resolve_region(ctx) + assert result.value == "ap-southeast-1" + assert result.source == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_env_takes_precedence_over_profile(self): + fs = FakeFileSystem({"/fake/config": "[profile default]\nregion = eu-west-1\n"}) + with patch.dict(os.environ, {"AWS_REGION": "us-west-2"}, clear=True): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + result = await resolve_region(ctx) + assert result.value == "us-west-2" + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_empty_string_env_var_treated_as_absent(self): + fs = FakeFileSystem({"/fake/config": "[profile default]\nregion = eu-west-1\n"}) + with patch.dict(os.environ, {"AWS_REGION": ""}, clear=True): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + result = await resolve_region(ctx) + assert result.value == "eu-west-1" + assert result.source == ConfigSource.PROFILE + + +class TestResolveRetryConfig: + @pytest.mark.asyncio + async def test_resolves_both_from_env(self): + with patch.dict( + os.environ, + {"AWS_RETRY_MODE": "standard", "AWS_MAX_ATTEMPTS": "10"}, + clear=True, + ): + ctx = SharedConfigContext() + result = await resolve_retry_config(ctx) + assert result.value.retry_mode == "standard" + assert result.value.max_attempts == 10 + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_uses_defaults_when_nothing_found(self): + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + result = await resolve_retry_config(ctx) + assert result.value.retry_mode == "standard" + assert result.value.max_attempts is None + assert result.source == ConfigSource.DEFAULT + + @pytest.mark.asyncio + async def test_partial_resolution_uses_defaults_for_missing(self): + with patch.dict(os.environ, {"AWS_RETRY_MODE": "standard"}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + result = await resolve_retry_config(ctx) + assert result.value.retry_mode == "standard" + assert result.value.max_attempts is None + assert result.source == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_max_attempts_cast_to_int(self): + with patch.dict( + os.environ, + {"AWS_RETRY_MODE": "standard", "AWS_MAX_ATTEMPTS": "5"}, + clear=True, + ): + ctx = SharedConfigContext() + result = await resolve_retry_config(ctx) + assert result.value.max_attempts == 5 + assert isinstance(result.value.max_attempts, int) + + @pytest.mark.asyncio + async def test_max_attempts_unset_when_not_found(self): + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + result = await resolve_retry_config(ctx) + assert result.value.max_attempts is None + + @pytest.mark.asyncio + async def test_invalid_max_attempts_raises_config_error(self): + with patch.dict(os.environ, {"AWS_MAX_ATTEMPTS": "abc"}, clear=True): + ctx = SharedConfigContext(fs=NullFileSystem()) + with pytest.raises(ConfigValidationError, match="Invalid integer value"): + await resolve_retry_config(ctx) + + +class TestAsyncAwsConfigResolve: + @pytest.mark.asyncio + async def test_resolves_region_from_env(self): + with patch.dict(os.environ, {"AWS_REGION": "us-west-2"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.region == "us-west-2" + + @pytest.mark.asyncio + async def test_resolves_retry_from_env(self): + with patch.dict( + os.environ, + { + "AWS_RETRY_MODE": "standard", + "AWS_MAX_ATTEMPTS": "5", + "AWS_REGION": "us-east-1", + }, + clear=True, + ): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.retry_strategy_options is not None + assert config.retry_strategy_options.retry_mode == "standard" + assert config.retry_strategy_options.max_attempts == 5 + + @pytest.mark.asyncio + async def test_explicit_override_takes_precedence(self): + with patch.dict(os.environ, {"AWS_REGION": "us-west-2"}, clear=True): + config = await AsyncAwsConfig.resolve( + region="eu-west-1", fs=NullFileSystem() + ) + assert config.region == "eu-west-1" + + @pytest.mark.asyncio + async def test_default_region_raises_error(self): + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ConfigValidationError, match="Region is required"): + await AsyncAwsConfig.resolve(fs=NullFileSystem()) + + @pytest.mark.asyncio + async def test_default_retry_strategy(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.retry_strategy_options is not None + assert config.retry_strategy_options.retry_mode == "standard" + assert config.retry_strategy_options.max_attempts is None + + @pytest.mark.asyncio + async def test_resolves_region_from_non_default_profile(self): + fs = FakeFileSystem( + { + "/fake/config": "[profile default]\nregion = us-east-1\n" + "[profile work]\nregion = eu-west-1\n" + } + ) + with patch.dict(os.environ, {}, clear=True): + config = await AsyncAwsConfig.resolve( + profile="work", + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + assert config.region == "eu-west-1" + assert config.source_of("region") == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_invalid_override_triggers_validator(self): + with patch.dict(os.environ, {}, clear=True): + with pytest.raises( + ConfigValidationError, match="Must be a valid AWS region" + ): + await AsyncAwsConfig.resolve(region="bad-value!") + + +class TestProvenanceTracking: + @pytest.mark.asyncio + async def test_source_of_env_resolved_field(self): + with patch.dict(os.environ, {"AWS_REGION": "us-west-2"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.source_of("region") == ConfigSource.ENV + + @pytest.mark.asyncio + async def test_source_of_profile_resolved_field(self): + fs = FakeFileSystem({"/fake/config": "[profile default]\nregion = eu-west-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", + ) + assert config.source_of("region") == ConfigSource.PROFILE + + @pytest.mark.asyncio + async def test_source_of_default_field(self): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.source_of("retry_strategy_options") == ConfigSource.DEFAULT + + @pytest.mark.asyncio + async def test_source_of_override_field(self): + with patch.dict(os.environ, {}, clear=True): + config = await AsyncAwsConfig.resolve( + region="us-east-1", fs=NullFileSystem() + ) + assert config.source_of("region") == ConfigSource.OVERRIDE + + @pytest.mark.asyncio + async def test_post_resolution_assignment_marks_source_as_override(self): + with patch.dict(os.environ, {"AWS_REGION": "us-west-2"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + assert config.source_of("region") == ConfigSource.ENV + config.region = "eu-west-1" + assert config.source_of("region") == ConfigSource.OVERRIDE + assert config.region == "eu-west-1" + + +class TestConstructionBlocking: + def test_direct_instantiation_raises_error(self): + with pytest.raises(ConfigError, match="cannot be constructed directly"): + AsyncAwsConfig() + + @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") + + @pytest.mark.parametrize( + "field_name,invalid_value,match", + [ + ("region", "bad-value!", "Must be a valid AWS region"), + ("region", None, "Region is required"), + ( + "retry_strategy_options", + "not-a-strategy", + "Must be RetryStrategyOptions", + ), + ], + ) + @pytest.mark.asyncio + async def test_setattr_validates_during_override( + self, + field_name: str, + invalid_value: str | None, + match: str, + ): + with patch.dict(os.environ, {"AWS_REGION": "us-east-1"}, clear=True): + config = await AsyncAwsConfig.resolve(fs=NullFileSystem()) + with pytest.raises(ConfigValidationError, match=match): + setattr(config, field_name, invalid_value) + + +class TestSharedConfigContext: + def test_default_profile_is_default(self): + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext() + assert ctx.profile_name == "default" + + def test_profile_from_aws_profile_env(self): + with patch.dict(os.environ, {"AWS_PROFILE": "work"}, clear=True): + ctx = SharedConfigContext() + assert ctx.profile_name == "work" + + def test_explicit_profile_overrides_env(self): + with patch.dict(os.environ, {"AWS_PROFILE": "work"}, clear=True): + ctx = SharedConfigContext(profile_name="custom") + assert ctx.profile_name == "custom" + + @pytest.mark.asyncio + async def test_parsed_profiles_caches_result(self): + fs = FakeFileSystem({"/fake/config": "[profile default]\nregion = us-east-1\n"}) + with patch.dict(os.environ, {}, clear=True): + ctx = SharedConfigContext( + fs=fs, + config_file_path="/fake/config", + credentials_file_path="/fake/credentials", + ) + result1 = await ctx.parsed_profiles() + result2 = await ctx.parsed_profiles() + assert result1 is result2 diff --git a/packages/smithy-aws-core/tests/unit/config/test_validators.py b/packages/smithy-aws-core/tests/unit/config/test_validators.py new file mode 100644 index 000000000..4dfdf1d1d --- /dev/null +++ b/packages/smithy-aws-core/tests/unit/config/test_validators.py @@ -0,0 +1,117 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Tests for config field validators.""" + +import pytest +from smithy_aws_core.config.types import FieldSpec +from smithy_aws_core.config.validators import ( + validate_max_attempts, + validate_region, + validate_retry_mode, + validate_retry_strategy_options, +) +from smithy_core.exceptions import ConfigValidationError +from smithy_core.retries import RetryStrategyOptions + + +class TestValidateRegion: + @pytest.mark.parametrize( + "region", + [ + "us-east-1", + "ap-southeast-2", + "eu-west-1", + "us", + "us-gov-west-1", + ], + ) + def test_valid_regions(self, region: str): + validate_region(region) + + def test_none_raises(self): + with pytest.raises(ConfigValidationError, match="Region is required"): + validate_region(None) + + @pytest.mark.parametrize( + "region,reason", + [ + ("", "empty string"), + ("-us-east-1", "starts with dash"), + ("us-east-1-", "ends with dash"), + ("12345", "all numbers"), + ("us-east-1!", "special characters"), + ("us east 1", "spaces"), + ], + ids=lambda x: x if isinstance(x, str) else "", + ) + def test_invalid_regions(self, region: str, reason: str): + with pytest.raises(ConfigValidationError, match="Must be a valid AWS region"): + validate_region(region) + + def test_non_string_raises(self): + with pytest.raises(ConfigValidationError, match="Must be a valid AWS region"): + validate_region(123) + + +class TestValidateRetryStrategyOptions: + def test_valid_default_options(self): + validate_retry_strategy_options(RetryStrategyOptions()) + + def test_valid_options_with_values(self): + validate_retry_strategy_options( + RetryStrategyOptions(retry_mode="standard", max_attempts=5) + ) + + @pytest.mark.parametrize( + "value", + [ + None, + "standard", + {"retry_mode": "standard"}, + 3, + ], + ids=["none", "string", "dict", "int"], + ) + def test_invalid_types_raise(self, value: object): + with pytest.raises(ConfigValidationError, match="Must be RetryStrategyOptions"): + validate_retry_strategy_options(value) + + +class TestFieldSpec: + """Tests for FieldSpec validation constraints.""" + + def test_default_only_is_valid(self): + FieldSpec(default=None) + + def test_default_factory_only_is_valid(self): + FieldSpec(default_factory=list) + + def test_both_default_and_factory_raises(self): + with pytest.raises(ValueError, match="cannot set both"): + FieldSpec(default=None, default_factory=list) + + def test_neither_default_nor_factory_raises(self): + with pytest.raises(ValueError, match="exactly one of"): + FieldSpec() + + +class TestValidateRetryMode: + @pytest.mark.parametrize("mode", ["standard"]) + def test_valid_modes(self, mode: str): + validate_retry_mode(mode) + + @pytest.mark.parametrize("mode", ["fake-mode", "", "STANDARD"]) + def test_invalid_modes(self, mode: str): + with pytest.raises(ConfigValidationError, match="retry_mode"): + validate_retry_mode(mode) + + +class TestValidateMaxAttempts: + @pytest.mark.parametrize("value", [1, 3, 10, 100]) + def test_valid_values(self, value: int): + validate_max_attempts(value) + + @pytest.mark.parametrize("value", [0, -1, -100]) + def test_invalid_values(self, value: int): + with pytest.raises(ConfigValidationError, match="max_attempts"): + validate_max_attempts(value) diff --git a/packages/smithy-core/src/smithy_core/exceptions.py b/packages/smithy-core/src/smithy_core/exceptions.py index 0a99976f9..9faf08ad4 100644 --- a/packages/smithy-core/src/smithy_core/exceptions.py +++ b/packages/smithy-core/src/smithy_core/exceptions.py @@ -116,3 +116,15 @@ class UnsupportedStreamError(SmithyError): class EndpointResolutionError(SmithyError): """Exception type for all exceptions raised by endpoint resolution.""" + + +class ConfigError(SmithyError): + """Raised when a config value cannot be constructed directly""" + + +class ConfigParseError(ConfigError): + """Raised when a config file cannot be parsed due to invalid syntax.""" + + +class ConfigValidationError(ConfigError): + """Raised when a config value cannot be validated"""