Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions sagemaker-serve/tests/unit/test_model_builder_coverage_boost.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from dataclasses import dataclass
import tempfile

import pytest

from sagemaker.serve.model_builder import ModelBuilder
from sagemaker.serve.mode.function_pointers import Mode
from sagemaker.serve.utils.types import ModelServer
Expand All @@ -16,6 +18,26 @@
from sagemaker.core.inference_config import AsyncInferenceConfig
from botocore.exceptions import ClientError

TEST_ROLE_ARN = "arn:aws:iam::123456789012:role/SageMakerRole"


@pytest.fixture(autouse=True)
def stub_role_resolution():
"""Keep ModelBuilder construction offline.

``ModelBuilder.__post_init__`` auto-resolves a serving role when no
``role_arn`` is given, which calls sts:GetCallerIdentity and the paginated
iam:SimulatePrincipalPolicy. Tests here construct ``ModelBuilder`` without a
role, so unpatched they issue live IAM calls and fail on throttling
(SimulatePrincipalPolicy "Rate exceeded") rather than on the behavior under
test. Tests that patch the resolver themselves still override this.
"""
with patch(
"sagemaker.serve.model_builder.resolve_and_validate_role",
side_effect=lambda provided_role=None, **kwargs: provided_role or TEST_ROLE_ARN,
):
yield


class TestModelBuilderInit(unittest.TestCase):
"""Test ModelBuilder initialization."""
Expand Down
29 changes: 27 additions & 2 deletions sagemaker-train/tests/integ/train/test_inspect_ai_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,31 @@
EVALUATION_TIMEOUT_SECONDS = 7200 # 2 hours
POLL_INTERVAL_SECONDS = 30

# SageMaker's message when UpdatePipeline loses an optimistic-concurrency race.
_PIPELINE_CONFLICT_MESSAGE = "has been modified since your last read"


def _evaluate_or_skip_on_pipeline_conflict(evaluator):
"""Start an evaluation, skipping if a concurrent evaluation won the pipeline race.

All evaluations of one eval type share a single SageMaker pipeline, so two
evaluations starting at the same time both call UpdatePipeline and SageMaker
rejects the loser with a conflict. ``evaluate()`` reports that as a Failed
execution with no ARN. The tests in this module run on separate xdist workers
and can overlap, so hitting it says nothing about the code under test --
give up on this pipeline update and move on rather than failing.
"""
execution = evaluator.evaluate()

failure_reason = getattr(execution.status, "failure_reason", None) or ""
if execution.arn is None and _PIPELINE_CONFLICT_MESSAGE in failure_reason:
pytest.skip(
f"A concurrent evaluation modified the shared evaluation pipeline: "
f"{failure_reason}"
)

return execution


def _prefix_has_content(s3_client, bucket_name: str, prefix: str) -> bool:
"""Check if an S3 prefix has any objects."""
Expand Down Expand Up @@ -136,7 +161,7 @@ def test_inspect_ai_bedrock_evaluation(
)

logger.info("Starting InspectAI evaluation with Bedrock inference...")
execution = evaluator.evaluate()
execution = _evaluate_or_skip_on_pipeline_conflict(evaluator)

assert execution is not None
assert execution.arn is not None
Expand Down Expand Up @@ -200,7 +225,7 @@ def test_inspect_ai_upload_benchmarks(
)

logger.info("Starting evaluation with pre-existing benchmarks...")
execution = evaluator2.evaluate()
execution = _evaluate_or_skip_on_pipeline_conflict(evaluator2)

assert execution is not None
assert execution.arn is not None
Expand Down
19 changes: 18 additions & 1 deletion sagemaker-train/tests/unit/ai_registry/test_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,27 @@ def mock_exists(path):
assert document['DatasetS3Prefix'] == 'path/to/dataset.jsonl'
assert document['DatasetS3Bucket'] == 'test-bucket'

