Skip to content

Commit 4b99d0f

Browse files
author
Bhanu Teja Goshikonda
committed
fix: migrate inference integration tests to SageMaker SDK v3 API
The PyPI sagemaker package v3.x removed the top-level sagemaker.Session class along with sagemaker.tensorflow.serving.TensorFlowModel and sagemaker.multidatamodel.MultiDataModel. Our conftest used the legacy v2 API. Rewrite to use boto3 directly (sagemaker, sagemaker-runtime, s3 clients) so tests work with the >=3.0.0 pin and are SDK-version independent. boto3 maps 1:1 to the create_model / create_endpoint_config / create_endpoint / invoke_endpoint flow these tests already needed, which is simpler than adopting v3's heavier ModelBuilder abstraction for integration tests. The sagemaker dep is dropped from requirements.txt since it is no longer imported.
1 parent 9be9aac commit 4b99d0f

4 files changed

Lines changed: 218 additions & 90 deletions

File tree

test/tensorflow/integration/inference/conftest.py

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
"""Pytest fixtures for TF 2.20 inference integration tests on SageMaker.
22
3+
These fixtures use boto3 directly (sagemaker, sagemaker-runtime, s3 clients)
4+
rather than the SageMaker Python SDK. The PyPI ``sagemaker`` package v3.x
5+
removed the legacy v2 surfaces this suite relied on (``sagemaker.Session``,
6+
``sagemaker.tensorflow.serving.TensorFlowModel``, ``sagemaker.multidatamodel``),
7+
and the v3 ``ModelBuilder`` flow is heavier than what these integration tests
8+
need. Going boto3-only keeps the tests transparent and SDK-version-independent.
9+
310
Fixtures intentionally defer all AWS calls until test-execution time so that
411
``pytest --collect-only`` works in environments without AWS credentials.
512
"""
@@ -46,11 +53,51 @@ def boto_session(aws_region: str):
4653

4754

4855
@pytest.fixture(scope="session")
49-
def sagemaker_session(boto_session):
50-
"""A SageMaker SDK session for high-level deploy/predict calls."""
51-
import sagemaker
56+
def sagemaker_client(boto_session):
57+
"""Low-level SageMaker control-plane client (create/delete model, endpoint, ...)."""
58+
return boto_session.client("sagemaker")
59+
5260

53-
return sagemaker.Session(boto_session=boto_session)
61+
@pytest.fixture(scope="session")
62+
def sagemaker_runtime_client(boto_session):
63+
"""SageMaker runtime client used to invoke endpoints."""
64+
return boto_session.client("sagemaker-runtime")
65+
66+
67+
@pytest.fixture(scope="session")
68+
def s3_client(boto_session):
69+
"""S3 client used to upload sample model tarballs."""
70+
return boto_session.client("s3")
71+
72+
73+
@pytest.fixture(scope="session")
74+
def default_bucket(boto_session, aws_region: str, s3_client) -> str:
75+
"""Resolve the ``sagemaker-<region>-<account>`` default bucket, creating it if absent.
76+
77+
Mirrors the behaviour of the v2 SDK's ``Session.default_bucket()`` so test
78+
bodies can keep using a single, predictable bucket without callers having
79+
to plumb one in.
80+
"""
81+
sts = boto_session.client("sts")
82+
account_id = sts.get_caller_identity()["Account"]
83+
bucket = f"sagemaker-{aws_region}-{account_id}"
84+
85+
try:
86+
s3_client.head_bucket(Bucket=bucket)
87+
except Exception:
88+
# Bucket missing or inaccessible — try to create it. us-east-1 must omit
89+
# LocationConstraint; every other region requires it.
90+
create_kwargs: dict = {"Bucket": bucket}
91+
if aws_region != "us-east-1":
92+
create_kwargs["CreateBucketConfiguration"] = {"LocationConstraint": aws_region}
93+
try:
94+
s3_client.create_bucket(**create_kwargs)
95+
except Exception:
96+
# Race or pre-existing-but-403 — leave the original error to surface
97+
# at upload time rather than masking it here.
98+
pass
99+
100+
return bucket
54101

55102

56103
@pytest.fixture
@@ -68,13 +115,61 @@ def _make(prefix: str) -> str:
68115

69116

