diff --git a/packages/smithy-aws-core/tests/functional/__init__.py b/packages/smithy-aws-core/tests/functional/__init__.py new file mode 100644 index 000000000..33cbe867a --- /dev/null +++ b/packages/smithy-aws-core/tests/functional/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-aws-core/tests/integration/test_user_agent_business_metrics.py b/packages/smithy-aws-core/tests/functional/test_user_agent_business_metrics.py similarity index 76% rename from packages/smithy-aws-core/tests/integration/test_user_agent_business_metrics.py rename to packages/smithy-aws-core/tests/functional/test_user_agent_business_metrics.py index b7f285bcc..b6b7446d3 100644 --- a/packages/smithy-aws-core/tests/integration/test_user_agent_business_metrics.py +++ b/packages/smithy-aws-core/tests/functional/test_user_agent_business_metrics.py @@ -2,9 +2,8 @@ # SPDX-License-Identifier: Apache-2.0 """End-to-end User Agent 2.1 business metrics through a generated AWS client. -Drives the generated awsJson1.0 client (``codegen-output/aws-json-1-0``) with a stub -transport that records each request's User-Agent header, and checks the ``m/`` -section the SDK sends for the features each call uses. +Drives the generated awsJson1.0 client (``codegen-output/aws-json-1-0``) and checks +the ``m/`` section of the User-Agent the SDK sends for the features each call uses. """ import os @@ -24,27 +23,24 @@ from smithy_aws_core.aio.identity.process import AsyncProcessCredentialsResolver from smithy_aws_core.identity.process import ProcessCredentialsResolver from smithy_http import HTTPResponse, tuples_to_fields +from smithy_test import HTTPMockController, mock +from smithy_test.aio import mock_async _JSON_CONTENT_TYPE = ("content-type", "application/x-amz-json-1.0") +# The mock replaces the credentials resolver, so tests of credential sources +# record requests with this transport instead. class _RecordingTransport: - """Returns canned responses and records the User-Agent of every request.""" + """Returns empty responses and records the User-Agent of every request.""" TIMEOUT_EXCEPTIONS = () - def __init__(self, *, throttle_first: bool = False) -> None: + def __init__(self) -> None: self.user_agents: list[str] = [] - self._throttle_first = throttle_first def send(self, request: Any, *, request_config: Any = None) -> HTTPResponse: self.user_agents.append(request.fields["User-Agent"].as_string()) - if self._throttle_first and len(self.user_agents) == 1: - return HTTPResponse( - status=429, - fields=tuples_to_fields([_JSON_CONTENT_TYPE]), - body=b'{"__type":"ThrottlingException"}', - ) return HTTPResponse( status=200, fields=tuples_to_fields([_JSON_CONTENT_TYPE]), @@ -64,8 +60,8 @@ def sync(request: pytest.FixtureRequest) -> bool: return request.param -def _transport(sync: bool, **kwargs: Any) -> _RecordingTransport: - return (_RecordingTransport if sync else _AsyncRecordingTransport)(**kwargs) +def _transport(sync: bool) -> _RecordingTransport: + return (_RecordingTransport if sync else _AsyncRecordingTransport)() def _process_resolver(sync: bool, script: Path) -> Any: @@ -134,52 +130,89 @@ async def _call( return transport.user_agents +async def _call_mocked( + sync: bool, + tmp_path: Path, + metrics: str, + *, + throttle_first: bool = False, + **config: Any, +) -> None: + missing = str(tmp_path / "missing") + config = { + "config_file_path": missing, + "credentials_file_path": missing, + "region": "us-west-2", + "aws_access_key_id": "AKID", + "aws_secret_access_key": "secret", + **config, + } + if sync: + client = JSONRPC10Client(config=JSONRPC10Config.resolve(**config)) + with _expect_metrics(mock(client), metrics, throttle_first): + client.empty_input_and_empty_output(EmptyInputAndEmptyOutputInput()) + return + async_client = AsyncJSONRPC10Client( + config=await AsyncJSONRPC10Config.resolve(**config) + ) + with _expect_metrics(await mock_async(async_client), metrics, throttle_first): + await async_client.empty_input_and_empty_output(EmptyInputAndEmptyOutputInput()) + + +def _expect_metrics( + controller: HTTPMockController, metrics: str, throttle_first: bool +) -> HTTPMockController: + expectation = controller.expect_request( + headers={"User-Agent": lambda ua: _business_metrics(ua) == [metrics]} + ) + if throttle_first: + expectation.times(2).respond_with( + status_code=429, + headers=dict([_JSON_CONTENT_TYPE]), + body='{"__type":"ThrottlingException"}', + ) + expectation.respond_with(headers=dict([_JSON_CONTENT_TYPE]), body="{}") + return controller + + async def test_reports_standard_retry_mode_by_default( sync: bool, tmp_path: Path ) -> None: - user_agents = await _call(sync, tmp_path, _transport(sync)) - assert _business_metrics(user_agents[0]) == ["m/E,e"] + await _call_mocked(sync, tmp_path, "m/E,e") async def test_reports_adaptive_retry_mode(sync: bool, tmp_path: Path) -> None: - user_agents = await _call(sync, tmp_path, _transport(sync), retry_mode="adaptive") - assert _business_metrics(user_agents[0]) == ["m/F,e"] + await _call_mocked(sync, tmp_path, "m/F,e", retry_mode="adaptive") async def test_reports_adaptive_retry_mode_from_environment( sync: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("AWS_RETRY_MODE", "adaptive") - user_agents = await _call(sync, tmp_path, _transport(sync)) - assert _business_metrics(user_agents[0]) == ["m/F,e"] + await _call_mocked(sync, tmp_path, "m/F,e") async def test_reports_endpoint_override(sync: bool, tmp_path: Path) -> None: - user_agents = await _call( - sync, tmp_path, _transport(sync), endpoint_uri="https://localhost:8443" - ) - assert _business_metrics(user_agents[0]) == ["m/E,N,e"] + await _call_mocked(sync, tmp_path, "m/E,N,e", endpoint_uri="https://localhost:8443") async def test_reports_endpoint_override_from_environment( sync: bool, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("AWS_ENDPOINT_URL_JSON_RPC_10", "https://localhost:8443") - user_agents = await _call(sync, tmp_path, _transport(sync)) - assert _business_metrics(user_agents[0]) == ["m/E,N,e"] + await _call_mocked(sync, tmp_path, "m/E,N,e") async def test_retried_call_sends_same_business_metrics_once( sync: bool, tmp_path: Path ) -> None: - user_agents = await _call( + await _call_mocked( sync, tmp_path, - _transport(sync, throttle_first=True), + "m/E,N,e", + throttle_first=True, endpoint_uri="https://localhost:8443", ) - assert len(user_agents) == 2 - assert [_business_metrics(ua) for ua in user_agents] == [["m/E,N,e"], ["m/E,N,e"]] async def test_reports_process_resolver_used_directly( diff --git a/packages/smithy-core/tests/functional/test_retries.py b/packages/smithy-core/tests/functional/test_retries.py index 979b3d014..aa131cd31 100644 --- a/packages/smithy-core/tests/functional/test_retries.py +++ b/packages/smithy-core/tests/functional/test_retries.py @@ -49,6 +49,8 @@ def retry_operation( if isinstance(response, Exception): error = response else: + # TODO: Clients don't retry 5xx responses: the protocols mark unmodeled + # errors retry-safe only for 408 and 429. This sets it so 5xx retries. error = CallError( fault="server" if response >= 500 else "client", message=f"HTTP {response}", diff --git a/packages/smithy-http/.changes/next-release/smithy-http-enhancement-76678ead0b6642e5800a99d5a245c8a8.json b/packages/smithy-http/.changes/next-release/smithy-http-enhancement-76678ead0b6642e5800a99d5a245c8a8.json new file mode 100644 index 000000000..7714525f8 --- /dev/null +++ b/packages/smithy-http/.changes/next-release/smithy-http-enhancement-76678ead0b6642e5800a99d5a245c8a8.json @@ -0,0 +1,4 @@ +{ + "type": "enhancement", + "description": "Allow MockHTTPClient and AsyncMockHTTPClient responses to have stream bodies." +} \ No newline at end of file diff --git a/packages/smithy-http/src/smithy_http/aio/testing.py b/packages/smithy-http/src/smithy_http/aio/testing.py index 7a1fe74bc..ae137d999 100644 --- a/packages/smithy-http/src/smithy_http/aio/testing.py +++ b/packages/smithy-http/src/smithy_http/aio/testing.py @@ -38,4 +38,6 @@ async def send( :raises MockHTTPClientError: If no responses are queued. """ status, fields, body = self._pop_response(request) - return HTTPResponse(status=status, fields=fields, body=list_async([body])) + if isinstance(body, bytes | bytearray): + body = list_async([bytes(body)]) + return HTTPResponse(status=status, fields=fields, body=body) diff --git a/packages/smithy-http/src/smithy_http/testing/mockhttp.py b/packages/smithy-http/src/smithy_http/testing/mockhttp.py index b9dac533c..841a211ae 100644 --- a/packages/smithy-http/src/smithy_http/testing/mockhttp.py +++ b/packages/smithy-http/src/smithy_http/testing/mockhttp.py @@ -6,6 +6,8 @@ from io import BytesIO from typing import Any, Self +from smithy_core.interfaces import AnyStreamingBlob + from smithy_http import HTTPResponse, tuples_to_fields from smithy_http.interfaces import ( Fields, @@ -37,7 +39,7 @@ def __init__( client. """ self._client_config = client_config - self._responses: deque[tuple[int, Fields, bytes]] = deque() + self._responses: deque[tuple[int, Fields, AnyStreamingBlob]] = deque() self._captured_requests: list[HTTPRequest] = [] def __deepcopy__(self, memo: Any) -> Self: @@ -57,17 +59,19 @@ def add_response( self, status: int = 200, headers: list[tuple[str, str]] | None = None, - body: bytes = b"", + body: AnyStreamingBlob = b"", ) -> None: """Queue a response for the next request. :param status: HTTP status code. :param headers: HTTP response headers as list of (name, value) tuples. - :param body: Response body as bytes. + :param body: Response body as bytes, or a stream the client can read. """ self._responses.append((status, tuples_to_fields(headers or []), body)) - def _pop_response(self, request: HTTPRequest) -> tuple[int, Fields, bytes]: + def _pop_response( + self, request: HTTPRequest + ) -> tuple[int, Fields, AnyStreamingBlob]: """Capture the request and return the next queued status, fields and body. :raises MockHTTPClientError: If no responses are queued. @@ -101,7 +105,9 @@ def send( :raises MockHTTPClientError: If no responses are queued. """ status, fields, body = self._pop_response(request) - return HTTPResponse(status=status, fields=fields, body=BytesIO(body)) + if isinstance(body, bytes | bytearray): + body = BytesIO(body) + return HTTPResponse(status=status, fields=fields, body=body) class MockHTTPClientError(Exception): diff --git a/packages/smithy-http/tests/functional/__init__.py b/packages/smithy-http/tests/functional/__init__.py new file mode 100644 index 000000000..33cbe867a --- /dev/null +++ b/packages/smithy-http/tests/functional/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-http/tests/functional/test_streaming_payloads.py b/packages/smithy-http/tests/functional/test_streaming_payloads.py new file mode 100644 index 000000000..5b97960f6 --- /dev/null +++ b/packages/smithy-http/tests/functional/test_streaming_payloads.py @@ -0,0 +1,73 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from io import BytesIO + +import pytest + +pytest.importorskip("restjson") + +from restjson.aio.client import AsyncRestJsonProtocolClient +from restjson.aio.config import AsyncRestJsonProtocolConfig +from restjson.client import RestJsonProtocolClient +from restjson.config import RestJsonProtocolConfig +from restjson.models import StreamingTraitsRequireLengthInput +from smithy_core.aio.types import AsyncBytesReader +from smithy_core.exceptions import SerializationError +from smithy_core.interfaces import StreamingBlob +from smithy_test import HTTPMockController, HTTPMockExpectation, mock +from smithy_test.aio import mock_async + +PAYLOAD = b'{"hello":"world"}' + + +def test_streaming_payloads_are_sent_the_same() -> None: + client = RestJsonProtocolClient(RestJsonProtocolConfig.resolve(region="us-west-2")) + with mock(client) as controller: + _expect_payload(controller).times(3) + + for blob in _blobs(): + client.streaming_traits_require_length( + StreamingTraitsRequireLengthInput(blob=blob) + ) + + +async def test_async_streaming_payloads_are_sent_the_same() -> None: + config = await AsyncRestJsonProtocolConfig.resolve(region="us-west-2") + async with AsyncRestJsonProtocolClient(config) as client: + with await mock_async(client) as controller: + _expect_payload(controller).times(3) + + for blob in _blobs(): + await client.streaming_traits_require_length( + StreamingTraitsRequireLengthInput(blob=blob) + ) + + +async def test_unsized_streams_are_rejected_before_sending() -> None: + config = await AsyncRestJsonProtocolConfig.resolve(region="us-west-2") + async with AsyncRestJsonProtocolClient(config) as client: + controller = await mock_async(client) + _expect_payload(controller) + + with pytest.raises(SerializationError, match="content length"): + await client.streaming_traits_require_length( + StreamingTraitsRequireLengthInput(blob=AsyncBytesReader(PAYLOAD)) + ) + + assert controller.requests == [] + + +def _blobs() -> list[StreamingBlob]: + return [PAYLOAD, bytearray(PAYLOAD), BytesIO(PAYLOAD)] + + +def _expect_payload(controller: HTTPMockController) -> HTTPMockExpectation: + return controller.expect_request( + method="POST", + path="/StreamingTraitsRequireLength", + headers={ + "Content-Type": "application/octet-stream", + "Content-Length": str(len(PAYLOAD)), + }, + body=PAYLOAD.decode(), + ) diff --git a/packages/smithy-http/tests/unit/aio/testing/test_mockhttp.py b/packages/smithy-http/tests/unit/aio/testing/test_mockhttp.py index da102fb32..c86831c2d 100644 --- a/packages/smithy-http/tests/unit/aio/testing/test_mockhttp.py +++ b/packages/smithy-http/tests/unit/aio/testing/test_mockhttp.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import pytest +from smithy_core.aio.types import AsyncBytesReader from smithy_core.aio.utils import read_streaming_blob_async from smithy_http.aio.testing import AsyncMockHTTPClient from smithy_http.testing import MockHTTPClientError, create_test_request @@ -121,3 +122,14 @@ async def test_captured_requests_copy(): assert captured1 is not captured2 # But with same content assert len(captured1) == len(captured2) == 1 + + +async def test_responds_with_a_stream(): + client = AsyncMockHTTPClient() + stream = AsyncBytesReader(b"streamed") + client.add_response(body=stream) + + response = await client.send(create_test_request()) + + assert response.body is stream + assert await read_streaming_blob_async(response.body) == b"streamed" diff --git a/packages/smithy-http/tests/unit/testing/test_mockhttp.py b/packages/smithy-http/tests/unit/testing/test_mockhttp.py index 7b11b62c5..0823e661d 100644 --- a/packages/smithy-http/tests/unit/testing/test_mockhttp.py +++ b/packages/smithy-http/tests/unit/testing/test_mockhttp.py @@ -1,6 +1,8 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +from io import BytesIO + import pytest from smithy_core.utils import read_streaming_blob from smithy_http.testing import MockHTTPClient, MockHTTPClientError, create_test_request @@ -27,3 +29,14 @@ def test_queued_responses_fifo(): def test_raises_without_queued_response(): with pytest.raises(MockHTTPClientError, match="No responses queued"): MockHTTPClient().send(create_test_request()) + + +def test_responds_with_a_stream(): + client = MockHTTPClient() + stream = BytesIO(b"streamed") + client.add_response(body=stream) + + response = client.send(create_test_request()) + + assert response.body is stream + assert read_streaming_blob(response.body) == b"streamed" diff --git a/packages/smithy-test/.changes/next-release/smithy-test-feature-374bd727a36340568563fe5d5fd5eb36.json b/packages/smithy-test/.changes/next-release/smithy-test-feature-374bd727a36340568563fe5d5fd5eb36.json new file mode 100644 index 000000000..8632cd403 --- /dev/null +++ b/packages/smithy-test/.changes/next-release/smithy-test-feature-374bd727a36340568563fe5d5fd5eb36.json @@ -0,0 +1,4 @@ +{ + "type": "feature", + "description": "Add HTTP mocking API" +} \ No newline at end of file diff --git a/packages/smithy-test/pyproject.toml b/packages/smithy-test/pyproject.toml index befd1301a..7005d3c44 100644 --- a/packages/smithy-test/pyproject.toml +++ b/packages/smithy-test/pyproject.toml @@ -29,6 +29,8 @@ classifiers = [ ] dependencies = [ "smithy-core~=0.10.0", + "smithy-http~=0.7.0", + "smithy-aws-core~=0.13.0", ] [project.urls] diff --git a/packages/smithy-test/src/smithy_test/__init__.py b/packages/smithy-test/src/smithy_test/__init__.py index 4663167d0..fcee2d325 100644 --- a/packages/smithy-test/src/smithy_test/__init__.py +++ b/packages/smithy-test/src/smithy_test/__init__.py @@ -5,12 +5,22 @@ from ._private.conformance import verify_models as verify_models from ._private.equality import deep_equal as deep_equal from ._private.equality import xml_equal as xml_equal +from .http_mock import HTTPMockController as HTTPMockController +from .http_mock import HTTPMockError as HTTPMockError +from .http_mock import HTTPMockExpectation as HTTPMockExpectation +from .http_mock import HTTPMockMismatchError as HTTPMockMismatchError +from .http_mock import mock as mock __version__ = "0.2.0" __all__ = ( "ConformanceError", + "HTTPMockController", + "HTTPMockError", + "HTTPMockExpectation", + "HTTPMockMismatchError", "check_rules_case", "deep_equal", + "mock", "verify_models", "xml_equal", ) diff --git a/packages/smithy-test/src/smithy_test/_private/http_mock.py b/packages/smithy-test/src/smithy_test/_private/http_mock.py new file mode 100644 index 000000000..c5a77fabf --- /dev/null +++ b/packages/smithy-test/src/smithy_test/_private/http_mock.py @@ -0,0 +1,211 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# pyright: reportPrivateUsage=false +import inspect +import re +from collections.abc import Callable, Iterable, Iterator, Mapping +from copy import deepcopy +from typing import Any, Protocol, cast +from urllib.parse import parse_qsl + +from smithy_core._client import ClientBase +from smithy_core.exceptions import SmithyError +from smithy_http.interfaces import HTTPRequest + +MOCK_CREDENTIAL = "HTTP_MOCK" + + +class MockTransport(Protocol): + """A mock transport, holding the config transports it replaced.""" + + displaced: list[Any] + + +class NoResponse(SmithyError): + """Ends a call whose matching expectation has no response.""" + + +def take_over( + client: ClientBase[Any], + transport: MockTransport, + wrap_invoke: Callable[[Callable[..., Any]], Callable[..., Any]], +) -> None: + """Route the client's calls to the transport and replace its credentials. + + :raises TypeError: If the client's internals aren't the ones this relies on. + """ + _check_client(client) + # Typed as Any so instance attributes can shadow the client's methods. + target: Any = client + if target._setup_done: + _scrub(target._config, transport) + target._transport = transport + elif target._config is not None: + # Copied so the caller's config, possibly shared, keeps its own values. + target._config = deepcopy(target._config) + _scrub(target._config, transport) + + post_setup = target._post_setup + build_call = target._build_call + + # Client and operation plugins may set credentials or a transport after this. + def _post_setup(config: Any) -> Any: + _scrub(config, transport) + return post_setup(config) + + def _build_call( + input: Any, operation: Any, *, config: Any, retry_strategy: Any + ) -> Any: + _scrub(config, transport) + return build_call( + input, operation, config=config, retry_strategy=retry_strategy + ) + + target._post_setup = _post_setup + target._build_call = _build_call + target._invoke = wrap_invoke(target._invoke) + + +def mismatches( + matchers: dict[str, Any], request: HTTPRequest, body: bytes +) -> tuple[list[str], Exception | None]: + """Describe each matcher the request fails, with the first error one raised.""" + failures: list[str] = [] + cause: Exception | None = None + for label, matcher, subject in _subjects(matchers, request, body): + if subject is _MISSING: + failures.append(f"{label}: missing") + continue + try: + if _matches(matcher, subject): + continue + failures.append(f"{label}: expected {matcher!r}, got {subject!r}") + except Exception as e: + failures.append(f"{label}: matcher raised {e!r}") + cause = cause or e + return failures, cause + + +_MISSING = object() + +_CLIENT_ATTRIBUTES = ("_setup_done", "_config", "_transport") +_CLIENT_HOOKS = { + "_post_setup": ("config",), + "_build_call": ("input", "operation", "config", "retry_strategy"), + "_invoke": ("input", "operation", "default_plugins", "plugins"), +} +_CREDENTIAL_FIELDS = ("aws_access_key_id", "aws_secret_access_key", "aws_session_token") + + +def _check_client(client: ClientBase[Any]) -> None: + name = type(client).__name__ + missing = [a for a in _CLIENT_ATTRIBUTES if not hasattr(client, a)] + if missing: + raise TypeError(f"{name} has no {', '.join(missing)}; it can't be mocked.") + for hook, parameters in _CLIENT_HOOKS.items(): + found = tuple(inspect.signature(getattr(client, hook)).parameters) + if found != parameters: + raise TypeError( + f"{name}.{hook} takes {found}, but the mock expects {parameters}." + ) + + +def _check_config(config: Any) -> None: + name = type(config).__name__ + if not hasattr(config, "transport"): + raise TypeError(f"{name} has no transport; it can't be mocked.") + aws = ("aws_credentials_identity_resolver", *_CREDENTIAL_FIELDS) + present = [f for f in aws if hasattr(config, f)] + if not present: + return + if len(present) < len(aws) or not callable( + getattr(config, "_static_credentials_resolver", None) + ): + raise TypeError( + f"{name} has the AWS credential fields {present}, but the mock needs all " + f"of {list(aws)} and _static_credentials_resolver to replace them." + ) + + +def _scrub(config: Any, transport: MockTransport) -> None: + _check_config(config) + source_of = getattr(config, "source_of", None) + # A default transport belongs to the config, so the mock closes it in its place. + if ( + config.transport is not transport + and source_of is not None + and source_of("transport") == "default" + ): + transport.displaced.append(config.transport) + config.transport = transport + if hasattr(config, "aws_credentials_identity_resolver"): + # Imported here so clients without AWS auth don't need smithy-aws-core. + from smithy_aws_core.identity.components import AWSCredentialsIdentity + + # CAUTION: bypasses AwsConfig.__setattr__, which locks resolved credentials. + for name in _CREDENTIAL_FIELDS: + object.__setattr__(config, name, MOCK_CREDENTIAL) + config.aws_credentials_identity_resolver = config._static_credentials_resolver( + AWSCredentialsIdentity( + access_key_id=MOCK_CREDENTIAL, + secret_access_key=MOCK_CREDENTIAL, + session_token=MOCK_CREDENTIAL, + ) + ) + + +def _subjects( + matchers: dict[str, Any], request: HTTPRequest, body: bytes +) -> Iterator[tuple[str, Any, Any]]: + uri = request.destination + scalars: dict[str, Any] = { + "method": request.method, + "scheme": uri.scheme, + "hostname": uri.host, + "port": uri.port, + "path": uri.path or "", + "body": body, + } + entries = { + "query": _join(parse_qsl(uri.query or "", keep_blank_values=True)), + "headers": _join((f.name.lower(), v) for f in request.fields for v in f.values), + } + for name, matcher in matchers.items(): + if name in scalars: + yield name, matcher, scalars[name] + elif not isinstance(matcher, Mapping): + yield name, matcher, entries[name] + else: + for key, entry_matcher in cast(Mapping[str, Any], matcher).items(): + # Header names are case-insensitive; query keys aren't. + lookup = key.lower() if name == "headers" else key + yield ( + f"{name}[{key!r}]", + entry_matcher, + entries[name].get(lookup, _MISSING), + ) + + +def _matches(matcher: Any, subject: Any) -> bool: + if isinstance(matcher, str): + text = _text(subject) + if len(matcher) >= 2 and matcher.startswith("/") and matcher.endswith("/"): + return re.search(matcher[1:-1], text) is not None + return text == matcher + result = matcher(subject) + return result is True or result is None + + +def _join(pairs: Iterable[tuple[str, str]]) -> dict[str, str]: + joined: dict[str, list[str]] = {} + for key, value in pairs: + joined.setdefault(key, []).append(value) + return {key: ",".join(values) for key, values in joined.items()} + + +def _text(subject: Any) -> str: + if subject is None: + return "" + if isinstance(subject, bytes): + return subject.decode("utf-8", errors="replace") + return str(subject) diff --git a/packages/smithy-test/src/smithy_test/aio/__init__.py b/packages/smithy-test/src/smithy_test/aio/__init__.py index 1deba7021..138ce6921 100644 --- a/packages/smithy-test/src/smithy_test/aio/__init__.py +++ b/packages/smithy-test/src/smithy_test/aio/__init__.py @@ -1,5 +1,6 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 from ._endpoints import check_client_case_async as check_client_case_async +from .http_mock import mock_async as mock_async -__all__ = ("check_client_case_async",) +__all__ = ("check_client_case_async", "mock_async") diff --git a/packages/smithy-test/src/smithy_test/aio/http_mock.py b/packages/smithy-test/src/smithy_test/aio/http_mock.py new file mode 100644 index 000000000..751571b65 --- /dev/null +++ b/packages/smithy-test/src/smithy_test/aio/http_mock.py @@ -0,0 +1,79 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# pyright: reportPrivateUsage=false +from collections.abc import Callable +from copy import copy +from typing import Any + +from smithy_core.aio.client import AsyncClient +from smithy_core.aio.utils import read_streaming_blob_async +from smithy_http import HTTPResponse +from smithy_http.aio.interfaces import AsyncHTTPClient +from smithy_http.aio.testing import AsyncMockHTTPClient +from smithy_http.interfaces import HTTPRequest, HTTPRequestConfiguration + +from .._private.http_mock import NoResponse, take_over +from ..http_mock import HTTPMockController + +__all__ = ["mock_async"] + + +async def mock_async[C](client: AsyncClient[C]) -> HTTPMockController: + """Take over a client so its requests are matched and answered, never sent. + + :raises TypeError: If the client isn't an async client this mock can take over. + """ + if not isinstance(client, AsyncClient): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"mock_async() takes an async client, got {type(client).__name__}. Use " + "smithy_test.mock() for sync clients." + ) + controller = HTTPMockController() + controller._capture = _AsyncMockTransport(controller) + take_over(client, controller._capture, _none_without_response) + return controller + + +class _AsyncMockTransport(AsyncMockHTTPClient): + """Queues the controller's response for each request, then answers with it.""" + + def __init__(self, controller: HTTPMockController) -> None: + super().__init__() + self._controller = controller + self.displaced: list[AsyncHTTPClient] = [] + + async def send( + self, + request: HTTPRequest, + *, + request_config: HTTPRequestConfiguration | None = None, + ) -> HTTPResponse: + captured = copy(request) + captured.body = await read_streaming_blob_async(request.body) + try: + status, headers, body = self._controller._exchange( + captured, captured.body, self.call_count + 1 + ) + except Exception: + # Unanswered requests are captured too, since send() only captures + # requests it answers. + self._captured_requests.append(captured) + raise + self.add_response(status, headers, body) + return await super().send(captured, request_config=request_config) + + async def close(self) -> None: + for transport in self.displaced: + # AsyncHTTPClient doesn't declare close; the client closes it the same way. + if (close := getattr(transport, "close", None)) is not None: + await close() + + +def _none_without_response(invoke: Callable[..., Any]) -> Callable[..., Any]: + async def _invoke(*args: Any, **kwargs: Any) -> Any: + try: + return await invoke(*args, **kwargs) + except NoResponse: + return None + + return _invoke diff --git a/packages/smithy-test/src/smithy_test/http_mock.py b/packages/smithy-test/src/smithy_test/http_mock.py new file mode 100644 index 000000000..fe0d54a69 --- /dev/null +++ b/packages/smithy-test/src/smithy_test/http_mock.py @@ -0,0 +1,235 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +# pyright: reportPrivateUsage=false +from collections.abc import Callable, Mapping +from copy import copy +from types import TracebackType +from typing import Any, Self + +from smithy_core.client import Client +from smithy_core.exceptions import SmithyError +from smithy_core.interfaces import AnyStreamingBlob +from smithy_core.utils import read_streaming_blob +from smithy_http import HTTPResponse +from smithy_http.interfaces import HTTPClient, HTTPRequest, HTTPRequestConfiguration +from smithy_http.testing.mockhttp import MockHTTPClient, MockHTTPClientBase + +from ._private.http_mock import NoResponse, mismatches, take_over + +__all__ = [ + "HTTPMockController", + "HTTPMockError", + "HTTPMockExpectation", + "HTTPMockMismatchError", + "mock", +] + +type _Matcher[T] = str | Callable[[T], bool | None] +"""An exact string, a ``"/regex/"`` string, or a callable returning True or None.""" + +type _EntriesMatcher = ( + Mapping[str, _Matcher[str]] | Callable[[dict[str, str]], bool | None] +) + +type _ResponseBody = str | AnyStreamingBlob + + +def mock[C](client: Client[C]) -> "HTTPMockController": + """Take over a client so its requests are matched and answered, never sent. + + :raises TypeError: If the client isn't a sync client this mock can take over. + """ + if not isinstance(client, Client): # pyright: ignore[reportUnnecessaryIsInstance] + raise TypeError( + f"mock() takes a sync client, got {type(client).__name__}. Use " + "smithy_test.aio.mock_async() for async clients." + ) + controller = HTTPMockController() + controller._capture = _MockTransport(controller) + take_over(client, controller._capture, _none_without_response) + return controller + + +class HTTPMockController: + """Matches, answers and records the requests of a client taken over by a mock. + + Used as a context manager, it raises on exit if an expectation wasn't met. + """ + + def __init__(self) -> None: + self._expectations: list[HTTPMockExpectation] = [] + self._capture: MockHTTPClientBase | None = None + self._position = 0 + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + # An error raised in the block is the more useful one to report. + if exc_type is not None: + return + unmet = [ + f"Expectation {i}: matched {e._used} of {e._times} requests" + for i, e in enumerate(self._expectations, start=1) + if e._used < e._times + ] + if unmet: + raise HTTPMockMismatchError( + "\n ".join(["Not every expected request was sent:", *unmet]) + ) + + @property + def requests(self) -> list[HTTPRequest]: + """The requests the client sent, with their bodies read into bytes.""" + return self._capture.captured_requests if self._capture else [] + + def expect_request( + self, + *, + method: _Matcher[str] | None = None, + scheme: _Matcher[str] | None = None, + hostname: _Matcher[str] | None = None, + port: _Matcher[int | None] | None = None, + path: _Matcher[str] | None = None, + query: _EntriesMatcher | None = None, + headers: _EntriesMatcher | None = None, + body: _Matcher[bytes] | None = None, + ) -> "HTTPMockExpectation": + """Expect the next request; the last expectation matches all later ones.""" + components = { + "method": method, + "scheme": scheme, + "hostname": hostname, + "port": port, + "path": path, + "query": query, + "headers": headers, + "body": body, + } + expectation = HTTPMockExpectation( + {name: m for name, m in components.items() if m is not None} + ) + self._expectations.append(expectation) + return expectation + + def _exchange( + self, request: HTTPRequest, body: bytes, number: int + ) -> tuple[int, list[tuple[str, str]], AnyStreamingBlob]: + """Match a request, then return the status, headers and body to answer it. + + :raises HTTPMockError: If no requests are expected. + :raises HTTPMockMismatchError: If the request doesn't match its expectation. + :raises NoResponse: If the matching expectation has no response. + """ + expectations = self._expectations + if not expectations: + raise HTTPMockError( + f"Request {number} was sent, but no requests were expected. Call " + "expect_request() before invoking the client." + ) + while ( + self._position < len(expectations) - 1 + and expectations[self._position]._used + >= expectations[self._position]._times + ): + self._position += 1 + expectation = expectations[self._position] + failures, cause = mismatches(expectation._matchers, request, body) + if failures: + heading = f"Request {number} didn't match expectation {self._position + 1}:" + raise HTTPMockMismatchError("\n ".join([heading, *failures])) from cause + return expectation._respond() + + +class HTTPMockExpectation: + """An expected request and the responses to it.""" + + def __init__(self, matchers: dict[str, Any]) -> None: + self._matchers = matchers + self._times = 1 + self._used = 0 + self._responses: list[tuple[int, list[tuple[str, str]], _ResponseBody]] = [] + + def times(self, n: int) -> Self: + """Expect this request ``n`` times in a row.""" + self._times = n + return self + + def respond_with( + self, + status_code: int = 200, + headers: Mapping[str, str] | None = None, + body: _ResponseBody = b"", + ) -> Self: + """Queue a response; the last response answers all later requests.""" + self._responses.append((status_code, list((headers or {}).items()), body)) + return self + + def _respond(self) -> tuple[int, list[tuple[str, str]], AnyStreamingBlob]: + self._used += 1 + if not self._responses: + raise NoResponse() + index = min(self._used, len(self._responses)) - 1 + status, headers, body = self._responses[index] + if isinstance(body, str): + body = body.encode("utf-8") + return status, headers, body + + +class HTTPMockError(SmithyError): + """Raised when a mocked client sends a request without any expectations.""" + + +# A SmithyError so the pipeline raises it as is instead of wrapping it. +class HTTPMockMismatchError(SmithyError, AssertionError): + """Raised when a mocked client's requests don't meet its expectations.""" + + +class _MockTransport(MockHTTPClient): + """Queues the controller's response for each request, then answers with it.""" + + def __init__(self, controller: HTTPMockController) -> None: + super().__init__() + self._controller = controller + self.displaced: list[HTTPClient] = [] + + def send( + self, + request: HTTPRequest, + *, + request_config: HTTPRequestConfiguration | None = None, + ) -> HTTPResponse: + captured = copy(request) + captured.body = read_streaming_blob(request.body) + try: + status, headers, body = self._controller._exchange( + captured, captured.body, self.call_count + 1 + ) + except Exception: + # Unanswered requests are captured too, since send() only captures + # requests it answers. + self._captured_requests.append(captured) + raise + self.add_response(status, headers, body) + return super().send(captured, request_config=request_config) + + def close(self) -> None: + for transport in self.displaced: + # HTTPClient doesn't declare close; the client closes it the same way. + if (close := getattr(transport, "close", None)) is not None: + close() + + +def _none_without_response(invoke: Callable[..., Any]) -> Callable[..., Any]: + def _invoke(*args: Any, **kwargs: Any) -> Any: + try: + return invoke(*args, **kwargs) + except NoResponse: + return None + + return _invoke diff --git a/packages/smithy-test/tests/functional/__init__.py b/packages/smithy-test/tests/functional/__init__.py new file mode 100644 index 000000000..33cbe867a --- /dev/null +++ b/packages/smithy-test/tests/functional/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-test/tests/functional/aio/__init__.py b/packages/smithy-test/tests/functional/aio/__init__.py new file mode 100644 index 000000000..33cbe867a --- /dev/null +++ b/packages/smithy-test/tests/functional/aio/__init__.py @@ -0,0 +1,2 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 diff --git a/packages/smithy-test/tests/functional/aio/test_http_mock.py b/packages/smithy-test/tests/functional/aio/test_http_mock.py new file mode 100644 index 000000000..fd8009a96 --- /dev/null +++ b/packages/smithy-test/tests/functional/aio/test_http_mock.py @@ -0,0 +1,70 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +import pytest + +pytest.importorskip("restjson") + +from restjson.aio.client import AsyncRestJsonProtocolClient +from restjson.aio.config import AsyncRestJsonProtocolConfig +from restjson.client import RestJsonProtocolClient +from restjson.models import SimpleScalarPropertiesInput +from smithy_test import HTTPMockError, HTTPMockMismatchError +from smithy_test.aio import mock_async + + +async def _client() -> AsyncRestJsonProtocolClient: + return AsyncRestJsonProtocolClient( + await AsyncRestJsonProtocolConfig.resolve( + region="us-west-2", + aws_access_key_id="real", + aws_secret_access_key="real", + ) + ) + + +async def test_matches_and_responds() -> None: + async with await _client() as client: + controller = await mock_async(client) + controller.expect_request( + path="/SimpleScalarProperties", + headers={"Authorization": "/Credential=HTTP_MOCK//"}, + body=lambda body: b'"stringValue":"value"' in body, + ).respond_with(body=b'{"stringValue": "hi"}') + + output = await client.simple_scalar_properties( + SimpleScalarPropertiesInput(string_value="value") + ) + + assert output.string_value == "hi" + assert controller.requests[0].body == b'{"stringValue":"value"}' + + +async def test_returns_none_without_response() -> None: + async with await _client() as client: + (await mock_async(client)).expect_request() + + assert ( + await client.simple_scalar_properties(SimpleScalarPropertiesInput()) is None + ) + + +async def test_raises_without_expectations() -> None: + async with await _client() as client: + await mock_async(client) + + with pytest.raises(HTTPMockError): + await client.simple_scalar_properties(SimpleScalarPropertiesInput()) + + +async def test_raises_on_exit_with_unmet_expectations() -> None: + async with await _client() as client: + with pytest.raises(HTTPMockMismatchError, match="matched 1 of 2 requests"): + with await mock_async(client) as controller: + controller.expect_request().times(2) + + await client.simple_scalar_properties(SimpleScalarPropertiesInput()) + + +async def test_rejects_a_sync_client() -> None: + with pytest.raises(TypeError, match=r"smithy_test\.mock\(\)"): + await mock_async(RestJsonProtocolClient()) # type: ignore[arg-type] diff --git a/packages/smithy-test/tests/functional/test_http_mock.py b/packages/smithy-test/tests/functional/test_http_mock.py new file mode 100644 index 000000000..b5e6b7df0 --- /dev/null +++ b/packages/smithy-test/tests/functional/test_http_mock.py @@ -0,0 +1,264 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +from io import BytesIO +from types import SimpleNamespace +from typing import Any + +import pytest + +pytest.importorskip("restjson") + +from restjson.aio.client import AsyncRestJsonProtocolClient +from restjson.client import RestJsonProtocolClient +from restjson.config import RestJsonProtocolConfig +from restjson.models import ( + AllQueryStringTypesInput, + SimpleScalarPropertiesInput, + StreamingTraitsInput, +) +from smithy_core.utils import read_streaming_blob +from smithy_http.urllib3 import Urllib3HTTPClient +from smithy_test import HTTPMockError, HTTPMockMismatchError, mock + + +def _client() -> RestJsonProtocolClient: + return RestJsonProtocolClient(RestJsonProtocolConfig.resolve(region="us-west-2")) + + +def _call(client: RestJsonProtocolClient, foo: str = "bar") -> Any: + return client.simple_scalar_properties( + SimpleScalarPropertiesInput(foo=foo, string_value="value") + ) + + +def test_matches_every_component_and_responds() -> None: + client = _client() + controller = mock(client) + + def has_string_value(body: bytes) -> None: + assert b'"stringValue":"value"' in body + + controller.expect_request( + method="PUT", + scheme="https", + hostname="/^restjson\\.us-west-2\\./", + path="/SimpleScalarProperties", + headers={"X-Foo": "bar", "Content-Type": lambda v: v.startswith("app")}, + body=has_string_value, + ).respond_with(headers={"X-Foo": "response"}, body='{"stringValue": "hi"}') + + output = client.simple_scalar_properties( + SimpleScalarPropertiesInput(foo="bar", string_value="value") + ) + + assert output.foo == "response" + assert output.string_value == "hi" + assert len(controller.requests) == 1 + assert controller.requests[0].body == b'{"stringValue":"value"}' + + +def test_responds_with_a_stream() -> None: + client = _client() + mock(client).expect_request().respond_with(body=BytesIO(b"streamed")) + + output = client.streaming_traits(StreamingTraitsInput(blob=b"")) + + assert read_streaming_blob(output.blob) == b"streamed" + + +def test_matches_query_entries() -> None: + client = _client() + controller = mock(client) + controller.expect_request( + query={"String": "Hello there", "StringList": "a,b,c"} + ).respond_with() + + client.all_query_string_types( + AllQueryStringTypesInput( + query_string="Hello there", query_string_list=["a", "b", "c"] + ) + ) + + +def test_returns_none_without_response() -> None: + client = _client() + mock(client).expect_request(path="/SimpleScalarProperties") + + assert _call(client) is None + + +def test_responds_in_turn_then_repeats_last() -> None: + client = _client() + mock(client).expect_request().times(4).respond_with( + headers={"X-Foo": "1"} + ).respond_with(headers={"X-Foo": "2"}) + + assert [_call(client).foo for _ in range(4)] == ["1", "2", "2", "2"] + + +def test_matches_expectations_in_order_then_repeats_last() -> None: + client = _client() + controller = mock(client) + controller.expect_request(headers={"X-Foo": "a"}).times(2) + controller.expect_request(headers={"X-Foo": "b"}) + + for foo in ["a", "a", "b", "b", "b"]: + assert _call(client, foo) is None + with pytest.raises(HTTPMockMismatchError, match="Request 6 didn't match"): + _call(client, "a") + + +def test_raises_without_expectations() -> None: + client = _client() + mock(client) + + with pytest.raises(HTTPMockError, match="no requests were expected"): + _call(client) + + +def test_reports_every_mismatch() -> None: + client = _client() + mock(client).expect_request( + method="GET", + path="/^\\/Other/", + headers={"X-Missing": "value"}, + body=lambda body: False, + ) + + with pytest.raises(HTTPMockMismatchError) as error: + _call(client) + + message = str(error.value) + assert "method: expected 'GET', got 'PUT'" in message + assert "path: expected" in message + assert "headers['X-Missing']: missing" in message + assert "body: expected" in message + + +def test_reports_matcher_errors() -> None: + client = _client() + + def fails(body: bytes) -> None: + raise AssertionError("bad body") + + mock(client).expect_request(body=fails) + + with pytest.raises(HTTPMockMismatchError, match="bad body") as error: + _call(client) + assert isinstance(error.value.__cause__, AssertionError) + + +def test_replaces_credentials() -> None: + config = RestJsonProtocolConfig.resolve( + region="us-west-2", aws_access_key_id="real", aws_secret_access_key="real" + ) + client = RestJsonProtocolClient(config) + mock(client).expect_request( + headers={ + "Authorization": "/Credential=HTTP_MOCK//", + "X-Amz-Security-Token": "HTTP_MOCK", + } + ) + + _call(client) + + assert config.aws_access_key_id == "real" + assert client._config.aws_access_key_id == "HTTP_MOCK" # pyright: ignore[reportPrivateUsage] + + +def test_replaces_credentials_from_the_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_REGION", "us-west-2") + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "real") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "real") + client = RestJsonProtocolClient() + mock(client).expect_request(headers={"Authorization": "/Credential=HTTP_MOCK//"}) + + _call(client) + + +def test_takes_over_a_client_after_setup() -> None: + client = _client() + client._ensure_setup() # pyright: ignore[reportPrivateUsage] + mock(client).expect_request(headers={"Authorization": "/Credential=HTTP_MOCK//"}) + + assert _call(client) is None + + +def test_ignores_transports_from_operation_plugins() -> None: + client = _client() + mock(client).expect_request() + + def plugin(config: RestJsonProtocolConfig) -> None: + config.transport = Urllib3HTTPClient() + + assert ( + client.simple_scalar_properties(SimpleScalarPropertiesInput(), plugins=[plugin]) + is None + ) + + +def test_exits_when_every_expectation_is_met() -> None: + client = _client() + with mock(client) as controller: + controller.expect_request(headers={"X-Foo": "a"}).times(2) + controller.expect_request(headers={"X-Foo": "b"}).times(2) + + for foo in ["a", "a", "b", "b", "b"]: + _call(client, foo) + + +def test_raises_on_exit_with_unmet_expectations() -> None: + client = _client() + with pytest.raises(HTTPMockMismatchError) as error: + with mock(client) as controller: + controller.expect_request().times(2) + controller.expect_request().times(3) + controller.expect_request() + + for _ in range(3): + _call(client) + + message = str(error.value) + assert "Expectation 2: matched 1 of 3 requests" in message + assert "Expectation 3: matched 0 of 1 requests" in message + assert "Expectation 1" not in message + + +def test_reports_the_error_raised_in_the_block() -> None: + client = _client() + with pytest.raises(ValueError, match="in the block"): + with mock(client) as controller: + controller.expect_request() + raise ValueError("in the block") + + +def test_allows_unmet_expectations_without_a_context_manager() -> None: + mock(_client()).expect_request().times(2) + + +def test_rejects_an_async_client() -> None: + client = AsyncRestJsonProtocolClient() + + with pytest.raises(TypeError, match="mock_async"): + mock(client) # type: ignore[arg-type] + + +def test_rejects_a_client_with_changed_internals() -> None: + class Changed(RestJsonProtocolClient): + def _build_call(self, input: Any, operation: Any, **kwargs: Any) -> Any: + return super()._build_call(input, operation, **kwargs) + + with pytest.raises(TypeError, match="_build_call takes"): + mock(Changed(RestJsonProtocolConfig.resolve(region="us-west-2"))) + + +def test_rejects_a_config_with_partial_aws_credentials() -> None: + client = _client() + client._config = SimpleNamespace( # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue] + transport=None, aws_credentials_identity_resolver=None + ) + + with pytest.raises(TypeError, match="AWS credential fields"): + mock(client) diff --git a/pyproject.toml b/pyproject.toml index 23cdad337..9493fd284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,10 @@ enableExperimentalFeatures = true exclude = [ "**/__pycache__", "packages/smithy-aws-core/tests/integration", + # Import generated clients, which static-analysis runs before installing. + "packages/smithy-aws-core/tests/functional", + "packages/smithy-http/tests/functional", + "packages/smithy-test/tests/functional", ] [tool.pytest.ini_options] diff --git a/scripts/api-snapshot/api-rex.json b/scripts/api-snapshot/api-rex.json index 5a1fc2995..3a8efe8da 100644 --- a/scripts/api-snapshot/api-rex.json +++ b/scripts/api-snapshot/api-rex.json @@ -1279,6 +1279,7 @@ "utils": "rex-module" }, "smithy_http.testing.mockhttp": { + "AnyStreamingBlob": "rex-type_alias", "Fields": "rex-protocol", "HTTPClient": "rex-protocol", "HTTPClientConfiguration": "rex-class", @@ -1387,13 +1388,48 @@ }, "smithy_test": { "ConformanceError": "rex-exception", + "HTTPMockController": "rex-class", + "HTTPMockError": "rex-exception", + "HTTPMockExpectation": "rex-class", + "HTTPMockMismatchError": "rex-exception", "check_rules_case": "rex-function", "deep_equal": "rex-function", + "http_mock": "rex-module", + "mock": "rex-function", "verify_models": "rex-function", "xml_equal": "rex-function" }, "smithy_test.aio": { - "check_client_case_async": "rex-function[async]" + "check_client_case_async": "rex-function[async]", + "http_mock": "rex-module", + "mock_async": "rex-function[async]" + }, + "smithy_test.aio.http_mock": { + "AsyncClient": "rex-class[async]", + "AsyncHTTPClient": "rex-protocol[async]", + "AsyncMockHTTPClient": "rex-class[async]", + "HTTPMockController": "rex-class", + "HTTPRequest": "rex-protocol", + "HTTPRequestConfiguration": "rex-class", + "HTTPResponse": "rex-class", + "NoResponse": "rex-exception", + "read_streaming_blob_async": "rex-function[async]", + "take_over": "rex-function" + }, + "smithy_test.http_mock": { + "AnyStreamingBlob": "rex-type_alias", + "Client": "rex-class", + "HTTPClient": "rex-protocol", + "HTTPRequest": "rex-protocol", + "HTTPRequestConfiguration": "rex-class", + "HTTPResponse": "rex-class", + "MockHTTPClient": "rex-class", + "MockHTTPClientBase": "rex-class", + "NoResponse": "rex-exception", + "SmithyError": "rex-exception", + "mismatches": "rex-function", + "read_streaming_blob": "rex-function", + "take_over": "rex-function" }, "smithy_xml": { "BytesReader": "rex-protocol", diff --git a/scripts/api-snapshot/api.json b/scripts/api-snapshot/api.json index 0d7b4b855..9e0ede45b 100644 --- a/scripts/api-snapshot/api.json +++ b/scripts/api-snapshot/api.json @@ -893,6 +893,16 @@ }, "smithy_test": {}, "smithy_test.aio": {}, + "smithy_test.aio.http_mock": { + "mock_async": "function[async]" + }, + "smithy_test.http_mock": { + "HTTPMockController": "class", + "HTTPMockError": "exception", + "HTTPMockExpectation": "class", + "HTTPMockMismatchError": "exception", + "mock": "function" + }, "smithy_xml": { "XMLCodec": "class" }, diff --git a/scripts/api-snapshot/mirrors.txt b/scripts/api-snapshot/mirrors.txt index 7fb485760..8efa0876f 100644 --- a/scripts/api-snapshot/mirrors.txt +++ b/scripts/api-snapshot/mirrors.txt @@ -95,4 +95,8 @@ Dual-mode symbols (async in aio, sync twin outside): parse_rest_json_error_info_async parse_rest_json_error_info testing.py AsyncMockHTTPClient MockHTTPClient (smithy_http.testing.mockhttp) -48 dual-mode symbol(s). + smithy_test/ + aio/ + http_mock.py + mock_async mock +49 dual-mode symbol(s). diff --git a/scripts/verifications/import-cost.json b/scripts/verifications/import-cost.json index 3711e73cb..5c0f09228 100644 --- a/scripts/verifications/import-cost.json +++ b/scripts/verifications/import-cost.json @@ -17,8 +17,8 @@ "smithy_json (all public modules)": 21, "smithy_python": 1, "smithy_python (all public modules)": 20, - "smithy_test": 28, - "smithy_test (all public modules)": 30, + "smithy_test": 44, + "smithy_test (all public modules)": 59, "smithy_xml": 20, "smithy_xml (all public modules)": 20 } diff --git a/uv.lock b/uv.lock index 35d88896b..59c1a388f 100644 --- a/uv.lock +++ b/uv.lock @@ -837,11 +837,17 @@ typing = [ name = "smithy-test" source = { editable = "packages/smithy-test" } dependencies = [ + { name = "smithy-aws-core" }, { name = "smithy-core" }, + { name = "smithy-http" }, ] [package.metadata] -requires-dist = [{ name = "smithy-core", editable = "packages/smithy-core" }] +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 = "smithy-xml"