Repository: getmoto/moto (python)

Issue:
SageMaker Search method for Model Groups not working with `Contains` filter
# Environment Information

Python Version: 3.8.12
- boto3 == 1.24.4
- pytest == 8.3.2
- moto == 5.0.13


# Documentation Reference
According to the [latest moto documentation](https://docs.getmoto.org/en/latest/docs/services/sagemaker.html) for sagemaker implemented services, the following methods are implemented:

- [X] create_model_package_group
- [X] list_model_package_groups
- [X] search

# Problem Description
I am currently using moto test the retrieval of model package groups based on certain tags. I created some model groups for testing, but when calling the method sagemaker_client.search with a `contains`name filter all the model groups were returned without any additional filter. I also called the list_model_groups method to show all the model groups that were previously created, for comparison.

## Minimal Example

**search_model_group.py**

```python
import boto3

class ModelGroupFinder:
    def __init__(self):
        self.sm_client = boto3.client('sagemaker')
    
    class ModelGroupFinder:
    def __init__(self):
        self.sm_client = boto3.client('sagemaker')
    
    def get_model_groups_with_search_method(self):
        search_results = self.sm_client.search(
            Resource='ModelPackageGroup',
            SearchExpression={
                'Filters': [{
                    'Name': 'ModelPackageGroupName', 
                    'Operator': 'Contains', 
                    'Value': '_DEV'}]}
        )
        return search_results

    def get_model_groups_with_list_model_groups_method(self):
        list_model_results = self.sm_client.list_model_package_groups()
        return list_model_results
```


**test_model_groups.py**
```python
import boto3
from moto import mock_aws
from .search_model_group import ModelGroupFinder
from pprint import pprint

MOCK_SOURCE_ROLE_ARN = 'arn:aws:iam::123456789012:role/DSSourceRole'


def mock_model_groups():
    sm_client = boto3.client('sagemaker')
    sm_client.create_model_package_group(
        ModelPackageGroupName='model-test_DEV',
        ModelPackageGroupDescription='Model group in DEV environment.',
        Tags=[
            {
                'Key': 'RegisteredBySourceRoleArn',
                'Value': MOCK_SOURCE_ROLE_ARN
            }
        ]
    )
    sm_client.create_model_package_group(
        ModelPackageGroupName='model-test_PROD',
        ModelPackageGroupDescription='Model group in PROD environment.',
        Tags=[
            {
                'Key': 'RegisteredBySourceRoleArn',
                'Value': MOCK_SOURCE_ROLE_ARN
            }
        ]
    )
    sm_client.create_model_package_group(
        ModelPackageGroupName='model-test2_DEV',
        ModelPackageGroupDescription='Model group in DEV environment.',
        Tags=[
            {
                'Key': 'RegisteredBySourceRoleArn',
                'Value': 'arn:aws:iam::123456789012:role/OtherSourceRole'
            }
        ]
    )

@mock_aws
def test_search_function():
    mock_model_groups()
    model_group_finder = ModelGroupFinder()
    model_groups = model_group_finder.get_model_groups_with_search_method()
    print('Sagemaker Search Function: ')
    pprint(model_groups)


@mock_aws
def test_list_function():
    mock_model_groups()
    model_group_finder = ModelGroupFinder()
    model_groups = model_group_finder.get_model_groups_with_list_model_groups_method()
    print('List Model Package Group Function')
    pprint(model_groups)
```

When running `pytest -s test_model_groups.py` the following print statements are shown in the console:

## Sagemaker Search Function:

```
example/test_model_groups.py Sagemaker Search Function: 
{'ResponseMetadata': {'HTTPHeaders': {'date': 'Tue, 20 Aug 2024 10:55:16 GMT',
                                      'server': 'amazon.com',
                                      'x-amzn-requestid': 'ANTahPltMIovDSarJhsDo4KQcfSn4cZr27RrV2vUW5PemHos0ylv'},
                      'HTTPStatusCode': 200,
                      'RequestId': 'ANTahPltMIovDSarJhsDo4KQcfSn4cZr27RrV2vUW5PemHos0ylv',
                      'RetryAttempts': 0},
 'Results': [{'ModelPackageGroup': {'CreationTime': datetime.datetime(2024, 8, 20, 13, 55, 16),
                                    'ModelPackageGroupArn': 'arn:aws:sagemaker:us-east-1:123456789012:model-package-group/model-test_DEV',
                                    'ModelPackageGroupDescription': 'Model '
                                                                    'group in '
                                                                    'DEV '
                                                                    'environment.',
                                    'ModelPackageGroupName': 'model-test_DEV',
                                    'ModelPackageGroupStatus': 'Completed',
                                    'Tags': [{'Key': 'RegisteredBySourceRoleArn',
                                              'Value': 'arn:aws:iam::123456789012:role/DSSourceRole'}]}},
             {'ModelPackageGroup': {'CreationTime': datetime.datetime(2024, 8, 20, 13, 55, 16),
                                    'ModelPackageGroupArn': 'arn:aws:sagemaker:us-east-1:123456789012:model-package-group/model-test_PROD',
                                    'ModelPackageGroupDescription': 'Model '
                                                                    'group in '
                                                                    'PROD '
                                                                    'environment.',
                                    'ModelPackageGroupName': 'model-test_PROD',
                                    'ModelPackageGroupStatus': 'Completed',
                                    'Tags': [{'Key': 'RegisteredBySourceRoleArn',
                                              'Value': 'arn:aws:iam::123456789012:role/DSSourceRole'}]}},
             {'ModelPackageGroup': {'CreationTime': datetime.datetime(2024, 8, 20, 13, 55, 16),
                                    'ModelPackageGroupArn': 'arn:aws:sagemaker:us-east-1:123456789012:model-package-group/model',
                                    'ModelPackageGroupDescription': 'Model '
                                                                    'group in '
                                                                    'other '
                                                                    'environment.',
                                    'ModelPackageGroupName': 'model',
                                    'ModelPackageGroupStatus': 'Completed',
                                    'Tags': [{'Key': 'RegisteredBySourceRoleArn',
                                              'Value': 'arn:aws:iam::123456789012:role/OtherSourceRole'}]}}]}
```

## Sagemaker List Model Package Groups function

```
{'ModelPackageGroupSummaryList': [{'CreationTime': datetime.datetime(2024, 8, 20, 13, 55, 16, 150531, tzinfo=tzutc()),
                                   'ModelPackageGroupArn': 'arn:aws:sagemaker:us-east-1:123456789012:model-package-group/model-test_DEV',
                                   'ModelPackageGroupDescription': 'Model '
                                                                   'group in '
                                                                   'DEV '
                                                                   'environment.',
                                   'ModelPackageGroupName': 'model-test_DEV',
                                   'ModelPackageGroupStatus': 'Completed'},
                                  {'CreationTime': datetime.datetime(2024, 8, 20, 13, 55, 16, 151405, tzinfo=tzutc()),
                                   'ModelPackageGroupArn': 'arn:aws:sagemaker:us-east-1:123456789012:model-package-group/model-test_PROD',
                                   'ModelPackageGroupDescription': 'Model '
                                                                   'group in '
                                                                   'PROD '
                                                                   'environment.',
                                   'ModelPackageGroupName': 'model-test_PROD',
                                   'ModelPackageGroupStatus': 'Completed'},
                                  {'CreationTime': datetime.datetime(2024, 8, 20, 13, 55, 16, 152038, tzinfo=tzutc()),
                                   'ModelPackageGroupArn': 'arn:aws:sagemaker:us-east-1:123456789012:model-package-group/model',
                                   'ModelPackageGroupDescription': 'Model '
                                                                   'group in '
                                                                   'other '
                                                                   'environment.',
                                   'ModelPackageGroupName': 'model',
                                   'ModelPackageGroupStatus': 'Completed'}],
 'ResponseMetadata': {'HTTPHeaders': {'date': 'Tue, 20 Aug 2024 10:55:16 GMT',
                                      'server': 'amazon.com',
                                      'x-amzn-requestid': 'BHGbElTsvY73Gv9RMp0Orw7O90dE2PieQAgjcL2wFofdOmu3OlRx'},
                      'HTTPStatusCode': 200,
                      'RequestId': 'BHGbElTsvY73Gv9RMp0Orw7O90dE2PieQAgjcL2wFofdOmu3OlRx',
                      'RetryAttempts': 0}}
```


As seen above the sm_client.search method is not working as expected. It is returning all the model groups instead of only filtering the model groups names containing the substring '_DEV'.

Pull request:
SageMaker: search() now supports the CONTAINS filter
Fixes #8010

Patch:
diff --git a/moto/sagemaker/models.py b/moto/sagemaker/models.py
index 57a09c537..c354010d3 100644
--- a/moto/sagemaker/models.py
+++ b/moto/sagemaker/models.py
@@ -11,6 +11,7 @@ from dateutil.tz import tzutc
 
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel, CloudFormationModel
+from moto.core.utils import camelcase_to_underscores
 from moto.sagemaker import validators
 from moto.utilities.paginator import paginate
 from moto.utilities.utils import ARN_PARTITION_REGEX, get_partition
@@ -3055,6 +3056,9 @@ class SageMakerModelBackend(BaseBackend):
         return list(self.experiments.values())
 
     def search(self, resource: Any = None, search_expression: Any = None) -> Any:
+        """
+        Only a few SearchExpressions are implemented. Please open a bug report if you find any issues.
+        """
         next_index = None
 
         valid_resources = [
@@ -3078,6 +3082,14 @@ class SageMakerModelBackend(BaseBackend):
                 f"An error occurred (ValidationException) when calling the Search operation: 1 validation error detected: Value '{resource}' at 'resource' failed to satisfy constraint: Member must satisfy enum value set: {valid_resources}"
             )
 
+        def compare_value(actual: Any, expected: Any, operator: str) -> bool:
+            # Defeault:  operator == "Equals"
+            if operator == "Contains":
+                return expected in actual
+            if operator == "NotEquals":
+                return expected != actual
+            return actual == expected
+
         def evaluate_search_expression(item: Any) -> bool:
             filters = None
             if search_expression is not None:
@@ -3085,43 +3097,39 @@ class SageMakerModelBackend(BaseBackend):
 
             if filters is not None:
                 for f in filters:
-                    if f["Operator"] == "Equals":
-                        if f["Name"].startswith("Tags."):
-                            key = f["Name"][5:]
-                            value = f["Value"]
-
-                            if (
-                                len(
-                                    [
-                                        e
-                                        for e in item.tags
-                                        if e["Key"] == key and e["Value"] == value
-                                    ]
-                                )
-                                == 0
-                            ):
-                                return False
-                        if f["Name"] == "ExperimentName":
-                            experiment_name = f["Value"]
-
-                            if hasattr(item, "experiment_name"):
-                                if getattr(item, "experiment_name") != experiment_name:
-                                    return False
-                            else:
-                                raise ValidationError(
-                                    message="Unknown property name: ExperimentName"
-                                )
-
-                        if f["Name"] == "TrialName":
-                            raise AWSValidationException(
-                                f"An error occurred (ValidationException) when calling the Search operation: Unknown property name: {f['Name']}"
-                            )
-
-                        if f["Name"] == "Parents.TrialName":
-                            trial_name = f["Value"]
-
-                            if getattr(item, "trial_name") != trial_name:
+                    prop_key = camelcase_to_underscores(f["Name"])
+                    if f["Name"].startswith("Tags."):
+                        key = f["Name"][5:]
+                        value = f["Value"]
+
+                        if f["Operator"] == "Equals":
+                            if not [
+                                e
+                                for e in item.tags
+                                if e["Key"] == key and e["Value"] == value
+                            ]:
                                 return False
+                        return True
+
+                    elif f["Name"] == "TrialName":
+                        raise AWSValidationException(
+                            f"An error occurred (ValidationException) when calling the Search operation: Unknown property name: {f['Name']}"
+                        )
+
+                    elif f["Name"] == "Parents.TrialName":
+                        trial_name = f["Value"]
+                        if getattr(item, "trial_name") != trial_name:
+                            return False
+
+                    elif hasattr(item, prop_key):
+                        if not compare_value(
+                            getattr(item, prop_key), f["Value"], f["Operator"]
+                        ):
+                            return False
+                    else:
+                        raise ValidationError(
+                            message=f"Unknown property name: {f['Name']}"
+                        )
 
             return True
 


Test patch:
diff --git a/tests/test_sagemaker/test_sagemaker_search.py b/tests/test_sagemaker/test_sagemaker_search.py
index 6a4fef741..615227a20 100644
--- a/tests/test_sagemaker/test_sagemaker_search.py
+++ b/tests/test_sagemaker/test_sagemaker_search.py
@@ -100,6 +100,61 @@ def test_search_trial_component_with_experiment_name(sagemaker_client):
     assert ex.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
 
 
+def test_search_experiments_with_filter(sagemaker_client):
+    experiment_name = "experiment_name"
+    trial_component_name = "trial_component_name"
+    _set_up_trial_component(
+        sagemaker_client,
+        experiment_name=experiment_name,
+        trial_component_name=trial_component_name,
+    )
+
+    _verify_search_results(
+        {"Name": "ExperimentName", "Operator": "Contains", "Value": "_DEV"}, 0
+    )
+    _verify_search_results(
+        {"Name": "ExperimentName", "Operator": "Contains", "Value": "_name"}, 1
+    )
+    _verify_search_results(
+        {"Name": "ExperimentName", "Operator": "Equals", "Value": "_name"}, 0
+    )
+    _verify_search_results(
+        {"Name": "ExperimentName", "Operator": "Equals", "Value": experiment_name}, 1
+    )
+    _verify_search_results(
+        {"Name": "ExperimentName", "Operator": "NotEquals", "Value": experiment_name}, 0
+    )
+    _verify_search_results(
+        {"Name": "ExperimentName", "Operator": "NotEquals", "Value": "else"}, 1
+    )
+
+
+def test_search_model_package_groups_with_filter(sagemaker_client):
+    sagemaker_client.create_model_package_group(
+        ModelPackageGroupName="mpg_DEV",
+        ModelPackageGroupDescription="test-model-package-group-description",
+    )
+
+    _verify_search_results(
+        {"Name": "ModelPackageGroupName", "Operator": "Contains", "Value": "_DEV"},
+        1,
+        resource="ModelPackageGroup",
+    )
+    _verify_search_results(
+        {"Name": "ModelPackageGroupName", "Operator": "Contains", "Value": "_PROD"},
+        0,
+        resource="ModelPackageGroup",
+    )
+
+
+def _verify_search_results(_filter, nr_of_results, resource="Experiment"):
+    sagemaker_client = boto3.client("sagemaker", region_name=TEST_REGION_NAME)
+    results = sagemaker_client.search(
+        Resource=resource, SearchExpression={"Filters": [_filter]}
+    )["Results"]
+    assert len(results) == nr_of_results
+
+
 def _set_up_trial_component(
     sagemaker_client,
     experiment_name="some-experiment-name",