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
1014Fixtures 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" )
4852def 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
0 commit comments