diff --git a/packages/smithy-testing/.changes/next-release/smithy-testing-feature-20251105202340.json b/packages/smithy-testing/.changes/next-release/smithy-testing-feature-20251105202340.json new file mode 100644 index 00000000..96880b21 --- /dev/null +++ b/packages/smithy-testing/.changes/next-release/smithy-testing-feature-20251105202340.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Added `SmithyKitchenSink` and `AwsKitchenSink` test clients for functional testing of smithy-python features." +} \ No newline at end of file diff --git a/packages/smithy-testing/NOTICE b/packages/smithy-testing/NOTICE new file mode 100644 index 00000000..616fc588 --- /dev/null +++ b/packages/smithy-testing/NOTICE @@ -0,0 +1 @@ +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/smithy-testing/README.md b/packages/smithy-testing/README.md new file mode 100644 index 00000000..bc9ccfee --- /dev/null +++ b/packages/smithy-testing/README.md @@ -0,0 +1,87 @@ +# smithy-testing + +This package provides generated test clients used to verify functionality in tools and libraries built with Smithy. + + +## Features +- **Generated Test Clients** - Real smithy-python clients for testing. +- **Test Fixtures** - Pytest fixtures for common testing scenarios. + + +## Test Clients + +### 1. SmithyKitchenSink (Generic Smithy Client) +- **Purpose**: Test core smithy-python functionality (retries, request/response pipeline, serde) +- **Auth**: HTTP Basic Auth +- **Use Cases**: Core smithy functionality, protocol testing, generic client behavior + +### 2. AwsKitchenSink (AWS Client) +- **Purpose**: Test AWS-specific functionality (SigV4 auth, credentials, endpoints) +- **Auth**: SigV4 +- **Use Cases**: AWS authentication, credential resolution, AWS retry behavior + + +## Quick Start + +### Basic Functional Test +```python +# In smithy-kitchen-sink/tests/ + +from smithy_core.retries import SimpleRetryStrategy +from smithy_http.testing import MockHTTPClient +from ..codegen.smithy_kitchen_sink.client import SmithyKitchenSink +from ..codegen.smithy_kitchen_sink.config import Config +from ..codegen.smithy_kitchen_sink.models import GetItemInput + + +async def test_simple_retry(): + # Set up mock responses + http_client = MockHTTPClient() + http_client.add_response( + status=500, + headers=[("X-Amzn-Errortype", "InternalError")], + body=b'{"message": "server error"}' + ) + http_client.add_response( + status=500, + headers=[("X-Amzn-Errortype", "InternalError")], + body=b'{"message": "server error"}' + ) + http_client.add_response(200, body=b'{"message": "success"}') + + # Create client with mock transport + config = Config( + transport=http_client, + endpoint_uri="https://test.example.com", + retry_strategy=SimpleRetryStrategy(max_attempts=3) + ) + client = SmithyKitchenSink(config=config) + + response = await client.get_item(GetItemInput(id="test-123")) + + assert http_client.call_count == 3 + assert response.message == "success" +``` + +## Development + +### Regenerating Test Clients + +After modifying Smithy models, regenerate the clients: +``` +python src/smithy_testing/internal/generate_clients.py +``` + +### Test Organization +Tests live in each client's directory: +- `smithy-kitchen-sink/tests/` +- `aws-kitchen-sink/tests/` + + +### Adding New Test Scenarios + +To add new operations or traits: +1. Edit the Smithy model files (`model/main.smithy`) +2. Update `smithy-build.json` if needed +3. Regenerate clients using the script above +4. Add tests using the new operations diff --git a/packages/smithy-testing/pyproject.toml b/packages/smithy-testing/pyproject.toml new file mode 100644 index 00000000..d07b0566 --- /dev/null +++ b/packages/smithy-testing/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "smithy-testing" +dynamic = ["version"] +requires-python = ">=3.12" +authors = [ + {name = "Amazon Web Services"}, +] +description = "Testing utilities and mock clients for smithy-python tests." +readme = "README.md" +license = {text = "Apache License 2.0"} +keywords = ["smithy", "sdk", "testing"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Natural Language :: English", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development :: Libraries" +] +dependencies = [ + "smithy-core~=0.1.0", + "smithy-http~=0.2.0", + "smithy-aws-core~=0.1.0", +] + +[project.urls] +"Changelog" = "https://github.com/smithy-lang/smithy-python/blob/develop/packages/smithy-testing/CHANGELOG.md" +"Code" = "https://github.com/smithy-lang/smithy-python/blob/develop/packages/smithy-testing/" +"Issue tracker" = "https://github.com/smithy-lang/smithy-python/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/smithy_testing/__init__.py" + +[tool.hatch.build] +exclude = [ + "tests", +] + +[tool.ruff] +src = ["src"] diff --git a/packages/smithy-testing/src/smithy_testing/__init__.py b/packages/smithy-testing/src/smithy_testing/__init__.py new file mode 100644 index 00000000..375cf945 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/__init__.py @@ -0,0 +1,4 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "0.0.1" diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/__init__.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/__init__.py new file mode 100644 index 00000000..30f6d44e --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/__init__.py @@ -0,0 +1,3 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +__version__: str = "0.0.1" diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/_private/__init__.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/_private/__init__.py new file mode 100644 index 00000000..247be3e3 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/_private/__init__.py @@ -0,0 +1 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/_private/schemas.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/_private/schemas.py new file mode 100644 index 00000000..a533a262 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/_private/schemas.py @@ -0,0 +1,124 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +from types import MappingProxyType + +from smithy_core.prelude import STRING +from smithy_core.schemas import Schema +from smithy_core.shapes import ShapeID, ShapeType +from smithy_core.traits import Trait + +CREATE_ITEM_INPUT = Schema.collection( + id=ShapeID("smithy.python.test#CreateItemInput"), + traits=[Trait.new(id=ShapeID("smithy.api#input"))], + members={ + "name": { + "target": STRING, + "traits": [Trait.new(id=ShapeID("smithy.api#required"))], + } + }, +) + +CREATE_ITEM_OUTPUT = Schema.collection( + id=ShapeID("smithy.python.test#CreateItemOutput"), + traits=[Trait.new(id=ShapeID("smithy.api#output"))], + members={"id": {"target": STRING}, "name": {"target": STRING}}, +) + +CREATE_ITEM = Schema( + id=ShapeID("smithy.python.test#CreateItem"), + shape_type=ShapeType.OPERATION, + traits=[ + Trait.new( + id=ShapeID("smithy.api#http"), + value=MappingProxyType({"code": 201, "method": "POST", "uri": "/items"}), + ) + ], +) + +GET_ITEM_INPUT = Schema.collection( + id=ShapeID("smithy.python.test#GetItemInput"), + traits=[Trait.new(id=ShapeID("smithy.api#input"))], + members={ + "id": { + "target": STRING, + "traits": [ + Trait.new(id=ShapeID("smithy.api#required")), + Trait.new(id=ShapeID("smithy.api#httpLabel")), + ], + } + }, +) + +GET_ITEM_OUTPUT = Schema.collection( + id=ShapeID("smithy.python.test#GetItemOutput"), + traits=[Trait.new(id=ShapeID("smithy.api#output"))], + members={"message": {"target": STRING}}, +) + +INTERNAL_ERROR = Schema.collection( + id=ShapeID("smithy.python.test#InternalError"), + traits=[ + Trait.new(id=ShapeID("smithy.api#error"), value="server"), + Trait.new(id=ShapeID("smithy.api#httpError"), value=500), + Trait.new(id=ShapeID("smithy.api#retryable")), + ], + members={"message": {"target": STRING}}, +) + +ITEM_NOT_FOUND = Schema.collection( + id=ShapeID("smithy.python.test#ItemNotFound"), + traits=[ + Trait.new(id=ShapeID("smithy.api#error"), value="client"), + Trait.new(id=ShapeID("smithy.api#httpError"), value=400), + ], + members={"message": {"target": STRING}}, +) + +SERVICE_UNAVAILABLE_ERROR = Schema.collection( + id=ShapeID("smithy.python.test#ServiceUnavailableError"), + traits=[ + Trait.new(id=ShapeID("smithy.api#error"), value="server"), + Trait.new(id=ShapeID("smithy.api#httpError"), value=503), + Trait.new(id=ShapeID("smithy.api#retryable")), + ], + members={"message": {"target": STRING}}, +) + +THROTTLING_ERROR = Schema.collection( + id=ShapeID("smithy.python.test#ThrottlingError"), + traits=[ + Trait.new(id=ShapeID("smithy.api#error"), value="client"), + Trait.new(id=ShapeID("smithy.api#httpError"), value=429), + Trait.new( + id=ShapeID("smithy.api#retryable"), + value=MappingProxyType({"throttling": True}), + ), + ], + members={"message": {"target": STRING}}, +) + +GET_ITEM = Schema( + id=ShapeID("smithy.python.test#GetItem"), + shape_type=ShapeType.OPERATION, + traits=[ + Trait.new( + id=ShapeID("smithy.api#http"), + value=MappingProxyType( + {"code": 200, "method": "GET", "uri": "/items/{id}"} + ), + ), + Trait.new(id=ShapeID("smithy.api#readonly")), + ], +) + +AWS_KITCHEN_SINK = Schema( + id=ShapeID("smithy.python.test#AwsKitchenSink"), + shape_type=ShapeType.SERVICE, + traits=[ + Trait.new( + id=ShapeID("aws.auth#sigv4"), + value=MappingProxyType({"name": "awskitchensink"}), + ), + Trait.new(id=ShapeID("aws.protocols#restJson1")), + ], +) diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/auth.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/auth.py new file mode 100644 index 00000000..fcc40430 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/auth.py @@ -0,0 +1,29 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +from typing import Any + +from smithy_core.auth import AuthOption, AuthParams +from smithy_core.interfaces.auth import AuthOption as AuthOptionProtocol +from smithy_core.shapes import ShapeID + + +class HTTPAuthSchemeResolver: + def resolve_auth_scheme( + self, auth_parameters: AuthParams[Any, Any] + ) -> list[AuthOptionProtocol]: + auth_options: list[AuthOptionProtocol] = [] + + if (option := _generate_sigv4_option(auth_parameters)) is not None: + auth_options.append(option) + + return auth_options + + +def _generate_sigv4_option( + auth_params: AuthParams[Any, Any], +) -> AuthOptionProtocol | None: + return AuthOption( + scheme_id=ShapeID("aws.auth#sigv4"), + identity_properties={}, # type: ignore + signer_properties={}, # type: ignore + ) diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/client.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/client.py new file mode 100644 index 00000000..4862e94e --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/client.py @@ -0,0 +1,121 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +import logging +from copy import deepcopy + +from smithy_core.aio.client import ClientCall, RequestPipeline +from smithy_core.exceptions import ExpectationNotMetError +from smithy_core.interceptors import InterceptorChain +from smithy_core.types import TypedProperties +from smithy_http.plugins import user_agent_plugin + +from .config import Config, Plugin +from .models import ( + CREATE_ITEM, + GET_ITEM, + CreateItemInput, + CreateItemOutput, + GetItemInput, + GetItemOutput, +) +from .user_agent import aws_user_agent_plugin + +logger = logging.getLogger(__name__) + + +class AwsKitchenSink: + """ + Client for AwsKitchenSink + + :param config: Optional configuration for the client. Here you can set things like the + endpoint for HTTP services or auth credentials. + + :param plugins: A list of callables that modify the configuration dynamically. These + can be used to set defaults, for example. + """ + + def __init__( + self, config: Config | None = None, plugins: list[Plugin] | None = None + ): + self._config = config or Config() + + client_plugins: list[Plugin] = [aws_user_agent_plugin, user_agent_plugin] + if plugins: + client_plugins.extend(plugins) + + for plugin in client_plugins: + plugin(self._config) + + async def create_item( + self, input: CreateItemInput, plugins: list[Plugin] | None = None + ) -> CreateItemOutput: + """ + Invokes the CreateItem operation. + + :param input: The operation's input. + + :param plugins: A list of callables that modify the configuration dynamically. + Changes made by these plugins only apply for the duration of the operation + execution and will not affect any other operation invocations. + + """ + operation_plugins: list[Plugin] = [] + if plugins: + operation_plugins.extend(plugins) + config = deepcopy(self._config) + for plugin in operation_plugins: + plugin(config) + if config.protocol is None or config.transport is None: + raise ExpectationNotMetError( + "protocol and transport MUST be set on the config to make calls." + ) + pipeline = RequestPipeline(protocol=config.protocol, transport=config.transport) + call = ClientCall( + input=input, + operation=CREATE_ITEM, + context=TypedProperties({"config": config}), + interceptor=InterceptorChain(config.interceptors), + auth_scheme_resolver=config.auth_scheme_resolver, + supported_auth_schemes=config.auth_schemes, + endpoint_resolver=config.endpoint_resolver, + retry_strategy=config.retry_strategy, + ) + + return await pipeline(call) + + async def get_item( + self, input: GetItemInput, plugins: list[Plugin] | None = None + ) -> GetItemOutput: + """ + Invokes the GetItem operation. + + :param input: The operation's input. + + :param plugins: A list of callables that modify the configuration dynamically. + Changes made by these plugins only apply for the duration of the operation + execution and will not affect any other operation invocations. + + """ + operation_plugins: list[Plugin] = [] + if plugins: + operation_plugins.extend(plugins) + config = deepcopy(self._config) + for plugin in operation_plugins: + plugin(config) + if config.protocol is None or config.transport is None: + raise ExpectationNotMetError( + "protocol and transport MUST be set on the config to make calls." + ) + pipeline = RequestPipeline(protocol=config.protocol, transport=config.transport) + call = ClientCall( + input=input, + operation=GET_ITEM, + context=TypedProperties({"config": config}), + interceptor=InterceptorChain(config.interceptors), + auth_scheme_resolver=config.auth_scheme_resolver, + supported_auth_schemes=config.auth_schemes, + endpoint_resolver=config.endpoint_resolver, + retry_strategy=config.retry_strategy, + ) + + return await pipeline(call) diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/config.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/config.py new file mode 100644 index 00000000..ab925491 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/config.py @@ -0,0 +1,174 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, TypeAlias, Union + +from smithy_aws_core.aio.protocols import RestJsonClientProtocol +from smithy_aws_core.auth import SigV4AuthScheme +from smithy_aws_core.endpoints.standard_regional import ( + StandardRegionalEndpointsResolver as _RegionalResolver, +) +from smithy_aws_core.identity import AWSCredentialsIdentity, AWSIdentityProperties +from smithy_core.aio.interfaces import ( + ClientProtocol, + ClientTransport, +) +from smithy_core.aio.interfaces import ( + EndpointResolver as _EndpointResolver, +) +from smithy_core.aio.interfaces.auth import AuthScheme +from smithy_core.aio.interfaces.identity import IdentityResolver +from smithy_core.interceptors import Interceptor +from smithy_core.interfaces import URI +from smithy_core.interfaces.retries import RetryStrategy +from smithy_core.retries import StandardRetryStrategy +from smithy_core.shapes import ShapeID +from smithy_http.aio.aiohttp import AIOHTTPClient +from smithy_http.interfaces import HTTPRequestConfiguration + +from ._private.schemas import AWS_KITCHEN_SINK as _SCHEMA_AWS_KITCHEN_SINK +from .auth import HTTPAuthSchemeResolver +from .models import CreateItemInput, CreateItemOutput, GetItemInput, GetItemOutput + +_ServiceInterceptor = Union[ + Interceptor[CreateItemInput, CreateItemOutput, Any, Any], + Interceptor[GetItemInput, GetItemOutput, Any, Any], +] + + +@dataclass(init=False) +class Config: + """Configuration for AwsKitchenSink.""" + + auth_scheme_resolver: HTTPAuthSchemeResolver + auth_schemes: dict[ShapeID, AuthScheme[Any, Any, Any, Any]] + aws_access_key_id: str | None + aws_credentials_identity_resolver: ( + IdentityResolver[AWSCredentialsIdentity, AWSIdentityProperties] | None + ) + aws_secret_access_key: str | None + aws_session_token: str | None + endpoint_resolver: _EndpointResolver + endpoint_uri: str | URI | None + http_request_config: HTTPRequestConfiguration | None + interceptors: list[_ServiceInterceptor] + protocol: ClientProtocol[Any, Any] | None + region: str | None + retry_strategy: RetryStrategy + sdk_ua_app_id: str | None + transport: ClientTransport[Any, Any] | None + user_agent_extra: str | None + + def __init__( + self, + *, + auth_scheme_resolver: HTTPAuthSchemeResolver | None = None, + auth_schemes: dict[ShapeID, AuthScheme[Any, Any, Any, Any]] | None = None, + aws_access_key_id: str | None = None, + aws_credentials_identity_resolver: IdentityResolver[ + AWSCredentialsIdentity, AWSIdentityProperties + ] + | None = None, + aws_secret_access_key: str | None = None, + aws_session_token: str | None = None, + endpoint_resolver: _EndpointResolver | None = None, + endpoint_uri: str | URI | None = None, + http_request_config: HTTPRequestConfiguration | None = None, + interceptors: list[_ServiceInterceptor] | None = None, + protocol: ClientProtocol[Any, Any] | None = None, + region: str | None = None, + retry_strategy: RetryStrategy | None = None, + sdk_ua_app_id: str | None = None, + transport: ClientTransport[Any, Any] | None = None, + user_agent_extra: str | None = None, + ): + """Constructor. + + :param auth_scheme_resolver: + An auth scheme resolver that determines the auth scheme for each operation. + + :param auth_schemes: + A map of auth scheme ids to auth schemes. + + :param aws_access_key_id: + The identifier for a secret access key. + + :param aws_credentials_identity_resolver: + Resolves AWS Credentials. Required for operations that use Sigv4 Auth. + + :param aws_secret_access_key: + A secret access key that can be used to sign requests. + + :param aws_session_token: + An access key ID that identifies temporary security credentials. + + :param endpoint_resolver: + The endpoint resolver used to resolve the final endpoint per-operation based on + the configuration. + + :param endpoint_uri: + A static URI to route requests to. + + :param http_request_config: + Configuration for individual HTTP requests. + + :param interceptors: + The list of interceptors, which are hooks that are called during the execution + of a request. + + :param protocol: + The protocol to serialize and deserialize requests with. + + :param region: + The AWS region to connect to. The configured region is used to determine the + service endpoint. + + :param retry_strategy: + The retry strategy for issuing retry tokens and computing retry delays. + + :param sdk_ua_app_id: + A unique and opaque application ID that is appended to the User-Agent header. + + :param transport: + The transport to use to send requests (e.g. an HTTP client). + + :param user_agent_extra: + Additional suffix to be added to the User-Agent header. + + """ + self.auth_scheme_resolver = auth_scheme_resolver or HTTPAuthSchemeResolver() + self.auth_schemes = auth_schemes or { + ShapeID("aws.auth#sigv4"): SigV4AuthScheme(service="awskitchensink") + } + self.aws_access_key_id = aws_access_key_id + self.aws_credentials_identity_resolver = aws_credentials_identity_resolver + self.aws_secret_access_key = aws_secret_access_key + self.aws_session_token = aws_session_token + self.endpoint_resolver = endpoint_resolver or _RegionalResolver( + endpoint_prefix="AwsKitchenSink" + ) + self.endpoint_uri = endpoint_uri + self.http_request_config = http_request_config + self.interceptors = interceptors or [] + self.protocol = protocol or RestJsonClientProtocol(_SCHEMA_AWS_KITCHEN_SINK) + self.region = region + self.retry_strategy = retry_strategy or StandardRetryStrategy() + self.sdk_ua_app_id = sdk_ua_app_id + self.transport = transport or AIOHTTPClient() + self.user_agent_extra = user_agent_extra + + def set_auth_scheme(self, scheme: AuthScheme[Any, Any, Any, Any]) -> None: + """Sets the implementation of an auth scheme. + + Using this method ensures the correct key is used. + + :param scheme: The auth scheme to add. + """ + self.auth_schemes[scheme.scheme_id] = scheme + + +# +# A callable that allows customizing the config object on each request. +# +Plugin: TypeAlias = Callable[[Config], None] diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/models.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/models.py new file mode 100644 index 00000000..1f01cafb --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/models.py @@ -0,0 +1,374 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +import logging +from dataclasses import dataclass +from typing import Any, Literal, Self + +from smithy_core.deserializers import ShapeDeserializer +from smithy_core.documents import TypeRegistry +from smithy_core.exceptions import ModeledError +from smithy_core.schemas import APIOperation, Schema +from smithy_core.serializers import ShapeSerializer +from smithy_core.shapes import ShapeID + +from ._private.schemas import ( + CREATE_ITEM as _SCHEMA_CREATE_ITEM, +) +from ._private.schemas import ( + CREATE_ITEM_INPUT as _SCHEMA_CREATE_ITEM_INPUT, +) +from ._private.schemas import ( + CREATE_ITEM_OUTPUT as _SCHEMA_CREATE_ITEM_OUTPUT, +) +from ._private.schemas import ( + GET_ITEM as _SCHEMA_GET_ITEM, +) +from ._private.schemas import ( + GET_ITEM_INPUT as _SCHEMA_GET_ITEM_INPUT, +) +from ._private.schemas import ( + GET_ITEM_OUTPUT as _SCHEMA_GET_ITEM_OUTPUT, +) +from ._private.schemas import ( + INTERNAL_ERROR as _SCHEMA_INTERNAL_ERROR, +) +from ._private.schemas import ( + ITEM_NOT_FOUND as _SCHEMA_ITEM_NOT_FOUND, +) +from ._private.schemas import ( + SERVICE_UNAVAILABLE_ERROR as _SCHEMA_SERVICE_UNAVAILABLE_ERROR, +) +from ._private.schemas import ( + THROTTLING_ERROR as _SCHEMA_THROTTLING_ERROR, +) + +logger = logging.getLogger(__name__) + + +class ServiceError(ModeledError): + """Base error for all errors in the service. + + Some exceptions do not extend from this class, including + synthetic, implicit, and shared exception types. + """ + + +@dataclass(kw_only=True) +class CreateItemInput: + name: str | None = None + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_CREATE_ITEM_INPUT, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.name is not None: + serializer.write_string( + _SCHEMA_CREATE_ITEM_INPUT.members["name"], self.name + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["name"] = de.read_string( + _SCHEMA_CREATE_ITEM_INPUT.members["name"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_CREATE_ITEM_INPUT, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class CreateItemOutput: + id: str | None = None + + name: str | None = None + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_CREATE_ITEM_OUTPUT, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.id is not None: + serializer.write_string(_SCHEMA_CREATE_ITEM_OUTPUT.members["id"], self.id) + + if self.name is not None: + serializer.write_string( + _SCHEMA_CREATE_ITEM_OUTPUT.members["name"], self.name + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["id"] = de.read_string( + _SCHEMA_CREATE_ITEM_OUTPUT.members["id"] + ) + + case 1: + kwargs["name"] = de.read_string( + _SCHEMA_CREATE_ITEM_OUTPUT.members["name"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_CREATE_ITEM_OUTPUT, consumer=_consumer) + return kwargs + + +CREATE_ITEM = APIOperation( + input=CreateItemInput, + output=CreateItemOutput, + schema=_SCHEMA_CREATE_ITEM, + input_schema=_SCHEMA_CREATE_ITEM_INPUT, + output_schema=_SCHEMA_CREATE_ITEM_OUTPUT, + error_registry=TypeRegistry({}), + effective_auth_schemes=[ShapeID("aws.auth#sigv4")], +) + + +@dataclass(kw_only=True) +class GetItemInput: + id: str | None = None + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_GET_ITEM_INPUT, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.id is not None: + serializer.write_string(_SCHEMA_GET_ITEM_INPUT.members["id"], self.id) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["id"] = de.read_string(_SCHEMA_GET_ITEM_INPUT.members["id"]) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_GET_ITEM_INPUT, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class GetItemOutput: + message: str | None = None + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_GET_ITEM_OUTPUT, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_GET_ITEM_OUTPUT.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_GET_ITEM_OUTPUT.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_GET_ITEM_OUTPUT, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class InternalError(ServiceError): + fault: Literal["client", "server"] | None = "server" + is_retry_safe: bool | None = True + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_INTERNAL_ERROR, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_INTERNAL_ERROR.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_INTERNAL_ERROR.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_INTERNAL_ERROR, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class ItemNotFound(ServiceError): + fault: Literal["client", "server"] | None = "client" + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_ITEM_NOT_FOUND, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_ITEM_NOT_FOUND.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_ITEM_NOT_FOUND.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_ITEM_NOT_FOUND, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class ServiceUnavailableError(ServiceError): + fault: Literal["client", "server"] | None = "server" + is_retry_safe: bool | None = True + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_SERVICE_UNAVAILABLE_ERROR, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_SERVICE_UNAVAILABLE_ERROR.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_SERVICE_UNAVAILABLE_ERROR.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_SERVICE_UNAVAILABLE_ERROR, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class ThrottlingError(ServiceError): + fault: Literal["client", "server"] | None = "client" + is_retry_safe: bool | None = True + is_throttling_error: bool = True + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_THROTTLING_ERROR, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_THROTTLING_ERROR.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_THROTTLING_ERROR.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_THROTTLING_ERROR, consumer=_consumer) + return kwargs + + +GET_ITEM = APIOperation( + input=GetItemInput, + output=GetItemOutput, + schema=_SCHEMA_GET_ITEM, + input_schema=_SCHEMA_GET_ITEM_INPUT, + output_schema=_SCHEMA_GET_ITEM_OUTPUT, + error_registry=TypeRegistry( + { + ShapeID("smithy.python.test#ItemNotFound"): ItemNotFound, + ShapeID("smithy.python.test#ThrottlingError"): ThrottlingError, + ShapeID("smithy.python.test#InternalError"): InternalError, + ShapeID( + "smithy.python.test#ServiceUnavailableError" + ): ServiceUnavailableError, + } + ), + effective_auth_schemes=[ShapeID("aws.auth#sigv4")], +) diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/user_agent.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/codegen/aws_kitchen_sink/user_agent.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/model/main.smithy b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/model/main.smithy new file mode 100644 index 00000000..54ea71f7 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/model/main.smithy @@ -0,0 +1,76 @@ +$version: "2" + +namespace smithy.python.test + +use aws.auth#sigv4 +use aws.protocols#restJson1 +use smithy.api#retryable + + +@restJson1 +@sigv4(name: "awskitchensink") +service AwsKitchenSink { + version: "2025-10-31" + operations: [ + CreateItem + GetItem + ] +} + +@http(code: 201, method: "POST", uri: "/items") +operation CreateItem { + input := { + @required + name: String + } + output := { + id: String + name: String + } +} + +@http(code: 200, method: "GET", uri: "/items/{id}") +@readonly +operation GetItem { + input := { + @required + @httpLabel + id: String + } + output := { + message: String + } + errors: [ + ItemNotFound + ThrottlingError + InternalError + ServiceUnavailableError + ] +} + +@error("client") +@httpError(400) +structure ItemNotFound { + message: String +} + +@error("client") +@retryable(throttling: true) +@httpError(429) +structure ThrottlingError { + message: String +} + +@error("server") +@retryable +@httpError(500) +structure InternalError { + message: String +} + +@error("server") +@retryable +@httpError(503) +structure ServiceUnavailableError { + message: String +} diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/smithy-build.json b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/smithy-build.json new file mode 100644 index 00000000..fde1ed8a --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/smithy-build.json @@ -0,0 +1,23 @@ +{ + "version": "1.0", + "sources": ["model"], + "maven": { + "dependencies": [ + "software.amazon.smithy:smithy-model:[1.54.0,2.0)", + "software.amazon.smithy:smithy-aws-traits:[1.54.0,2.0)", + "software.amazon.smithy:smithy-aws-endpoints:[1.54.0,2.0)", + "software.amazon.smithy.python.codegen.aws:core:0.0.1" + ] + }, + "projections": { + "client": { + "plugins": { + "python-client-codegen": { + "service": "smithy.python.test#AwsKitchenSink", + "module": "aws_kitchen_sink", + "moduleVersion": "0.0.1" + } + } + } + } +} \ No newline at end of file diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/tests/conftest.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/tests/conftest.py new file mode 100644 index 00000000..e9eb0e68 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/tests/conftest.py @@ -0,0 +1,27 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from smithy_aws_core.identity import StaticCredentialsResolver +from smithy_http.testing import MockHTTPClient + +from ..codegen.aws_kitchen_sink.client import AwsKitchenSink +from ..codegen.aws_kitchen_sink.config import Config + + +@pytest.fixture +def http_client() -> MockHTTPClient: + return MockHTTPClient() + + +@pytest.fixture +def client(http_client: MockHTTPClient) -> AwsKitchenSink: + config = Config( + transport=http_client, + endpoint_uri="https://test.aws.dev", + aws_access_key_id="fake-access-key", + aws_secret_access_key="fake-secret-key", + aws_credentials_identity_resolver=StaticCredentialsResolver(), + region="us-west-2", + ) + return AwsKitchenSink(config=config) diff --git a/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/tests/test_standard_retry.py b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/tests/test_standard_retry.py new file mode 100644 index 00000000..bcd831b9 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/aws-kitchen-sink/tests/test_standard_retry.py @@ -0,0 +1,102 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from smithy_aws_core.identity import StaticCredentialsResolver +from smithy_core.retries import StandardRetryQuota +from smithy_http.testing import MockHTTPClient + +from ..codegen.aws_kitchen_sink.client import AwsKitchenSink +from ..codegen.aws_kitchen_sink.config import Config +from ..codegen.aws_kitchen_sink.models import GetItemInput, ServiceError + + +def add_error_responses(http_client: MockHTTPClient, count: int) -> None: + for _ in range(count): + http_client.add_response( + status=500, + headers=[("X-Amzn-Errortype", "InternalError")], + body=b'{"message": "Server Error"}', + ) + + +async def test_retry_eventually_succeeds( + http_client: MockHTTPClient, + client: AwsKitchenSink, +): + add_error_responses(http_client, 2) + http_client.add_response(status=200, body=b'{"message": "success"}') + + response = await client.get_item(GetItemInput(id="test-123")) + + assert http_client.call_count == 3 + assert response.message == "success" + + +async def test_max_attempts_exceeded( + http_client: MockHTTPClient, + client: AwsKitchenSink, +): + add_error_responses(http_client, 3) + + with pytest.raises(ServiceError): + await client.get_item(GetItemInput(id="test-123")) + + assert http_client.call_count == 3 + + +async def test_retry_quota_exceeded( + monkeypatch: pytest.MonkeyPatch, + http_client: MockHTTPClient, +): + monkeypatch.setattr(StandardRetryQuota, "INITIAL_RETRY_TOKENS", 5, raising=False) + + add_error_responses(http_client, 2) + + config = Config( + transport=http_client, + endpoint_uri="https://test.smithy.dev", + aws_access_key_id="fake-access-key", + aws_secret_access_key="fake-secret-key", + aws_credentials_identity_resolver=StaticCredentialsResolver(), + region="us-west-2", + ) + client = AwsKitchenSink(config=config) + + with pytest.raises(ServiceError): + await client.get_item(GetItemInput(id="test-123")) + + assert http_client.call_count == 2 + + +async def test_throttling_error_retry( + http_client: MockHTTPClient, + client: AwsKitchenSink, +): + http_client.add_response( + status=429, + headers=[("X-Amzn-Errortype", "ThrottlingError")], + body=b'{"message": "Rate exceeded"}', + ) + http_client.add_response(200, body=b'{"message": "success"}') + + response = await client.get_item(GetItemInput(id="test-123")) + + assert http_client.call_count == 2 + assert response.message == "success" + + +async def test_non_retryable_error( + http_client: MockHTTPClient, + client: AwsKitchenSink, +): + http_client.add_response( + status=400, + headers=[("X-Amzn-Errortype", "ItemNotFound")], + body=b'{"message": "Item not found"}', + ) + + with pytest.raises(ServiceError): + await client.get_item(GetItemInput(id="nonexistent")) + + assert http_client.call_count == 1 diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/__init__.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/__init__.py new file mode 100644 index 00000000..30f6d44e --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/__init__.py @@ -0,0 +1,3 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +__version__: str = "0.0.1" diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/_private/__init__.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/_private/__init__.py new file mode 100644 index 00000000..247be3e3 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/_private/__init__.py @@ -0,0 +1 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/_private/schemas.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/_private/schemas.py new file mode 100644 index 00000000..075180b6 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/_private/schemas.py @@ -0,0 +1,121 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +from types import MappingProxyType + +from smithy_core.prelude import STRING +from smithy_core.schemas import Schema +from smithy_core.shapes import ShapeID, ShapeType +from smithy_core.traits import Trait + +CREATE_ITEM_INPUT = Schema.collection( + id=ShapeID("smithy.python.test#CreateItemInput"), + traits=[Trait.new(id=ShapeID("smithy.api#input"))], + members={ + "name": { + "target": STRING, + "traits": [Trait.new(id=ShapeID("smithy.api#required"))], + } + }, +) + +CREATE_ITEM_OUTPUT = Schema.collection( + id=ShapeID("smithy.python.test#CreateItemOutput"), + traits=[Trait.new(id=ShapeID("smithy.api#output"))], + members={"id": {"target": STRING}, "name": {"target": STRING}}, +) + +CREATE_ITEM = Schema( + id=ShapeID("smithy.python.test#CreateItem"), + shape_type=ShapeType.OPERATION, + traits=[ + Trait.new( + id=ShapeID("smithy.api#http"), + value=MappingProxyType({"code": 201, "method": "POST", "uri": "/items"}), + ) + ], +) + +GET_ITEM_INPUT = Schema.collection( + id=ShapeID("smithy.python.test#GetItemInput"), + traits=[Trait.new(id=ShapeID("smithy.api#input"))], + members={ + "id": { + "target": STRING, + "traits": [ + Trait.new(id=ShapeID("smithy.api#required")), + Trait.new(id=ShapeID("smithy.api#httpLabel")), + ], + } + }, +) + +GET_ITEM_OUTPUT = Schema.collection( + id=ShapeID("smithy.python.test#GetItemOutput"), + traits=[Trait.new(id=ShapeID("smithy.api#output"))], + members={"message": {"target": STRING}}, +) + +INTERNAL_ERROR = Schema.collection( + id=ShapeID("smithy.python.test#InternalError"), + traits=[ + Trait.new(id=ShapeID("smithy.api#error"), value="server"), + Trait.new(id=ShapeID("smithy.api#httpError"), value=500), + Trait.new(id=ShapeID("smithy.api#retryable")), + ], + members={"message": {"target": STRING}}, +) + +ITEM_NOT_FOUND = Schema.collection( + id=ShapeID("smithy.python.test#ItemNotFound"), + traits=[ + Trait.new(id=ShapeID("smithy.api#error"), value="client"), + Trait.new(id=ShapeID("smithy.api#httpError"), value=400), + ], + members={"message": {"target": STRING}}, +) + +SERVICE_UNAVAILABLE_ERROR = Schema.collection( + id=ShapeID("smithy.python.test#ServiceUnavailableError"), + traits=[ + Trait.new(id=ShapeID("smithy.api#error"), value="server"), + Trait.new(id=ShapeID("smithy.api#httpError"), value=503), + Trait.new(id=ShapeID("smithy.api#retryable")), + ], + members={"message": {"target": STRING}}, +) + +THROTTLING_ERROR = Schema.collection( + id=ShapeID("smithy.python.test#ThrottlingError"), + traits=[ + Trait.new(id=ShapeID("smithy.api#error"), value="client"), + Trait.new(id=ShapeID("smithy.api#httpError"), value=429), + Trait.new( + id=ShapeID("smithy.api#retryable"), + value=MappingProxyType({"throttling": True}), + ), + ], + members={"message": {"target": STRING}}, +) + +GET_ITEM = Schema( + id=ShapeID("smithy.python.test#GetItem"), + shape_type=ShapeType.OPERATION, + traits=[ + Trait.new( + id=ShapeID("smithy.api#http"), + value=MappingProxyType( + {"code": 200, "method": "GET", "uri": "/items/{id}"} + ), + ), + Trait.new(id=ShapeID("smithy.api#readonly")), + ], +) + +SMITHY_KITCHEN_SINK = Schema( + id=ShapeID("smithy.python.test#SmithyKitchenSink"), + shape_type=ShapeType.SERVICE, + traits=[ + Trait.new(id=ShapeID("smithy.api#httpBasicAuth")), + Trait.new(id=ShapeID("aws.protocols#restJson1")), + ], +) diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/auth.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/auth.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/client.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/client.py new file mode 100644 index 00000000..7eada3e9 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/client.py @@ -0,0 +1,120 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +import logging +from copy import deepcopy + +from smithy_core.aio.client import ClientCall, RequestPipeline +from smithy_core.exceptions import ExpectationNotMetError +from smithy_core.interceptors import InterceptorChain +from smithy_core.types import TypedProperties +from smithy_http.plugins import user_agent_plugin + +from .config import Config, Plugin +from .models import ( + CREATE_ITEM, + GET_ITEM, + CreateItemInput, + CreateItemOutput, + GetItemInput, + GetItemOutput, +) + +logger = logging.getLogger(__name__) + + +class SmithyKitchenSink: + """ + Client for SmithyKitchenSink + + :param config: Optional configuration for the client. Here you can set things like the + endpoint for HTTP services or auth credentials. + + :param plugins: A list of callables that modify the configuration dynamically. These + can be used to set defaults, for example. + """ + + def __init__( + self, config: Config | None = None, plugins: list[Plugin] | None = None + ): + self._config = config or Config() + + client_plugins: list[Plugin] = [user_agent_plugin] + if plugins: + client_plugins.extend(plugins) + + for plugin in client_plugins: + plugin(self._config) + + async def create_item( + self, input: CreateItemInput, plugins: list[Plugin] | None = None + ) -> CreateItemOutput: + """ + Invokes the CreateItem operation. + + :param input: The operation's input. + + :param plugins: A list of callables that modify the configuration dynamically. + Changes made by these plugins only apply for the duration of the operation + execution and will not affect any other operation invocations. + + """ + operation_plugins: list[Plugin] = [] + if plugins: + operation_plugins.extend(plugins) + config = deepcopy(self._config) + for plugin in operation_plugins: + plugin(config) + if config.protocol is None or config.transport is None: + raise ExpectationNotMetError( + "protocol and transport MUST be set on the config to make calls." + ) + pipeline = RequestPipeline(protocol=config.protocol, transport=config.transport) + call = ClientCall( + input=input, + operation=CREATE_ITEM, + context=TypedProperties({"config": config}), + interceptor=InterceptorChain(config.interceptors), + auth_scheme_resolver=config.auth_scheme_resolver, + supported_auth_schemes=config.auth_schemes, + endpoint_resolver=config.endpoint_resolver, + retry_strategy=config.retry_strategy, + ) + + return await pipeline(call) + + async def get_item( + self, input: GetItemInput, plugins: list[Plugin] | None = None + ) -> GetItemOutput: + """ + Invokes the GetItem operation. + + :param input: The operation's input. + + :param plugins: A list of callables that modify the configuration dynamically. + Changes made by these plugins only apply for the duration of the operation + execution and will not affect any other operation invocations. + + """ + operation_plugins: list[Plugin] = [] + if plugins: + operation_plugins.extend(plugins) + config = deepcopy(self._config) + for plugin in operation_plugins: + plugin(config) + if config.protocol is None or config.transport is None: + raise ExpectationNotMetError( + "protocol and transport MUST be set on the config to make calls." + ) + pipeline = RequestPipeline(protocol=config.protocol, transport=config.transport) + call = ClientCall( + input=input, + operation=GET_ITEM, + context=TypedProperties({"config": config}), + interceptor=InterceptorChain(config.interceptors), + auth_scheme_resolver=config.auth_scheme_resolver, + supported_auth_schemes=config.auth_schemes, + endpoint_resolver=config.endpoint_resolver, + retry_strategy=config.retry_strategy, + ) + + return await pipeline(call) diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/config.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/config.py new file mode 100644 index 00000000..cb9a5b27 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/config.py @@ -0,0 +1,117 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, TypeAlias, Union + +from smithy_aws_core.aio.protocols import RestJsonClientProtocol +from smithy_core.aio.endpoints import StaticEndpointResolver +from smithy_core.aio.interfaces import ( + ClientProtocol, + ClientTransport, +) +from smithy_core.aio.interfaces import ( + EndpointResolver as _EndpointResolver, +) +from smithy_core.aio.interfaces.auth import AuthScheme +from smithy_core.interceptors import Interceptor +from smithy_core.interfaces import URI +from smithy_core.interfaces.retries import RetryStrategy +from smithy_core.retries import SimpleRetryStrategy +from smithy_core.shapes import ShapeID +from smithy_http.aio.aiohttp import AIOHTTPClient +from smithy_http.interfaces import HTTPRequestConfiguration + +from ._private.schemas import SMITHY_KITCHEN_SINK as _SCHEMA_SMITHY_KITCHEN_SINK +from .auth import HTTPAuthSchemeResolver +from .models import CreateItemInput, CreateItemOutput, GetItemInput, GetItemOutput + +_ServiceInterceptor = Union[ + Interceptor[CreateItemInput, CreateItemOutput, Any, Any], + Interceptor[GetItemInput, GetItemOutput, Any, Any], +] + + +@dataclass(init=False) +class Config: + """Configuration for SmithyKitchenSink.""" + + auth_scheme_resolver: HTTPAuthSchemeResolver + auth_schemes: dict[ShapeID, AuthScheme[Any, Any, Any, Any]] + endpoint_resolver: _EndpointResolver + endpoint_uri: str | URI | None + http_request_config: HTTPRequestConfiguration | None + interceptors: list[_ServiceInterceptor] + protocol: ClientProtocol[Any, Any] | None + retry_strategy: RetryStrategy + transport: ClientTransport[Any, Any] | None + + def __init__( + self, + *, + auth_scheme_resolver: HTTPAuthSchemeResolver | None = None, + auth_schemes: dict[ShapeID, AuthScheme[Any, Any, Any, Any]] | None = None, + endpoint_resolver: _EndpointResolver | None = None, + endpoint_uri: str | URI | None = None, + http_request_config: HTTPRequestConfiguration | None = None, + interceptors: list[_ServiceInterceptor] | None = None, + protocol: ClientProtocol[Any, Any] | None = None, + retry_strategy: RetryStrategy | None = None, + transport: ClientTransport[Any, Any] | None = None, + ): + """Constructor. + + :param auth_scheme_resolver: + An auth scheme resolver that determines the auth scheme for each operation. + + :param auth_schemes: + A map of auth scheme ids to auth schemes. + + :param endpoint_resolver: + The endpoint resolver used to resolve the final endpoint per-operation based on + the configuration. + + :param endpoint_uri: + A static URI to route requests to. + + :param http_request_config: + Configuration for individual HTTP requests. + + :param interceptors: + The list of interceptors, which are hooks that are called during the execution + of a request. + + :param protocol: + The protocol to serialize and deserialize requests with. + + :param retry_strategy: + The retry strategy for issuing retry tokens and computing retry delays. + + :param transport: + The transport to use to send requests (e.g. an HTTP client). + + """ + self.auth_scheme_resolver = auth_scheme_resolver or HTTPAuthSchemeResolver() + self.auth_schemes = auth_schemes or {} + self.endpoint_resolver = endpoint_resolver or StaticEndpointResolver() + self.endpoint_uri = endpoint_uri + self.http_request_config = http_request_config + self.interceptors = interceptors or [] + self.protocol = protocol or RestJsonClientProtocol(_SCHEMA_SMITHY_KITCHEN_SINK) + self.retry_strategy = retry_strategy or SimpleRetryStrategy() + self.transport = transport or AIOHTTPClient() + + def set_auth_scheme(self, scheme: AuthScheme[Any, Any, Any, Any]) -> None: + """Sets the implementation of an auth scheme. + + Using this method ensures the correct key is used. + + :param scheme: The auth scheme to add. + """ + self.auth_schemes[scheme.scheme_id] = scheme + + +# +# A callable that allows customizing the config object on each request. +# +Plugin: TypeAlias = Callable[[Config], None] diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/models.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/models.py new file mode 100644 index 00000000..4b822735 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/codegen/smithy_kitchen_sink/models.py @@ -0,0 +1,374 @@ +# Code generated by smithy-python-codegen DO NOT EDIT. + +import logging +from dataclasses import dataclass +from typing import Any, Literal, Self + +from smithy_core.deserializers import ShapeDeserializer +from smithy_core.documents import TypeRegistry +from smithy_core.exceptions import ModeledError +from smithy_core.schemas import APIOperation, Schema +from smithy_core.serializers import ShapeSerializer +from smithy_core.shapes import ShapeID + +from ._private.schemas import ( + CREATE_ITEM as _SCHEMA_CREATE_ITEM, +) +from ._private.schemas import ( + CREATE_ITEM_INPUT as _SCHEMA_CREATE_ITEM_INPUT, +) +from ._private.schemas import ( + CREATE_ITEM_OUTPUT as _SCHEMA_CREATE_ITEM_OUTPUT, +) +from ._private.schemas import ( + GET_ITEM as _SCHEMA_GET_ITEM, +) +from ._private.schemas import ( + GET_ITEM_INPUT as _SCHEMA_GET_ITEM_INPUT, +) +from ._private.schemas import ( + GET_ITEM_OUTPUT as _SCHEMA_GET_ITEM_OUTPUT, +) +from ._private.schemas import ( + INTERNAL_ERROR as _SCHEMA_INTERNAL_ERROR, +) +from ._private.schemas import ( + ITEM_NOT_FOUND as _SCHEMA_ITEM_NOT_FOUND, +) +from ._private.schemas import ( + SERVICE_UNAVAILABLE_ERROR as _SCHEMA_SERVICE_UNAVAILABLE_ERROR, +) +from ._private.schemas import ( + THROTTLING_ERROR as _SCHEMA_THROTTLING_ERROR, +) + +logger = logging.getLogger(__name__) + + +class ServiceError(ModeledError): + """Base error for all errors in the service. + + Some exceptions do not extend from this class, including + synthetic, implicit, and shared exception types. + """ + + +@dataclass(kw_only=True) +class CreateItemInput: + name: str | None = None + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_CREATE_ITEM_INPUT, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.name is not None: + serializer.write_string( + _SCHEMA_CREATE_ITEM_INPUT.members["name"], self.name + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["name"] = de.read_string( + _SCHEMA_CREATE_ITEM_INPUT.members["name"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_CREATE_ITEM_INPUT, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class CreateItemOutput: + id: str | None = None + + name: str | None = None + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_CREATE_ITEM_OUTPUT, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.id is not None: + serializer.write_string(_SCHEMA_CREATE_ITEM_OUTPUT.members["id"], self.id) + + if self.name is not None: + serializer.write_string( + _SCHEMA_CREATE_ITEM_OUTPUT.members["name"], self.name + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["id"] = de.read_string( + _SCHEMA_CREATE_ITEM_OUTPUT.members["id"] + ) + + case 1: + kwargs["name"] = de.read_string( + _SCHEMA_CREATE_ITEM_OUTPUT.members["name"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_CREATE_ITEM_OUTPUT, consumer=_consumer) + return kwargs + + +CREATE_ITEM = APIOperation( + input=CreateItemInput, + output=CreateItemOutput, + schema=_SCHEMA_CREATE_ITEM, + input_schema=_SCHEMA_CREATE_ITEM_INPUT, + output_schema=_SCHEMA_CREATE_ITEM_OUTPUT, + error_registry=TypeRegistry({}), + effective_auth_schemes=[ShapeID("smithy.api#httpBasicAuth")], +) + + +@dataclass(kw_only=True) +class GetItemInput: + id: str | None = None + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_GET_ITEM_INPUT, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.id is not None: + serializer.write_string(_SCHEMA_GET_ITEM_INPUT.members["id"], self.id) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["id"] = de.read_string(_SCHEMA_GET_ITEM_INPUT.members["id"]) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_GET_ITEM_INPUT, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class GetItemOutput: + message: str | None = None + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_GET_ITEM_OUTPUT, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_GET_ITEM_OUTPUT.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_GET_ITEM_OUTPUT.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_GET_ITEM_OUTPUT, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class InternalError(ServiceError): + fault: Literal["client", "server"] | None = "server" + is_retry_safe: bool | None = True + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_INTERNAL_ERROR, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_INTERNAL_ERROR.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_INTERNAL_ERROR.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_INTERNAL_ERROR, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class ItemNotFound(ServiceError): + fault: Literal["client", "server"] | None = "client" + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_ITEM_NOT_FOUND, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_ITEM_NOT_FOUND.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_ITEM_NOT_FOUND.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_ITEM_NOT_FOUND, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class ServiceUnavailableError(ServiceError): + fault: Literal["client", "server"] | None = "server" + is_retry_safe: bool | None = True + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_SERVICE_UNAVAILABLE_ERROR, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_SERVICE_UNAVAILABLE_ERROR.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_SERVICE_UNAVAILABLE_ERROR.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_SERVICE_UNAVAILABLE_ERROR, consumer=_consumer) + return kwargs + + +@dataclass(kw_only=True) +class ThrottlingError(ServiceError): + fault: Literal["client", "server"] | None = "client" + is_retry_safe: bool | None = True + is_throttling_error: bool = True + + def serialize(self, serializer: ShapeSerializer): + serializer.write_struct(_SCHEMA_THROTTLING_ERROR, self) + + def serialize_members(self, serializer: ShapeSerializer): + if self.message is not None: + serializer.write_string( + _SCHEMA_THROTTLING_ERROR.members["message"], self.message + ) + + @classmethod + def deserialize(cls, deserializer: ShapeDeserializer) -> Self: + return cls(**cls.deserialize_kwargs(deserializer)) + + @classmethod + def deserialize_kwargs(cls, deserializer: ShapeDeserializer) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + + def _consumer(schema: Schema, de: ShapeDeserializer) -> None: + match schema.expect_member_index(): + case 0: + kwargs["message"] = de.read_string( + _SCHEMA_THROTTLING_ERROR.members["message"] + ) + + case _: + logger.debug("Unexpected member schema: %s", schema) + + deserializer.read_struct(_SCHEMA_THROTTLING_ERROR, consumer=_consumer) + return kwargs + + +GET_ITEM = APIOperation( + input=GetItemInput, + output=GetItemOutput, + schema=_SCHEMA_GET_ITEM, + input_schema=_SCHEMA_GET_ITEM_INPUT, + output_schema=_SCHEMA_GET_ITEM_OUTPUT, + error_registry=TypeRegistry( + { + ShapeID("smithy.python.test#ItemNotFound"): ItemNotFound, + ShapeID("smithy.python.test#ThrottlingError"): ThrottlingError, + ShapeID("smithy.python.test#InternalError"): InternalError, + ShapeID( + "smithy.python.test#ServiceUnavailableError" + ): ServiceUnavailableError, + } + ), + effective_auth_schemes=[ShapeID("smithy.api#httpBasicAuth")], +) diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/model/main.smithy b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/model/main.smithy new file mode 100644 index 00000000..7115ef1a --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/model/main.smithy @@ -0,0 +1,75 @@ +$version: "2" + +namespace smithy.python.test + +use aws.protocols#restJson1 +use smithy.api#httpBasicAuth +use smithy.api#retryable + +@restJson1 +@httpBasicAuth +service SmithyKitchenSink { + version: "2025-10-31" + operations: [ + CreateItem + GetItem + ] +} + +@http(code: 201, method: "POST", uri: "/items") +operation CreateItem { + input := { + @required + name: String + } + output := { + id: String + name: String + } +} + +@http(code: 200, method: "GET", uri: "/items/{id}") +@readonly +operation GetItem { + input := { + @required + @httpLabel + id: String + } + output := { + message: String + } + errors: [ + ItemNotFound + ThrottlingError + InternalError + ServiceUnavailableError + ] +} + +@error("client") +@httpError(400) +structure ItemNotFound { + message: String +} + +@error("client") +@retryable(throttling: true) +@httpError(429) +structure ThrottlingError { + message: String +} + +@error("server") +@retryable +@httpError(500) +structure InternalError { + message: String +} + +@error("server") +@retryable +@httpError(503) +structure ServiceUnavailableError { + message: String +} diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/smithy-build.json b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/smithy-build.json new file mode 100644 index 00000000..61b48835 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/smithy-build.json @@ -0,0 +1,22 @@ +{ + "version": "1.0", + "sources": ["model"], + "maven": { + "dependencies": [ + "software.amazon.smithy:smithy-model:[1.54.0,2.0)", + "software.amazon.smithy:smithy-aws-traits:[1.54.0,2.0)", + "software.amazon.smithy.python.codegen:core:0.0.1" + ] + }, + "projections": { + "client": { + "plugins": { + "python-client-codegen": { + "service": "smithy.python.test#SmithyKitchenSink", + "module": "smithy_kitchen_sink", + "moduleVersion": "0.0.1" + } + } + } + } +} \ No newline at end of file diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/tests/conftest.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/tests/conftest.py new file mode 100644 index 00000000..461c396c --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/tests/conftest.py @@ -0,0 +1,22 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from smithy_http.testing import MockHTTPClient + +from ..codegen.smithy_kitchen_sink.client import SmithyKitchenSink +from ..codegen.smithy_kitchen_sink.config import Config + + +@pytest.fixture +def http_client() -> MockHTTPClient: + return MockHTTPClient() + + +@pytest.fixture +def client(http_client: MockHTTPClient) -> SmithyKitchenSink: + config = Config( + transport=http_client, + endpoint_uri="https://test.smithy.dev", + ) + return SmithyKitchenSink(config=config) diff --git a/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/tests/test_simple_retry.py b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/tests/test_simple_retry.py new file mode 100644 index 00000000..bb09dddd --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/clients/smithy-kitchen-sink/tests/test_simple_retry.py @@ -0,0 +1,75 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest +from smithy_http.testing import MockHTTPClient + +from ..codegen.smithy_kitchen_sink.client import SmithyKitchenSink +from ..codegen.smithy_kitchen_sink.models import GetItemInput, ServiceError + + +def add_error_responses(http_client: MockHTTPClient, count: int) -> None: + for _ in range(count): + http_client.add_response( + status=500, + headers=[("X-Amzn-Errortype", "InternalError")], + body=b'{"message": "Server Error"}', + ) + + +async def test_retry_eventually_succeeds( + http_client: MockHTTPClient, + client: SmithyKitchenSink, +): + add_error_responses(http_client, 2) + http_client.add_response(status=200, body=b'{"message": "success"}') + + response = await client.get_item(GetItemInput(id="test-123")) + + assert http_client.call_count == 3 + assert response.message == "success" + + +async def test_max_attempts_exceeded( + http_client: MockHTTPClient, + client: SmithyKitchenSink, +): + add_error_responses(http_client, 5) + + with pytest.raises(ServiceError): + await client.get_item(GetItemInput(id="test-123")) + + assert http_client.call_count == 5 + + +async def test_throttling_error_retry( + http_client: MockHTTPClient, + client: SmithyKitchenSink, +): + http_client.add_response( + status=429, + headers=[("X-Amzn-Errortype", "ThrottlingError")], + body=b'{"message": "Rate exceeded"}', + ) + http_client.add_response(200, body=b'{"message": "success"}') + + response = await client.get_item(GetItemInput(id="test-123")) + + assert http_client.call_count == 2 + assert response.message == "success" + + +async def test_non_retryable_error( + http_client: MockHTTPClient, + client: SmithyKitchenSink, +): + http_client.add_response( + status=400, + headers=[("X-Amzn-Errortype", "ItemNotFound")], + body=b'{"message": "Item not found"}', + ) + + with pytest.raises(ServiceError): + await client.get_item(GetItemInput(id="nonexistent")) + + assert http_client.call_count == 1 diff --git a/packages/smithy-testing/src/smithy_testing/internal/generate_clients.py b/packages/smithy-testing/src/smithy_testing/internal/generate_clients.py new file mode 100644 index 00000000..1692cf68 --- /dev/null +++ b/packages/smithy-testing/src/smithy_testing/internal/generate_clients.py @@ -0,0 +1,78 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Internal script for generating test clients. + +This module is used internally by the smithy-testing package to generate +test clients from Smithy models for functional testing. It is not part of +the public API and should not be imported or relied upon externally. +""" + +import shutil +import subprocess +from pathlib import Path + +# ruff: noqa: T201 +# ruff: noqa: S603 +# ruff: noqa: S607 + + +def main() -> None: + script_dir = Path(__file__).parent + package_dir = script_dir.parent + clients_dir = package_dir / "clients" + + print("Building clients...") + added_clients: list[str] = [] + + for client_dir in clients_dir.iterdir(): + if not client_dir.is_dir(): + continue + + smithy_build_file = client_dir / "smithy-build.json" + if not smithy_build_file.exists(): + print(f" ⚠️ Skipping {client_dir.name} - no smithy-build.json") + continue + + print(f" 🛠️ Building {client_dir.name}") + + # Run smithy build in client directory + result = subprocess.run( + ["smithy", "build"], cwd=client_dir, capture_output=True, text=True + ) + + if result.returncode != 0: + print(f"Build failed for {client_dir.name}:") + print(result.stderr) + continue + + # Copy generated code to codegen directory + build_dir = client_dir / "build" + if build_dir.exists(): + # Find generated source directories + for projection_dir in build_dir.rglob("python-client-codegen/src"): + for module_dir in projection_dir.iterdir(): + if module_dir.is_dir(): + dst_path = client_dir / "codegen" / module_dir.name + + dst_path.parent.mkdir(parents=True, exist_ok=True) + if dst_path.exists(): + shutil.rmtree(dst_path) + shutil.copytree(module_dir, dst_path) + added_clients.append(module_dir.name) + + # Remove build directory + shutil.rmtree(build_dir) + + if added_clients: + print("Generated clients:") + for client in added_clients: + print(f" ✅ {client}") + else: + print("⚠️ No clients generated") + + print("Done!") + + +if __name__ == "__main__": + main() diff --git a/packages/smithy-testing/src/smithy_testing/py.typed b/packages/smithy-testing/src/smithy_testing/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index d7a23d99..9f1ecb21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,10 +38,12 @@ smithy_json = { workspace = true } smithy_aws_core = { workspace = true } smithy_aws_event_stream = { workspace = true } aws_sdk_signers = {workspace = true } +smithy_testing = { workspace = true } [tool.pyright] typeCheckingMode = "strict" enableExperimentalFeatures = true +exclude = ["packages/smithy-testing/src/smithy_testing/clients/*/codegen/**"] [tool.pytest.ini_options] asyncio_mode = "auto" # makes pytest run async tests without having to be marked with the @pytest.mark.asyncio decorator @@ -57,7 +59,10 @@ target-version = "py312" # probably not, a lot of work: DOC, D, PL, TRY select = [ "ASYNC", "C4", "E1", "E4", "E7", "E9", "F", "FURB", "G", "I", "LOG", "PIE", "RUF", "S", "T", "UP" ] -exclude = [ "packages/smithy-core/src/smithy_core/rfc3986.py" ] +exclude = [ + "packages/smithy-core/src/smithy_core/rfc3986.py", + "packages/smithy-testing/src/smithy_testing/clients/*/codegen/*" +] [tool.ruff.lint.isort] classes = ["URI"] diff --git a/uv.lock b/uv.lock index 3bf8fb83..fba17cc6 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ members = [ "smithy-http", "smithy-json", "smithy-python", + "smithy-testing", ] [[package]] @@ -730,6 +731,22 @@ test = [ ] typing = [{ name = "pyright", specifier = ">=1.1.400" }] +[[package]] +name = "smithy-testing" +source = { editable = "packages/smithy-testing" } +dependencies = [ + { name = "smithy-aws-core" }, + { name = "smithy-core" }, + { name = "smithy-http" }, +] + +[package.metadata] +requires-dist = [ + { name = "smithy-aws-core", editable = "packages/smithy-aws-core" }, + { name = "smithy-core", editable = "packages/smithy-core" }, + { name = "smithy-http", editable = "packages/smithy-http" }, +] + [[package]] name = "typing-extensions" version = "4.13.2"