@patch('sagemaker.ai_registry.air_hub_entity.AIRHub.get_hub_name', return_value="test-hub")
@patch('sagemaker.ai_registry.dataset._get_default_bucket', return_value="test-bucket")
@patch('sagemaker.train.defaults.TrainDefaults.get_role', return_value="arn:aws:iam::123456789012:role/SageMakerRole")
@patch('sagemaker.train.defaults.TrainDefaults.get_sagemaker_session')
@patch('sagemaker.ai_registry.dataset._get_current_domain_id', return_value=None)
@patch('sagemaker.ai_registry.dataset.Session')
@patch('sagemaker.ai_registry.dataset.DataSet._validate_dataset_file')
@patch('sagemaker.ai_registry.dataset.DataSet._validate_dataset_format')
@patch('sagemaker.ai_registry.dataset.AIRHub')
def test_create_with_local_file(self, mock_air_hub, mock_validate_format, mock_validate_file):
def test_create_with_local_file(
self,
mock_air_hub,
mock_validate_format,
mock_validate_file,
mock_session,
mock_get_domain_id,
mock_get_session,
mock_get_role,
mock_default_bucket,
mock_get_hub_name,
):
mock_air_hub.upload_to_s3.return_value = "s3://bucket/path"
mock_air_hub.import_hub_content.return_value = {"HubContentArn": "test-arn"}
mock_air_hub.describe_hub_content.return_value = {
Expand Down
18 changes: 17 additions & 1 deletion sagemaker-train/tests/unit/ai_registry/test_dataset_domain_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,23 @@ def sample_dataset_file():

class TestDataSetDomainId:
"""Test domain-id is added to SearchKeywords when available."""


@pytest.fixture(autouse=True)
def stub_account_lookups(self):
"""Keep these unit tests offline.

Uploading a local dataset derives the default bucket, and constructing the entity
derives the hub name — both call STS ``GetCallerIdentity``. ``AIRHub`` is patched on
the base-entity module because ``AIRHubEntity.__init__`` resolves the hub name
through its own import, not the one patched on ``dataset``.
"""
with patch(
'sagemaker.ai_registry.dataset._get_default_bucket', return_value='test-bucket'
), patch(
'sagemaker.ai_registry.air_hub_entity.AIRHub.get_hub_name', return_value='test-hub'
):
yield

@patch('sagemaker.core.helper.session_helper.Session')
@patch('sagemaker.ai_registry.dataset._get_current_domain_id')
@patch('sagemaker.ai_registry.dataset.AIRHub')
Expand Down
33 changes: 32 additions & 1 deletion sagemaker-train/tests/unit/ai_registry/test_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,43 @@
)


DUMMY_ROLE = "arn:aws:iam::123456789012:role/SageMakerRole"


def _keywords_from_import_call(mock_air_hub):
"""Extract the SearchKeyword strings passed to import_hub_content."""
tags = mock_air_hub.import_hub_content.call_args.kwargs["tags"]
return [f"{tag[0]}:{tag[1]}" for tag in tags]


class TestEvaluator:
@pytest.fixture(autouse=True)
def stub_aws_resolution(self):
"""Keep these unit tests offline.

``Evaluator.create`` builds a default ``Session``, auto-detects the Studio domain
ID (STS ``GetCallerIdentity``) and resolves/validates an execution role (IAM
``SimulatePrincipalPolicy``); constructing the entity also derives the hub name
from the caller's account. Unmocked, those reach real AWS and make the suite slow
and flaky — CI hit ``Throttling: Rate exceeded`` on ``SimulatePrincipalPolicy``.

``AIRHub`` is patched on the base-entity module as well as on ``evaluator``, since
``AIRHubEntity.__init__`` resolves the hub name through its own import.
"""
session = MagicMock()
with patch("sagemaker.ai_registry.evaluator.Session", return_value=session), patch(
"sagemaker.ai_registry.evaluator._get_current_domain_id", return_value=None
), patch(
"sagemaker.train.defaults.TrainDefaults.get_sagemaker_session",
return_value=session,
), patch(
"sagemaker.train.defaults.TrainDefaults.get_role", return_value=DUMMY_ROLE
), patch(
"sagemaker.ai_registry.air_hub_entity.AIRHub.get_hub_name",
return_value="test-hub",
):
yield

@patch('sagemaker.ai_registry.evaluator.AIRHub')
def test_create_with_lambda_arn(self, mock_air_hub):
mock_air_hub.import_hub_content.return_value = {"HubContentArn": "test-arn"}
Expand All @@ -52,9 +82,10 @@ def test_create_with_lambda_arn(self, mock_air_hub):
assert evaluator.method == EvaluatorMethod.LAMBDA
mock_air_hub.import_hub_content.assert_called_once()