70117
@pytest.fixture
71-
def cleanup_endpoint(sagemaker_session):
118+
def upload_to_s3(s3_client):
119+
"""Yield-style helper that uploads a local file to ``s3://<bucket>/<key>``.
120+
121+
Returns the resulting ``s3://`` URI. Failures propagate; teardown is
122+
intentionally not provided since SageMaker model artifacts are typically
123+
left in the bucket for forensic inspection.
124+
"""
125+
126+
def _upload(local_path: str, bucket: str, key: str) -> str:
127+
s3_client.upload_file(local_path, bucket, key)
128+
return f"s3://{bucket}/{key}"
129+
130+
return _upload
131+
132+
133+
@pytest.fixture
134+
def wait_for_endpoint(sagemaker_client):
135+
"""Poll ``describe_endpoint`` until the endpoint reaches ``InService``.
136+
137+
Raises ``RuntimeError`` if the endpoint enters ``Failed`` / ``OutOfService``
138+
or if the wait exceeds ``timeout_seconds`` (default 1800s = 30 min, which
139+
matches typical first-pull cold-start latency for inference DLCs).
140+
"""
141+
142+
def _wait(endpoint_name: str, timeout_seconds: int = 1800, poll_seconds: int = 30) -> None:
143+
deadline = time.time() + timeout_seconds
144+
while time.time() < deadline:
145+
resp = sagemaker_client.describe_endpoint(EndpointName=endpoint_name)
146+
status = resp["EndpointStatus"]
147+
if status == "InService":
148+
return
149+
if status in {"Failed", "OutOfService"}:
150+
reason = resp.get("FailureReason", "<no FailureReason>")
151+
raise RuntimeError(
152+
f"endpoint {endpoint_name} entered terminal state {status}: {reason}"
153+
)
154+
time.sleep(poll_seconds)
155+
raise RuntimeError(
156+
f"endpoint {endpoint_name} did not reach InService within {timeout_seconds}s"
157+
)
158+
159+
return _wait
160+
161+
162+
@pytest.fixture
163+
def cleanup_endpoint(sagemaker_client):
72164
"""Yield-style fixture that tears down endpoint, endpoint config, and model.
73165
74166
Usage:
75167
def test_x(cleanup_endpoint, ...):
76168
cleanup_endpoint(endpoint_name, model_name=model_name)
77169
# ... deploy + predict ...
170+
171+
Endpoint config is registered under the same name as the endpoint, matching
172+
what the test bodies pass to ``create_endpoint_config``.
78173
"""
79174
registered: list[dict] = []
80175

@@ -83,25 +178,22 @@ def _register(endpoint_name: str, model_name: str | None = None) -> None:
83178

84179
yield _register
85180

86-
sm_client = sagemaker_session.boto_session.client("sagemaker")
87181
for item in registered:
88182
endpoint_name = item["endpoint_name"]
89183
model_name = item["model_name"]
90184

91185
for delete_call, kwargs in (
92-
(sm_client.delete_endpoint, {"EndpointName": endpoint_name}),
93-
(sm_client.delete_endpoint_config, {"EndpointConfigName": endpoint_name}),
186+
(sagemaker_client.delete_endpoint, {"EndpointName": endpoint_name}),
187+
(sagemaker_client.delete_endpoint_config, {"EndpointConfigName": endpoint_name}),
94188
):
95189
try:
96190
delete_call(**kwargs)
97-
except sm_client.exceptions.ClientError:
98-
# Swallow NotFound / already-deleted; teardown should be best-effort.
99-
pass
100191
except Exception:
192+
# Swallow NotFound / already-deleted; teardown is best-effort.
101193
pass
102194

103195
if model_name:
104196
try:
105-
sm_client.delete_model(ModelName=model_name)
197+
sagemaker_client.delete_model(ModelName=model_name)
106198
except Exception:
107199
pass
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
boto3
22
pytest
3-
sagemaker>=3.0.0

