Skip to content

Commit 0426f69

Browse files
author
Bhanu Teja Goshikonda
committed
fix: rewrite inference integration tests using SageMaker SDK v3
Replaces commit 4b99d0f which used boto3 directly. The SageMaker Python SDK v3 is the supported entrypoint for these tests; v3 removed the v2 sagemaker.Session, sagemaker.tensorflow.serving.TensorFlowModel, and sagemaker.multidatamodel.MultiDataModel classes the old fixtures relied on. For DLC integration tests we already supply image_uri and a pre-built model.tar.gz, so ModelBuilder's auto-detection adds no value. We use the v3 sagemaker-core resource layer directly (the same surface ModelBuilder calls underneath): - conftest sagemaker_session fixture uses sagemaker.core.helper.session_helper.Session (default_bucket / upload_data); cleanup_endpoint uses Endpoint.get(...).delete(), EndpointConfig.get(...).delete(), Model.get(...).delete() - single-model test uses Model.create(primary_container=ContainerDefinition(...)) + EndpointConfig.create([ProductionVariant(...)]) + Endpoint.create() with endpoint.wait_for_status("InService") and endpoint.invoke(...) - MME test expresses the multi-model contract directly: ContainerDefinition(mode="MultiModel", model_data_url=<S3 prefix>) and endpoint.invoke(target_model="modelN.tar.gz") - sagemaker>=3.0.0 retained in requirements.txt
1 parent 4b99d0f commit 0426f69

4 files changed

Lines changed: 177 additions & 221 deletions

File tree

Lines changed: 43 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
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.
3+
Uses the SageMaker Python SDK v3 (``sagemaker>=3.0.0``) — the v2 Estimator /
4+
Model / Predictor classes were removed in v3 in favor of the unified
5+
``ModelBuilder`` and the ``sagemaker-core`` resource layer
6+
(``Endpoint``, ``EndpointConfig``, ``Model``, ``ContainerDefinition``,
7+
``ProductionVariant``). For these DLC tests we already have a custom
8+
``image_uri`` and a pre-built ``model.tar.gz``, so the simplest v3 path is
9+
the resource layer directly: ``Model.create -> EndpointConfig.create ->
10+
Endpoint.create -> endpoint.invoke()``. ``ModelBuilder`` is the right choice
11+
when the SDK should auto-detect the framework / container / packaging — for
12+
us, those are all fixed by the test fixture inputs.
913
1014
Fixtures intentionally defer all AWS calls until test-execution time so that
1115
``pytest --collect-only`` works in environments without AWS credentials.
@@ -46,58 +50,29 @@ def inference_image_uri() -> str:
4650

4751
@pytest.fixture(scope="session")
4852
def boto_session(aws_region: str):
49-
"""A boto3 session bound to the configured region."""
53+
"""A boto3 session bound to the configured region.
54+
55+
Used purely as a transport for ``sagemaker.core.helper.session_helper.Session``
56+
and for the underlying ``s3`` client when uploading model artifacts; no
57+
SageMaker control-plane calls go through it directly.
58+
"""
5059
import boto3
5160

5261
return boto3.Session(region_name=aws_region)
5362

5463

5564
@pytest.fixture(scope="session")
56-
def sagemaker_client(boto_session):
57-
"""Low-level SageMaker control-plane client (create/delete model, endpoint, ...)."""
58-
return boto_session.client("sagemaker")
59-
60-
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.
65+
def sagemaker_session(boto_session):
66+
"""A SageMaker SDK v3 session.
7667
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.
68+
``sagemaker.core.helper.session_helper.Session`` is the v3 replacement for
69+
the v2 ``sagemaker.Session``. We use it for ``default_bucket()`` and
70+
``upload_data()``; resource-layer ``create()`` calls accept it via the
71+
``session=`` kwarg.
8072
"""
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
73+
from sagemaker.core.helper.session_helper import Session
74+
75+
return Session(boto_session=boto_session)
10176

10277

10378
@pytest.fixture
@@ -115,61 +90,20 @@ def _make(prefix: str) -> str:
11590

11691

11792
@pytest.fixture
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):
93+
def cleanup_endpoint(boto_session):
16494
"""Yield-style fixture that tears down endpoint, endpoint config, and model.
16595
96+
Uses the v3 ``sagemaker-core`` resource layer (``Endpoint.get(...).delete()``,
97+
etc.) rather than raw boto3 SDK calls, so cleanup code matches the deploy
98+
code in the tests. The ``session=`` kwarg on resource ``get`` / ``create``
99+
methods accepts a raw ``boto3.session.Session`` (see
100+
``sagemaker.core.utils.utils.SageMakerClient``); pass ``boto_session``
101+
rather than the helper ``Session``.
102+
166103
Usage:
167104
def test_x(cleanup_endpoint, ...):
168105
cleanup_endpoint(endpoint_name, model_name=model_name)
169106
# ... 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``.
173107
"""
174108
registered: list[dict] = []
175109