@patch('sagemaker.ai_registry.evaluator._get_default_bucket', return_value="test-bucket")
@patch('sagemaker.ai_registry.evaluator.boto3')
@patch('sagemaker.ai_registry.evaluator.AIRHub')
def test_create_with_byoc(self, mock_air_hub, mock_boto3):
def test_create_with_byoc(self, mock_air_hub, mock_boto3, mock_default_bucket):
mock_lambda_client = MagicMock()
mock_boto3.client.return_value = mock_lambda_client
mock_lambda_client.create_function.return_value = {"FunctionArn": "lambda-arn"}
Expand Down
36 changes: 26 additions & 10 deletions sagemaker-train/tests/unit/ai_registry/test_evaluator_domain_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,38 @@

class TestEvaluatorDomainId:
"""Test domain-id is added to SearchKeywords when available."""

@patch('sagemaker.core.helper.session_helper.Session')

@pytest.fixture(autouse=True)
def stub_aws_resolution(self):
"""Keep these unit tests offline.

``Evaluator.create`` builds a default ``Session``, resolves/validates an execution
role via IAM ``SimulatePrincipalPolicy``, and derives the hub name from the
caller's account. Unmocked, those reach real AWS and made CI fail with
``Throttling: Rate exceeded``.
"""
session = Mock()
with patch("sagemaker.ai_registry.evaluator.Session", return_value=session), patch(
"sagemaker.train.defaults.TrainDefaults.get_sagemaker_session",
return_value=session,
), patch(
"sagemaker.train.defaults.TrainDefaults.get_role",
return_value="arn:aws:iam::123456789012:role/test-role",
), patch(
"sagemaker.ai_registry.air_hub_entity.AIRHub.get_hub_name",
return_value="test-hub",
):
yield

@patch('sagemaker.ai_registry.evaluator._get_current_domain_id')
@patch('sagemaker.ai_registry.evaluator.AIRHub')
def test_domain_id_added_when_available(
self, mock_air_hub, mock_get_domain_id, mock_session
self, mock_air_hub, mock_get_domain_id
):
"""Test that domain-id is added to tags when available."""
# Setup mocks
mock_domain_id = "d-test123456"
mock_get_domain_id.return_value = mock_domain_id
mock_session.return_value = Mock()

# Mock AIRHub methods
mock_air_hub.import_hub_content = Mock()
Expand Down Expand Up @@ -62,16 +82,14 @@ def test_domain_id_added_when_available(
# Verify domain-id is in tags
assert any(tag[0] == '@domain' and tag[1] == mock_domain_id for tag in tags)

@patch('sagemaker.core.helper.session_helper.Session')
@patch('sagemaker.ai_registry.evaluator._get_current_domain_id')
@patch('sagemaker.ai_registry.evaluator.AIRHub')
def test_domain_id_not_added_when_unavailable(
self, mock_air_hub, mock_get_domain_id, mock_session
self, mock_air_hub, mock_get_domain_id
):
"""Test that domain-id is not added when unavailable (non-Studio)."""
# Setup mocks - domain_id returns None
mock_get_domain_id.return_value = None
mock_session.return_value = Mock()

# Mock AIRHub methods
mock_air_hub.import_hub_content = Mock()
Expand Down Expand Up @@ -104,18 +122,16 @@ def test_domain_id_not_added_when_unavailable(
# Verify domain-id is NOT in tags
assert not any(tag[0] == '@domain' for tag in tags)

@patch('sagemaker.core.helper.session_helper.Session')
@patch('sagemaker.ai_registry.evaluator._get_current_domain_id')
@patch('sagemaker.ai_registry.evaluator.AIRHub')
def test_explicit_domain_id_used_without_auto_detection(
self, mock_air_hub, mock_get_domain_id, mock_session
self, mock_air_hub, mock_get_domain_id
):
"""An explicit domain_id is tagged and auto-detection is not invoked.

Covers the non-Studio case (P467494019) where the domain cannot be inferred and
must be supplied by the caller.
"""
mock_session.return_value = Mock()
mock_air_hub.import_hub_content = Mock()
mock_air_hub.describe_hub_content = Mock(return_value={
'HubContentName': 'test-evaluator',
Expand Down
Loading