test/tensorflow/integration/inference/test_multi_model_endpoint.py

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
Builds two tiny SavedModels (``y = 2x`` and ``y = 3x``), uploads both to a
44
shared S3 prefix, deploys a SageMaker MME backed by the v2 inference image,
55
and asserts that ``TargetModel`` routes invocations correctly.
6+
7+
Uses boto3 directly (sagemaker, sagemaker-runtime, s3 clients) rather than
8+
the SageMaker Python SDK, because the v3.x ``sagemaker`` package removed
9+
the v2 ``MultiDataModel`` / ``TensorFlowModel`` classes this test originally
10+
relied on. MME is a thin server-side construct anyway: ``Mode: MultiModel``
11+
on the PrimaryContainer plus a model_data S3 prefix.
612
"""
713

814
from __future__ import annotations
@@ -18,9 +24,9 @@
1824
INSTANCE_TYPE = "ml.c5.xlarge"
1925

2026

21-
def _decode(response) -> dict:
22-
"""Normalize an MME runtime invoke response Body into a Python dict."""
23-
body = response["Body"].read() if hasattr(response, "get") else response
27+
def _decode(invoke_response) -> dict:
28+
"""Decode an ``invoke_endpoint`` response Body into a Python dict."""
29+
body = invoke_response["Body"].read()
2430
if isinstance(body, (bytes, bytearray)):
2531
body = body.decode("utf-8")
2632
return json.loads(body)
@@ -36,15 +42,16 @@ def _values_from_predictions(predictions) -> list:
3642

3743

3844
def test_mme_two_models(
39-
sagemaker_session,
45+
sagemaker_client,
46+
sagemaker_runtime_client,
4047
sagemaker_role_arn,
4148
inference_image_uri,
49+
default_bucket,
50+
upload_to_s3,
4251
unique_name,
4352
cleanup_endpoint,
53+
wait_for_endpoint,
4454
):
45-
from sagemaker.multidatamodel import MultiDataModel
46-
from sagemaker.tensorflow.serving import TensorFlowModel
47-
4855
with tempfile.TemporaryDirectory(prefix="tf220-mme-") as workdir:
4956
workdir_path = Path(workdir)
5057

@@ -59,48 +66,55 @@ def test_mme_two_models(
5966
output_dir=model2_dir, multiplier=3.0, model_name="model", tar_filename="model2.tar.gz"
6067
)
6168

62-
bucket = sagemaker_session.default_bucket()
6369
run_id = unique_name("mme")
6470
s3_key_prefix = f"tf220-inference-tests/mme-models/{run_id}"
6571

6672
# Upload each tarball under the shared MME prefix so the runtime can
6773
# resolve TargetModel relative to the same S3 location.
68-
sagemaker_session.upload_data(path=model1_tar, bucket=bucket, key_prefix=s3_key_prefix)
69-
sagemaker_session.upload_data(path=model2_tar, bucket=bucket, key_prefix=s3_key_prefix)
70-
s3_model_prefix = f"s3://{bucket}/{s3_key_prefix}/"
74+
upload_to_s3(model1_tar, default_bucket, f"{s3_key_prefix}/model1.tar.gz")
75+
upload_to_s3(model2_tar, default_bucket, f"{s3_key_prefix}/model2.tar.gz")
76+
s3_model_prefix = f"s3://{default_bucket}/{s3_key_prefix}/"
7177

7278
endpoint_name = unique_name("tf220-mme")
7379
base_model_name = unique_name("tf220-mme-model")
7480
cleanup_endpoint(endpoint_name, model_name=base_model_name)
7581

76-
# The TensorFlowModel acts as the container template; MultiDataModel
77-
# reuses its image_uri + execution role for the endpoint config.
78-
base_model = TensorFlowModel(
79-
model_data=model1_tar, # placeholder; MME ignores model_data at runtime
80-
role=sagemaker_role_arn,
81-
image_uri=inference_image_uri,
82-
sagemaker_session=sagemaker_session,
83-
name=base_model_name,
82+
# ``Mode: MultiModel`` plus an S3 prefix is the entire MME contract on
83+
# the control plane; the runtime resolves ``TargetModel`` relative to
84+
# ModelDataUrl.
85+
sagemaker_client.create_model(
86+
ModelName=base_model_name,
87+
ExecutionRoleArn=sagemaker_role_arn,
88+
PrimaryContainer={
89+
"Image": inference_image_uri,
90+
"ModelDataUrl": s3_model_prefix,
91+
"Mode": "MultiModel",
92+
},
8493
)
8594

86-
mme = MultiDataModel(
87-
name=base_model_name,
88-
model_data_prefix=s3_model_prefix,
89-
model=base_model,
90-
sagemaker_session=sagemaker_session,
95+
sagemaker_client.create_endpoint_config(
96+
EndpointConfigName=endpoint_name,
97+
ProductionVariants=[
98+
{
99+
"VariantName": "AllTraffic",
100+
"ModelName": base_model_name,
101+
"InitialInstanceCount": 1,
102+
"InstanceType": INSTANCE_TYPE,
103+
"InitialVariantWeight": 1.0,
104+
}
105+
],
91106
)
92107

93-
mme.deploy(
94-
initial_instance_count=1,
95-
instance_type=INSTANCE_TYPE,
96-
endpoint_name=endpoint_name,
108+
sagemaker_client.create_endpoint(
109+
EndpointName=endpoint_name,
110+
EndpointConfigName=endpoint_name,
97111
)
112+
wait_for_endpoint(endpoint_name)
98113

99-
runtime = sagemaker_session.boto_session.client("sagemaker-runtime")
100114
payload = json.dumps({"instances": [[1.0, 2.0, 3.0]]})
101115

102116
# Invoke model1 (x * 2.0)
103-
resp1 = runtime.invoke_endpoint(
117+
resp1 = sagemaker_runtime_client.invoke_endpoint(
104118
EndpointName=endpoint_name,
105119
ContentType="application/json",
106120
TargetModel="model1.tar.gz",
@@ -112,7 +126,7 @@ def test_mme_two_models(
112126
assert values1 == pytest.approx([2.0, 4.0, 6.0]), f"model1 got {values1!r}"
113127

114128
# Invoke model2 (x * 3.0)
115-
resp2 = runtime.invoke_endpoint(
129+
resp2 = sagemaker_runtime_client.invoke_endpoint(
116130
EndpointName=endpoint_name,
117131
ContentType="application/json",
118132
TargetModel="model2.tar.gz",

0 commit comments

Comments
 (0)