Skip to content

Commit 7c0da2c

Browse files
authored
Add generated Lex Runtime V2 client with integration tests (#51)
* Add generated Lex Runtime V2 client with integration tests * Add unique suffix to resource names to avoid race condition in parallel runs * Ensure fixture cleanup on partial setup failure * Add tags and prefix to integ-test resources for orphan cleanup
1 parent 8937a4c commit 7c0da2c

4 files changed

Lines changed: 373 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from smithy_aws_core.identity import EnvironmentCredentialsResolver
5+
6+
from aws_sdk_lex_runtime_v2.client import LexRuntimeV2Client
7+
from aws_sdk_lex_runtime_v2.config import Config
8+
9+
BOT_ALIAS_ID = "TSTALIASID"
10+
LOCALE_ID = "en_US"
11+
REGION = "us-east-1"
12+
13+
14+
def create_lex_client(region: str) -> LexRuntimeV2Client:
15+
return LexRuntimeV2Client(
16+
config=Config(
17+
endpoint_uri=f"https://runtime-v2-lex.{region}.amazonaws.com",
18+
region=region,
19+
aws_credentials_identity_resolver=EnvironmentCredentialsResolver(),
20+
)
21+
)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Pytest fixtures for Lex Runtime V2 integration tests.
5+
6+
Creates and tears down a Lex V2 bot with a Greeting intent once per
7+
test session. All integration tests receive the bot_id via the
8+
``lex_bot`` fixture.
9+
"""
10+
11+
import json
12+
import uuid
13+
from typing import Any
14+
15+
import boto3
16+
import pytest
17+
18+
from . import LOCALE_ID, REGION
19+
20+
# Tags applied to all resources so orphaned resources from interrupted
21+
# test runs can be discovered and cleaned up.
22+
_TAGS = [{"Key": "Purpose", "Value": "IntegTest"}]
23+
24+
25+
def _create_lex_bot(
26+
iam_client: Any, lex_client: Any, sts_client: Any, role_name: str, bot_name: str
27+
) -> str:
28+
"""Create a Lex V2 bot with a Greeting intent.
29+
30+
Args:
31+
iam_client: A boto3 IAM client.
32+
lex_client: A boto3 lexv2-models client.
33+
sts_client: A boto3 STS client.
34+
role_name: The name of the IAM role to create for the bot.
35+
bot_name: The name of the Lex bot to create.
36+
37+
Returns:
38+
The bot ID.
39+
"""
40+
account_id = sts_client.get_caller_identity()["Account"]
41+
role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"
42+
43+
# Create IAM role for the bot
44+
trust_policy = {
45+
"Version": "2012-10-17",
46+
"Statement": [
47+
{
48+
"Effect": "Allow",
49+
"Principal": {"Service": "lexv2.amazonaws.com"},
50+
"Action": "sts:AssumeRole",
51+
}
52+
],
53+
}
54+
iam_client.create_role(
55+
RoleName=role_name,
56+
AssumeRolePolicyDocument=json.dumps(trust_policy),
57+
Tags=_TAGS,
58+
)
59+
60+
# Create bot
61+
response = lex_client.create_bot(
62+
botName=bot_name,
63+
roleArn=role_arn,
64+
dataPrivacy={"childDirected": False},
65+
# 5-minute idle timeout is sufficient for integration tests.
66+
idleSessionTTLInSeconds=300,
67+
botTags={t["Key"]: t["Value"] for t in _TAGS},
68+
)
69+
bot_id = response["botId"]
70+
lex_client.get_waiter("bot_available").wait(botId=bot_id)
71+
72+
# Create locale
73+
lex_client.create_bot_locale(
74+
botId=bot_id,
75+
botVersion="DRAFT",
76+
localeId=LOCALE_ID,
77+
# Required field. Confidence threshold (0-1) that determines when Lex
78+
# inserts AMAZON.FallbackIntent into the interpretations list.
79+
# 0.40 is a reasonable value for a simple test bot.
80+
nluIntentConfidenceThreshold=0.40,
81+
)
82+
lex_client.get_waiter("bot_locale_created").wait(
83+
botId=bot_id, botVersion="DRAFT", localeId=LOCALE_ID
84+
)
85+
86+
# Create intent
87+
lex_client.create_intent(
88+
intentName="Greeting",
89+
botId=bot_id,
90+
botVersion="DRAFT",
91+
localeId=LOCALE_ID,
92+
sampleUtterances=[
93+
{"utterance": "Hello"},
94+
{"utterance": "Hi"},
95+
{"utterance": "Hey"},
96+
],
97+
intentClosingSetting={
98+
"closingResponse": {
99+
"messageGroups": [
100+
{
101+
"message": {
102+
"plainTextMessage": {"value": "Hello! How can I help you?"}
103+
}
104+
}
105+
]
106+
},
107+
"active": True,
108+
},
109+
)
110+
111+
# Build locale
112+
lex_client.build_bot_locale(botId=bot_id, botVersion="DRAFT", localeId=LOCALE_ID)
113+
lex_client.get_waiter("bot_locale_built").wait(
114+
botId=bot_id, botVersion="DRAFT", localeId=LOCALE_ID
115+
)
116+
117+
return bot_id
118+
119+
120+
def _delete_lex_bot(
121+
iam_client: Any, lex_client: Any, role_name: str, bot_id: str | None
122+
) -> None:
123+
"""Delete a Lex V2 bot and its associated IAM role.
124+
125+
Args:
126+
iam_client: A boto3 IAM client.
127+
lex_client: A boto3 lexv2-models client.
128+
role_name: The name of the IAM role to delete.
129+
bot_id: The bot ID to delete, or None if creation failed.
130+
"""
131+
if bot_id:
132+
lex_client.delete_bot(botId=bot_id, skipResourceInUseCheck=True)
133+
134+
try:
135+
iam_client.delete_role(RoleName=role_name)
136+
except iam_client.exceptions.NoSuchEntityException:
137+
pass
138+
139+
140+
@pytest.fixture(scope="session")
141+
def lex_bot():
142+
"""Create a Lex bot for the test session and delete it after."""
143+
unique_suffix = uuid.uuid4().hex[:16]
144+
role_name = f"integ-test-lex-runtime-v2-role-{unique_suffix}"
145+
bot_name = f"integ-test-lex-runtime-v2-bot-{unique_suffix}"
146+
147+
iam_client = boto3.client("iam")
148+
lex_client = boto3.client("lexv2-models", region_name=REGION)
149+
sts_client = boto3.client("sts")
150+
151+
bot_id = None
152+
try:
153+
bot_id = _create_lex_bot(
154+
iam_client, lex_client, sts_client, role_name, bot_name
155+
)
156+
yield bot_id
157+
finally:
158+
_delete_lex_bot(iam_client, lex_client, role_name, bot_id)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Test bidirectional streaming event stream handling."""
5+
6+
import asyncio
7+
import uuid
8+
9+
from smithy_core.aio.eventstream import DuplexEventStream
10+
11+
from aws_sdk_lex_runtime_v2.models import (
12+
StartConversationInput,
13+
StartConversationRequestEventStream,
14+
StartConversationRequestEventStreamConfigurationEvent,
15+
StartConversationRequestEventStreamTextInputEvent,
16+
StartConversationRequestEventStreamDisconnectionEvent,
17+
StartConversationResponseEventStream,
18+
StartConversationResponseEventStreamHeartbeatEvent,
19+
StartConversationResponseEventStreamIntentResultEvent,
20+
StartConversationResponseEventStreamTextResponseEvent,
21+
StartConversationResponseEventStreamTranscriptEvent,
22+
StartConversationOutput,
23+
ConfigurationEvent,
24+
TextInputEvent,
25+
DisconnectionEvent,
26+
)
27+
from . import BOT_ALIAS_ID, LOCALE_ID, REGION, create_lex_client
28+
29+
30+
async def _send_events(
31+
stream: DuplexEventStream[
32+
StartConversationRequestEventStream,
33+
StartConversationResponseEventStream,
34+
StartConversationOutput,
35+
],
36+
) -> None:
37+
"""Send configuration, text input, and disconnection events."""
38+
input_stream = stream.input_stream
39+
40+
await input_stream.send(
41+
StartConversationRequestEventStreamConfigurationEvent(
42+
value=ConfigurationEvent(response_content_type="text/plain; charset=utf-8")
43+
)
44+
)
45+
46+
await input_stream.send(
47+
StartConversationRequestEventStreamTextInputEvent(
48+
value=TextInputEvent(text="Hello")
49+
)
50+
)
51+
52+
await asyncio.sleep(3)
53+
54+
await input_stream.send(
55+
StartConversationRequestEventStreamDisconnectionEvent(
56+
value=DisconnectionEvent()
57+
)
58+
)
59+
60+
await input_stream.close()
61+
62+
63+
async def _receive_events(
64+
stream: DuplexEventStream[
65+
StartConversationRequestEventStream,
66+
StartConversationResponseEventStream,
67+
StartConversationOutput,
68+
],
69+
) -> tuple[bool, bool, bool, bool]:
70+
"""Receive and collect output from the stream.
71+
72+
Returns:
73+
Tuple of (got_transcript, got_intent_result, got_text_response, got_heartbeat)
74+
"""
75+
got_transcript = False
76+
got_intent_result = False
77+
got_text_response = False
78+
got_heartbeat = False
79+
80+
_, output_stream = await stream.await_output()
81+
if output_stream is None:
82+
return got_transcript, got_intent_result, got_text_response, got_heartbeat
83+
84+
async for event in output_stream:
85+
if isinstance(event, StartConversationResponseEventStreamTranscriptEvent):
86+
got_transcript = True
87+
assert event.value.event_id is not None
88+
assert event.value.transcript == "Hello"
89+
elif isinstance(event, StartConversationResponseEventStreamIntentResultEvent):
90+
got_intent_result = True
91+
assert event.value.event_id is not None
92+
assert event.value.input_mode == "Text"
93+
assert event.value.session_id is not None
94+
assert event.value.session_state is not None
95+
assert event.value.session_state.intent is not None
96+
assert event.value.session_state.intent.name == "Greeting"
97+
assert event.value.session_state.intent.state == "Fulfilled"
98+
assert event.value.interpretations is not None
99+
assert len(event.value.interpretations) == 2
100+
interps_by_name = {
101+
i.intent.name: i for i in event.value.interpretations if i.intent
102+
}
103+
assert "Greeting" in interps_by_name
104+
assert "FallbackIntent" in interps_by_name
105+
assert interps_by_name["Greeting"].nlu_confidence is not None
106+
assert interps_by_name["Greeting"].nlu_confidence.score == 1.0
107+
elif isinstance(event, StartConversationResponseEventStreamTextResponseEvent):
108+
got_text_response = True
109+
assert event.value.event_id is not None
110+
assert event.value.messages is not None
111+
assert len(event.value.messages) == 1
112+
msg = event.value.messages[0]
113+
assert msg.content_type == "PlainText"
114+
assert msg.content == "Hello! How can I help you?"
115+
elif isinstance(event, StartConversationResponseEventStreamHeartbeatEvent):
116+
got_heartbeat = True
117+
assert event.value.event_id is not None
118+
else:
119+
raise RuntimeError(
120+
f"Received unexpected event type in stream: {type(event).__name__}"
121+
)
122+
123+
return got_transcript, got_intent_result, got_text_response, got_heartbeat
124+
125+
126+
async def test_start_conversation(lex_bot: str) -> None:
127+
"""Test bidirectional streaming StartConversation operation."""
128+
client = create_lex_client(REGION)
129+
130+
stream = await client.start_conversation(
131+
input=StartConversationInput(
132+
bot_id=lex_bot,
133+
bot_alias_id=BOT_ALIAS_ID,
134+
locale_id=LOCALE_ID,
135+
session_id=str(uuid.uuid4()),
136+
conversation_mode="TEXT",
137+
)
138+
)
139+
140+
results = await asyncio.gather(_send_events(stream), _receive_events(stream))
141+
142+
got_transcript, got_intent_result, got_text_response, got_heartbeat = results[1]
143+
assert got_transcript, "Expected to receive a TranscriptEvent"
144+
assert got_intent_result, "Expected to receive an IntentResultEvent"
145+
assert got_text_response, "Expected to receive a TextResponseEvent"
146+
assert got_heartbeat, "Expected to receive a HeartbeatEvent"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Test non-streaming output type handling."""
5+
6+
import uuid
7+
8+
from aws_sdk_lex_runtime_v2.models import RecognizeTextInput, RecognizeTextOutput
9+
from . import BOT_ALIAS_ID, LOCALE_ID, REGION, create_lex_client
10+
11+
12+
async def test_recognize_text(lex_bot: str) -> None:
13+
"""Test non-streaming RecognizeText operation."""
14+
client = create_lex_client(REGION)
15+
response = await client.recognize_text(
16+
input=RecognizeTextInput(
17+
bot_id=lex_bot,
18+
bot_alias_id=BOT_ALIAS_ID,
19+
locale_id=LOCALE_ID,
20+
session_id=str(uuid.uuid4()),
21+
text="Hello",
22+
)
23+
)
24+
25+
assert isinstance(response, RecognizeTextOutput)
26+
assert response.session_id is not None
27+
28+
# Verify messages
29+
assert response.messages is not None
30+
assert len(response.messages) == 1
31+
msg = response.messages[0]
32+
assert msg.content_type == "PlainText"
33+
assert msg.content == "Hello! How can I help you?"
34+
35+
# Verify session state
36+
assert response.session_state is not None
37+
assert response.session_state.intent is not None
38+
assert response.session_state.intent.name == "Greeting"
39+
assert response.session_state.intent.state == "Fulfilled"
40+
41+
# Verify interpretations
42+
assert response.interpretations is not None
43+
assert len(response.interpretations) == 2
44+
interps_by_name = {i.intent.name: i for i in response.interpretations if i.intent}
45+
assert "Greeting" in interps_by_name
46+
assert "FallbackIntent" in interps_by_name
47+
assert interps_by_name["Greeting"].nlu_confidence is not None
48+
assert interps_by_name["Greeting"].nlu_confidence.score == 1.0

0 commit comments

Comments
 (0)