@@ -178,22 +112,26 @@ def _register(endpoint_name: str, model_name: str | None = None) -> None:
178112

179113
yield _register
180114

115+
# Import lazily so collection works without the SDK installed.
116+
from sagemaker.core.resources import Endpoint, EndpointConfig, Model
117+
181118
for item in registered:
182119
endpoint_name = item["endpoint_name"]
183120
model_name = item["model_name"]
184121

185-
for delete_call, kwargs in (
186-
(sagemaker_client.delete_endpoint, {"EndpointName": endpoint_name}),
187-
(sagemaker_client.delete_endpoint_config, {"EndpointConfigName": endpoint_name}),
122+
# Endpoint config name == endpoint name in our deploy flow below.
123+
for resource_cls, get_kwargs in (
124+
(Endpoint, {"endpoint_name": endpoint_name}),
125+
(EndpointConfig, {"endpoint_config_name": endpoint_name}),
188126
):
189127
try:
190-
delete_call(**kwargs)
128+
resource_cls.get(session=boto_session, **get_kwargs).delete()
191129
except Exception:
192-
# Swallow NotFound / already-deleted; teardown is best-effort.
130+
# Best-effort teardown: swallow NotFound / already-deleted.
193131
pass
194132

195133
if model_name:
196134
try:
197-
sagemaker_client.delete_model(ModelName=model_name)
135+
Model.get(model_name=model_name, session=boto_session).delete()
198136
except Exception:
199137
pass
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
boto3
22
pytest
3+
sagemaker>=3.0.0

test/tensorflow/integration/inference/test_multi_model_endpoint.py

Lines changed: 71 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22
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,
5-
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.
5+
and asserts that ``target_model`` routes invocations correctly.
6+
7+
Uses the SageMaker Python SDK v3 ``sagemaker-core`` resource layer — v3
8+
removed ``sagemaker.multidatamodel.MultiDataModel``. The native MME wire
9+
contract (``ContainerDefinition.mode = "MultiModel"``,
10+
``model_data_url = s3://bucket/prefix/``, plus the
11+
``X-Amzn-SageMaker-Target-Model`` header on invoke) is unchanged, so we
12+
express it directly: ``Model.create`` with ``mode="MultiModel"`` and an S3
13+
prefix in ``model_data_url``, then ``endpoint.invoke(target_model=...)``
14+
which sets the runtime header for us.
1215
"""
1316

1417
from __future__ import annotations
@@ -24,14 +27,6 @@
2427
INSTANCE_TYPE = "ml.c5.xlarge"
2528

2629

27-
def _decode(invoke_response) -> dict:
28-
"""Decode an ``invoke_endpoint`` response Body into a Python dict."""
29-
body = invoke_response["Body"].read()
30-
if isinstance(body, (bytes, bytearray)):
31-
body = body.decode("utf-8")
32-
return json.loads(body)
33-
34-
3530
def _values_from_predictions(predictions) -> list:
3631
"""Pull the numeric output list out of either signature-keyed or raw rows."""
3732
assert predictions and isinstance(predictions, list)
@@ -42,16 +37,21 @@ def _values_from_predictions(predictions) -> list:
4237

4338

4439
def test_mme_two_models(
45-
sagemaker_client,
46-
sagemaker_runtime_client,
40+
boto_session,
41+
sagemaker_session,
4742
sagemaker_role_arn,
4843
inference_image_uri,
49-
default_bucket,
50-
upload_to_s3,
5144
unique_name,
5245
cleanup_endpoint,
53-
wait_for_endpoint,
5446
):
47+
from sagemaker.core.resources import (
48+
ContainerDefinition,
49+
Endpoint,
50+
EndpointConfig,
51+
Model,
52+
ProductionVariant,
53+
)
54+
5555
with tempfile.TemporaryDirectory(prefix="tf220-mme-") as workdir:
5656
workdir_path = Path(workdir)
5757

@@ -66,73 +66,78 @@ def test_mme_two_models(
6666
output_dir=model2_dir, multiplier=3.0, model_name="model", tar_filename="model2.tar.gz"
6767
)
6868

69+
bucket = sagemaker_session.default_bucket()
6970
run_id = unique_name("mme")
7071
s3_key_prefix = f"tf220-inference-tests/mme-models/{run_id}"
7172

7273
# Upload each tarball under the shared MME prefix so the runtime can
73-
# resolve TargetModel relative to the same S3 location.
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}/"
74+
# resolve target_model relative to the same S3 location.
75+
sagemaker_session.upload_data(path=model1_tar, bucket=bucket, key_prefix=s3_key_prefix)
76+
sagemaker_session.upload_data(path=model2_tar, bucket=bucket, key_prefix=s3_key_prefix)
77+
s3_model_prefix = f"s3://{bucket}/{s3_key_prefix}/"
7778

7879
endpoint_name = unique_name("tf220-mme")
79-
base_model_name = unique_name("tf220-mme-model")
80-
cleanup_endpoint(endpoint_name, model_name=base_model_name)
81-
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-
},
80+
model_name = unique_name("tf220-mme-model")
81+
cleanup_endpoint(endpoint_name, model_name=model_name)
82+
83+
# 1. Create a multi-model SageMaker Model. The MME contract is
84+
# expressed at the container definition level: mode="MultiModel"
85+
# plus an S3 *prefix* (not a single tar) in model_data_url.
86+
Model.create(
87+
model_name=model_name,
88+
primary_container=ContainerDefinition(
89+
image=inference_image_uri,
90+
mode="MultiModel",
91+
model_data_url=s3_model_prefix,
92+
),
93+
execution_role_arn=sagemaker_role_arn,
94+
session=boto_session,
9395
)
9496

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-
}
97+
# 2. Endpoint config + endpoint — same shape as single-model.
98+
EndpointConfig.create(
99+
endpoint_config_name=endpoint_name,
100+
production_variants=[
101+
ProductionVariant(
102+
variant_name="AllTraffic",
103+
model_name=model_name,
104+
initial_instance_count=1,
105+
instance_type=INSTANCE_TYPE,
106+
),
105107
],
108+
session=boto_session,
106109
)
107110

108-
sagemaker_client.create_endpoint(
109-
EndpointName=endpoint_name,
110-
EndpointConfigName=endpoint_name,
111+
endpoint = Endpoint.create(
112+
endpoint_name=endpoint_name,
113+
endpoint_config_name=endpoint_name,
114+
session=boto_session,
111115
)
112-
wait_for_endpoint(endpoint_name)
116+
endpoint.wait_for_status("InService")
113117

114118
payload = json.dumps({"instances": [[1.0, 2.0, 3.0]]})
115119

116-
# Invoke model1 (x * 2.0)
117-
resp1 = sagemaker_runtime_client.invoke_endpoint(
118-
EndpointName=endpoint_name,
119-
ContentType="application/json",
120-
TargetModel="model1.tar.gz",
121-
Body=payload,
120+
# 3. Invoke each model by name. ``target_model`` maps to the
121+
# X-Amzn-SageMaker-Target-Model header that selects the tarball
122+
# within the MME's S3 prefix.
123+
resp1 = endpoint.invoke(
124+
body=payload,
125+
content_type="application/json",
126+
accept="application/json",
127+
target_model="model1.tar.gz",
122128
)
123-
body1 = _decode(resp1)
129+
body1 = json.loads(resp1.body.read().decode("utf-8"))
124130
assert "predictions" in body1, f"model1 response missing predictions: {body1!r}"
125131
values1 = _values_from_predictions(body1["predictions"])
126132
assert values1 == pytest.approx([2.0, 4.0, 6.0]), f"model1 got {values1!r}"
127133

128-
# Invoke model2 (x * 3.0)
129-
resp2 = sagemaker_runtime_client.invoke_endpoint(
130-
EndpointName=endpoint_name,
131-
ContentType="application/json",
132-
TargetModel="model2.tar.gz",
133-
Body=payload,
134+
resp2 = endpoint.invoke(
135+
body=payload,
136+
content_type="application/json",
137+
accept="application/json",
138+
target_model="model2.tar.gz",
134139
)
135-
body2 = _decode(resp2)
140+
body2 = json.loads(resp2.body.read().decode("utf-8"))
136141
assert "predictions" in body2, f"model2 response missing predictions: {body2!r}"
137142
values2 = _values_from_predictions(body2["predictions"])
138143
assert values2 == pytest.approx([3.0, 6.0, 9.0]), f"model2 got {values2!r}"

0 commit comments

Comments
 (0)