diff --git a/Makefile b/Makefile index 43184115..22f2b071 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,60 @@ SMITHY_PYTHON ?= ../smithy-python # Clients per gradle build in `make clients`; lower it if the build runs out of heap. BATCH_SIZE ?= 25 -.PHONY: docs docs-serve docs-clean docs-install docs-lock venv gen clients publish-codegen aio-check +.PHONY: docs docs-serve docs-clean docs-install docs-lock venv gen clients publish-codegen aio-check static-analysis test-unit test-functional test-integration + +# Each client and package runs in its own .venv. Dependencies outside this repo, +# including smithy-python, resolve from PyPI at the versions each one declares. +PROJECTS := $(patsubst %/pyproject.toml,%,$(wildcard clients/*/pyproject.toml packages/*/pyproject.toml)) +RUFF_VERSION := 0.15.22 +PYRIGHT_VERSION := 1.1.406 +UV_RUN = uv run --python $(PYTHON_VERSION) --project + +# Q Business is sunsetting; new application creation is unavailable. +INTEGRATION_SKIP := clients/aws-sdk-qbusiness + +static-analysis: + @set -e; for p in $(PROJECTS); do \ + dirs=$$(ls -d $$p/src $$p/tests 2>/dev/null || true); \ + group=$$(grep -q '^test = \[' $$p/pyproject.toml && echo --group test || true); \ + echo "== $$p"; \ + uvx ruff@$(RUFF_VERSION) check --config $$p/pyproject.toml $$dirs; \ + uvx ruff@$(RUFF_VERSION) format --check --config $$p/pyproject.toml $$dirs; \ + $(UV_RUN) $$p $$group --with pyright==$(PYRIGHT_VERSION) pyright -p $$p $$dirs; \ + done + # TODO: enable once aws-credentials-sts passes the aio naming rules. + # $(MAKE) aio-check + +test-unit: + @set -e; for p in $(PROJECTS); do \ + [ -d $$p/tests/unit ] || continue; \ + echo "== $$p"; \ + $(UV_RUN) $$p --group test pytest $$p/tests/unit; \ + done + +test-functional: + # pytest exits 5 when a generated protocol test file has no cases. + @set -e; for p in $(PROJECTS); do \ + [ -n "$$(find $$p/tests -name 'test_*.py' -not -path '*/tests/unit/*' -not -path '*/tests/integration/*' 2>/dev/null)" ] || continue; \ + echo "== $$p"; \ + $(UV_RUN) $$p --group test pytest $$p/tests --ignore=$$p/tests/unit --ignore=$$p/tests/integration || [ $$? -eq 5 ]; \ + done + +# Calls live services, so it needs AWS credentials. +test-integration: + @set -e; for p in $(filter-out $(INTEGRATION_SKIP),$(PROJECTS)); do \ + [ -d $$p/tests/integration ] || continue; \ + echo "== $$p"; \ + $(UV_RUN) $$p --group test --all-extras pytest $$p/tests/integration; \ + done + +# Regenerate every client from codegen/aws-models with codegen built from the +# smithy-python commit in codegen/smithy-python.toml. Each client is cleared first, +# keeping only the paths in gen_client.py's KEEP, then scripts/client-overlays is applied. +clients: + SMITHY_PYTHON=$(abspath $(SMITHY_PYTHON)) uv run python codegen/gen_client.py --all --pinned --batch-size $(BATCH_SIZE) + +########################## Granular targets below ########################## # Publishes the smithy-python codegen jars to the local Maven cache so `gen` # can resolve them. Run once, and again whenever the generator changes. @@ -19,12 +72,6 @@ publish-codegen: gen: SMITHY_PYTHON=$(abspath $(SMITHY_PYTHON)) uv run python codegen/gen_client.py $(filter-out gen,$(MAKECMDGOALS)) -# Regenerate every client from codegen/aws-models with codegen built from the -# smithy-python commit in codegen/smithy-python.toml. Each client is cleared first, -# keeping only the paths in gen_client.py's KEEP, then scripts/client-overlays is applied. -clients: - SMITHY_PYTHON=$(abspath $(SMITHY_PYTHON)) uv run python codegen/gen_client.py --all --pinned --batch-size $(BATCH_SIZE) - # Swallow the client-name argument so make does not treat it as a target. # Scoped to `gen` so it does not mask typos in unrelated commands. ifeq (gen,$(firstword $(MAKECMDGOALS))) diff --git a/clients/aws-sdk-connecthealth/pyproject.toml b/clients/aws-sdk-connecthealth/pyproject.toml index e182a724..b5bc4230 100644 --- a/clients/aws-sdk-connecthealth/pyproject.toml +++ b/clients/aws-sdk-connecthealth/pyproject.toml @@ -42,6 +42,7 @@ test = [ "boto3", "pytest>=9.0.1,<10.0.0", "pytest-asyncio>=1.3.0,<1.4.0", + "types-boto3-lite[s3]", ] [build-system] diff --git a/clients/aws-sdk-connecthealth/tests/integration/__init__.py b/clients/aws-sdk-connecthealth/tests/integration/__init__.py index 8b4c358a..9652e5f5 100644 --- a/clients/aws-sdk-connecthealth/tests/integration/__init__.py +++ b/clients/aws-sdk-connecthealth/tests/integration/__init__.py @@ -13,6 +13,9 @@ REGION = "us-east-1" AUDIO_FILE = Path(__file__).parent / "assets" / "test.wav" +# (domain_id, subscription_id, output_s3_uri) +type ConnectHealthResources = tuple[str, str, str] + async def create_connecthealth_client( region: str, *, transport: HTTPClient | None = None diff --git a/clients/aws-sdk-connecthealth/tests/integration/conftest.py b/clients/aws-sdk-connecthealth/tests/integration/conftest.py index 2165234d..bce85a8e 100644 --- a/clients/aws-sdk-connecthealth/tests/integration/conftest.py +++ b/clients/aws-sdk-connecthealth/tests/integration/conftest.py @@ -10,11 +10,17 @@ import asyncio import uuid -from typing import Any +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any import boto3 import pytest +# The stubs are a pyright-only test dependency; the annotations using them are +# on local variables, which are never evaluated at runtime. +if TYPE_CHECKING: + from types_boto3_s3 import S3Client + from aws_sdk_connecthealth.client import AsyncConnectHealthClient from aws_sdk_connecthealth.models import ( CreateDomainInput, @@ -25,7 +31,7 @@ SubscriptionStatus, ) -from . import REGION, create_connecthealth_client +from . import REGION, ConnectHealthResources, create_connecthealth_client # Tags applied to all resources so orphaned resources from interrupted # test runs can be discovered and cleaned up. @@ -118,7 +124,7 @@ def _create_s3_bucket(s3_client: Any, bucket_name: str) -> None: s3_client.create_bucket( Bucket=bucket_name, CreateBucketConfiguration={ - "Tags": [{"Key": k, "Value": v} for k, v in _TAGS.items()], + "Tags": [{"Key": k, "Value": v} for k, v in _TAGS.items()] }, ) @@ -146,13 +152,13 @@ def _delete_s3_bucket(s3_client: Any, bucket_name: str) -> None: @pytest.fixture(scope="session") -async def connecthealth_resources(): +async def connecthealth_resources() -> AsyncIterator[ConnectHealthResources]: """Create ConnectHealth resources for the test session and delete them after.""" unique_suffix = uuid.uuid4().hex[:16] domain_name = f"integ-test-connecthealth-domain-{unique_suffix}" bucket_name = f"integ-test-connecthealth-bucket-{unique_suffix}" - s3_client = boto3.client("s3", region_name=REGION) + s3_client: S3Client = boto3.client("s3", region_name=REGION) async with await create_connecthealth_client(REGION) as client: domain_id: str | None = None subscription_id: str | None = None diff --git a/clients/aws-sdk-connecthealth/tests/integration/test_bidirectional_streaming.py b/clients/aws-sdk-connecthealth/tests/integration/test_bidirectional_streaming.py index 8f8ae81f..beac9745 100644 --- a/clients/aws-sdk-connecthealth/tests/integration/test_bidirectional_streaming.py +++ b/clients/aws-sdk-connecthealth/tests/integration/test_bidirectional_streaming.py @@ -39,7 +39,13 @@ StartMedicalScribeListeningSessionOutput, ) -from . import AUDIO_FILE, REGION, create_connecthealth_client, streaming_endpoint_plugin +from . import ( + AUDIO_FILE, + REGION, + ConnectHealthResources, + create_connecthealth_client, + streaming_endpoint_plugin, +) SAMPLE_RATE = 16000 BYTES_PER_SAMPLE = 2 @@ -155,7 +161,9 @@ async def _receive_events( return got_transcript -async def test_start_medical_scribe_listening_session(connecthealth_resources) -> None: +async def test_start_medical_scribe_listening_session( + connecthealth_resources: ConnectHealthResources, +) -> None: """Test bidirectional streaming with audio input and transcript output.""" domain_id, subscription_id, output_s3_uri = connecthealth_resources diff --git a/clients/aws-sdk-connecthealth/tests/integration/test_non_streaming.py b/clients/aws-sdk-connecthealth/tests/integration/test_non_streaming.py index c4907778..9c8d1f3a 100644 --- a/clients/aws-sdk-connecthealth/tests/integration/test_non_streaming.py +++ b/clients/aws-sdk-connecthealth/tests/integration/test_non_streaming.py @@ -17,10 +17,10 @@ SubscriptionStatus, ) -from . import REGION, create_connecthealth_client +from . import REGION, ConnectHealthResources, create_connecthealth_client -async def test_list_domains(connecthealth_resources) -> None: +async def test_list_domains(connecthealth_resources: ConnectHealthResources) -> None: """Test non-streaming ListDomains operation.""" _ = connecthealth_resources async with await create_connecthealth_client(REGION) as client: @@ -31,7 +31,7 @@ async def test_list_domains(connecthealth_resources) -> None: assert len(response.domains) >= 1 -async def test_get_domain(connecthealth_resources) -> None: +async def test_get_domain(connecthealth_resources: ConnectHealthResources) -> None: """Test non-streaming GetDomain operation.""" domain_id, _, _ = connecthealth_resources async with await create_connecthealth_client(REGION) as client: @@ -51,7 +51,9 @@ async def test_get_domain(connecthealth_resources) -> None: assert response.tags == {"Purpose": "IntegTest"} -async def test_list_subscriptions(connecthealth_resources) -> None: +async def test_list_subscriptions( + connecthealth_resources: ConnectHealthResources, +) -> None: """Test non-streaming ListSubscriptions operation.""" domain_id, subscription_id, _ = connecthealth_resources async with await create_connecthealth_client(REGION) as client: @@ -68,7 +70,9 @@ async def test_list_subscriptions(connecthealth_resources) -> None: assert sub.domain_id == domain_id -async def test_get_subscription(connecthealth_resources) -> None: +async def test_get_subscription( + connecthealth_resources: ConnectHealthResources, +) -> None: """Test non-streaming GetSubscription operation.""" domain_id, subscription_id, _ = connecthealth_resources async with await create_connecthealth_client(REGION) as client: diff --git a/clients/aws-sdk-lex-runtime-v2/pyproject.toml b/clients/aws-sdk-lex-runtime-v2/pyproject.toml index 1a4f2c73..a3edbba3 100644 --- a/clients/aws-sdk-lex-runtime-v2/pyproject.toml +++ b/clients/aws-sdk-lex-runtime-v2/pyproject.toml @@ -42,6 +42,7 @@ test = [ "boto3", "pytest>=9.0.1,<10.0.0", "pytest-asyncio>=1.3.0,<1.4.0", + "types-boto3-lite[iam,lexv2-models,sts]", ] [build-system] diff --git a/clients/aws-sdk-lex-runtime-v2/tests/integration/conftest.py b/clients/aws-sdk-lex-runtime-v2/tests/integration/conftest.py index d7475875..7475cefa 100644 --- a/clients/aws-sdk-lex-runtime-v2/tests/integration/conftest.py +++ b/clients/aws-sdk-lex-runtime-v2/tests/integration/conftest.py @@ -10,11 +10,18 @@ import json import uuid -from typing import Any +from typing import TYPE_CHECKING, Any import boto3 import pytest +# The stubs are a pyright-only test dependency; the annotations using them are +# on local variables, which are never evaluated at runtime. +if TYPE_CHECKING: + from types_boto3_iam import IAMClient + from types_boto3_lexv2_models import LexModelsV2Client + from types_boto3_sts import STSClient + from . import LOCALE_ID, REGION # Tags applied to all resources so orphaned resources from interrupted @@ -144,9 +151,9 @@ def lex_bot(): role_name = f"integ-test-lex-runtime-v2-role-{unique_suffix}" bot_name = f"integ-test-lex-runtime-v2-bot-{unique_suffix}" - iam_client = boto3.client("iam") - lex_client = boto3.client("lexv2-models", region_name=REGION) - sts_client = boto3.client("sts") + iam_client: IAMClient = boto3.client("iam") + lex_client: LexModelsV2Client = boto3.client("lexv2-models", region_name=REGION) + sts_client: STSClient = boto3.client("sts") bot_id = None try: diff --git a/clients/aws-sdk-polly/tests/integration/test_bidirectional_streaming.py b/clients/aws-sdk-polly/tests/integration/test_bidirectional_streaming.py index da35433a..56f99151 100644 --- a/clients/aws-sdk-polly/tests/integration/test_bidirectional_streaming.py +++ b/clients/aws-sdk-polly/tests/integration/test_bidirectional_streaming.py @@ -19,7 +19,6 @@ StartSpeechSynthesisStreamEventStreamServiceQuotaExceededException, StartSpeechSynthesisStreamEventStreamStreamClosedEvent, StartSpeechSynthesisStreamEventStreamThrottlingException, - StartSpeechSynthesisStreamEventStreamUnknown, StartSpeechSynthesisStreamEventStreamValidationException, StartSpeechSynthesisStreamInput, StartSpeechSynthesisStreamOutput, @@ -85,12 +84,8 @@ async def _receive_audio( break elif isinstance(event, ERROR_EVENT_TYPES): raise event.value - elif isinstance(event, StartSpeechSynthesisStreamEventStreamUnknown): - raise RuntimeError(f"Received unknown event in stream: {event.tag}") else: - raise RuntimeError( - f"Received unexpected event type in stream: {type(event).__name__}" - ) + raise RuntimeError(f"Received unknown event in stream: {event.tag}") return audio_bytes, request_characters diff --git a/clients/aws-sdk-qbusiness/tests/integration/conftest.py b/clients/aws-sdk-qbusiness/tests/integration/conftest.py index 4ac6316c..1903f211 100644 --- a/clients/aws-sdk-qbusiness/tests/integration/conftest.py +++ b/clients/aws-sdk-qbusiness/tests/integration/conftest.py @@ -10,9 +10,11 @@ import asyncio import uuid +from collections.abc import AsyncIterator import pytest +from aws_sdk_qbusiness.client import AsyncQBusinessClient from aws_sdk_qbusiness.models import ( ApplicationStatus, CreateApplicationInput, @@ -46,7 +48,9 @@ _THROTTLE_RETRY_TIMEOUT_SECONDS = 300 -async def _wait_for_application_active(client, application_id: str) -> None: +async def _wait_for_application_active( + client: AsyncQBusinessClient, application_id: str +) -> None: """Wait for an Application to reach ACTIVE status. Args: @@ -68,7 +72,9 @@ async def _wait_for_application_active(client, application_id: str) -> None: raise TimeoutError(f"Application {application_id} did not become ACTIVE in time") -async def _wait_for_index_active(client, application_id: str, index_id: str) -> None: +async def _wait_for_index_active( + client: AsyncQBusinessClient, application_id: str, index_id: str +) -> None: """Wait for an Index to reach ACTIVE status. Args: @@ -92,7 +98,7 @@ async def _wait_for_index_active(client, application_id: str, index_id: str) -> async def _wait_for_retriever_active( - client, application_id: str, retriever_id: str + client: AsyncQBusinessClient, application_id: str, retriever_id: str ) -> None: """Wait for a Retriever to reach ACTIVE status. @@ -119,7 +125,7 @@ async def _wait_for_retriever_active( async def _create_qbusiness_app( - client, app_name: str, index_name: str, retriever_name: str + client: AsyncQBusinessClient, app_name: str, index_name: str, retriever_name: str ) -> str: """Create a Q Business application with index and retriever. @@ -185,7 +191,9 @@ async def _create_qbusiness_app( return application_id -async def _delete_qbusiness_app(client, application_id: str | None) -> None: +async def _delete_qbusiness_app( + client: AsyncQBusinessClient, application_id: str | None +) -> None: """Delete a Q Business application. Cascades to its index and retriever. Args: @@ -200,7 +208,7 @@ async def _delete_qbusiness_app(client, application_id: str | None) -> None: @pytest.fixture(scope="session") -async def qbusiness_app(): +async def qbusiness_app() -> AsyncIterator[str]: """Create a Q Business application for the test session and delete it after.""" unique_suffix = uuid.uuid4().hex[:16] app_name = f"integ-test-qbusiness-app-{unique_suffix}" diff --git a/clients/aws-sdk-transcribe-streaming/pyproject.toml b/clients/aws-sdk-transcribe-streaming/pyproject.toml index 635b7f14..7e213df4 100644 --- a/clients/aws-sdk-transcribe-streaming/pyproject.toml +++ b/clients/aws-sdk-transcribe-streaming/pyproject.toml @@ -42,6 +42,7 @@ test = [ "boto3", "pytest>=9.0.1,<10.0.0", "pytest-asyncio>=1.3.0,<1.4.0", + "types-boto3-lite[iam,s3,sts]", ] [build-system] diff --git a/clients/aws-sdk-transcribe-streaming/tests/integration/conftest.py b/clients/aws-sdk-transcribe-streaming/tests/integration/conftest.py index b771dc1e..07d9aabd 100644 --- a/clients/aws-sdk-transcribe-streaming/tests/integration/conftest.py +++ b/clients/aws-sdk-transcribe-streaming/tests/integration/conftest.py @@ -10,11 +10,18 @@ import json import uuid -from typing import Any +from typing import TYPE_CHECKING, Any import boto3 import pytest +# The stubs are a pyright-only test dependency; the annotations using them are +# on local variables, which are never evaluated at runtime. +if TYPE_CHECKING: + from types_boto3_iam import IAMClient + from types_boto3_s3 import S3Client + from types_boto3_sts import STSClient + REGION = "us-east-1" # Tags applied to all resources so orphaned resources from interrupted @@ -140,9 +147,9 @@ def healthscribe_resources(): role_name = f"integ-test-transcribe-streaming-role-{unique_suffix}" bucket_name = f"integ-test-transcribe-streaming-bucket-{unique_suffix}" - iam_client = boto3.client("iam") - s3_client = boto3.client("s3", region_name=REGION) - sts_client = boto3.client("sts") + iam_client: IAMClient = boto3.client("iam") + s3_client: S3Client = boto3.client("s3", region_name=REGION) + sts_client: STSClient = boto3.client("sts") try: role_arn = _create_healthscribe_resources( diff --git a/codegen/gen_client.py b/codegen/gen_client.py index 64c32be1..ba303eba 100644 --- a/codegen/gen_client.py +++ b/codegen/gen_client.py @@ -45,11 +45,12 @@ "zensical.toml", ) -# The integration tests of these clients set up resources with boto3. +# The integration tests of these clients set up resources with boto3; values are +# the services they call, which select the matching types-boto3-lite stubs. CLIENTS_REQUIRING_BOTO3 = { - "aws-sdk-connecthealth", - "aws-sdk-lex-runtime-v2", - "aws-sdk-transcribe-streaming", + "aws-sdk-connecthealth": ["s3"], + "aws-sdk-lex-runtime-v2": ["iam", "lexv2-models", "sts"], + "aws-sdk-transcribe-streaming": ["iam", "s3", "sts"], } CLIENT_DOC_DEPS = {"zensical", "mkdocstrings[python]"} @@ -267,7 +268,8 @@ def _install_client(slug: str, docs_deps: list[str]) -> None: if docs_deps: _uv_add(dest, "docs", docs_deps) if name in CLIENTS_REQUIRING_BOTO3: - _uv_add(dest, "test", ["boto3"]) + services = ",".join(CLIENTS_REQUIRING_BOTO3[name]) + _uv_add(dest, "test", ["boto3", f"types-boto3-lite[{services}]"]) print(f"wrote {dest}") diff --git a/packages/aws-credentials-http/pyproject.toml b/packages/aws-credentials-http/pyproject.toml index 330025bb..8524e913 100644 --- a/packages/aws-credentials-http/pyproject.toml +++ b/packages/aws-credentials-http/pyproject.toml @@ -39,6 +39,15 @@ dependencies = [ [project.entry-points."smithy_aws_core.identity.chain_providers"] EcsContainer = "aws_credentials_http.providers:EcsContainerProvider" +[dependency-groups] +test = [ + "pytest>=9.0.1,<10.0.0", + "pytest-asyncio>=1.3.0,<1.4.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/packages/aws-credentials-imds/pyproject.toml b/packages/aws-credentials-imds/pyproject.toml index d5a7f6b9..a1d44039 100644 --- a/packages/aws-credentials-imds/pyproject.toml +++ b/packages/aws-credentials-imds/pyproject.toml @@ -39,6 +39,15 @@ dependencies = [ [project.entry-points."smithy_aws_core.identity.chain_providers"] Ec2InstanceMetadata = "aws_credentials_imds.providers:Ec2InstanceMetadataProvider" +[dependency-groups] +test = [ + "pytest>=9.0.1,<10.0.0", + "pytest-asyncio>=1.3.0,<1.4.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/packages/aws-credentials-sts/pyproject.toml b/packages/aws-credentials-sts/pyproject.toml index 435bf0ef..cfe0140a 100644 --- a/packages/aws-credentials-sts/pyproject.toml +++ b/packages/aws-credentials-sts/pyproject.toml @@ -40,6 +40,18 @@ dependencies = [ [project.entry-points."smithy_aws_core.identity.chain_providers"] ProfileAssumeRole = "aws_credentials_sts.providers:ProfileAssumeRoleProvider" +[dependency-groups] +test = [ + "pytest>=9.0.1,<10.0.0", + "pytest-asyncio>=1.3.0,<1.4.0", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.uv.sources] +aws-sdk-sts = { path = "../../clients/aws-sdk-sts", editable = true } + [build-system] requires = ["hatchling"] build-backend = "hatchling.build"