Repository: getmoto/moto

Issue:
cloudwatch list_metrics call fails for dimensions exist query
I'm using Moto's `@mock_cloudwatch` annotation to mock a call to the CloudWatch list_metrics API. This fails for a query which is testing certain dimensions for existence. The query works for CloudWatch itself.

Example code:
```python
def _get_metrics() -> list[dict]:
    cloudwatch = boto3.client("cloudwatch", region_name="eu-west-1")
    paginator = cloudwatch.get_paginator('list_metrics')
    iterator = paginator.paginate(
        Namespace="MyNamespace",
        MetricName="MyMetric",
        Dimensions=[
            {'Name': 'A'},
            {'Name': 'B'},
            {'Name': 'C'}
        ]
    )

    return [metric for response in iterator for metric in response["Metrics"]]
```

This code is not specifying a `Value` key/value pair for dimensions. This will filter for metrics having these dimension keys regardless of the value.

Executing this within a test function annotated with `@mock_cloudwatch` fails with the following stack trace:

```
Error
Traceback (most recent call last):
  File "/usr/local/Cellar/python@3.9/3.9.0_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/unittest/case.py", line 59, in testPartExecutor
    yield
  File "/usr/local/Cellar/python@3.9/3.9.0_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/unittest/case.py", line 593, in run
    self._callTestMethod(testMethod)
  File "/usr/local/Cellar/python@3.9/3.9.0_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/unittest/case.py", line 550, in _callTestMethod
    method()
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/core/models.py", line 100, in wrapper
    result = func(*args, **kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/core/models.py", line 100, in wrapper
    result = func(*args, **kwargs)
  File "/Users/someuser/workspace/my-project/source/test/test_main.py", line 77, in test_main_happy_path
    main(config_base_dir=get_test_config_dir())
  File "/Users/someuser/workspace/my-project/source/src/x_generator/main.py", line 40, in main
    public_api_endpoints = get_public_api_endpoints()
  File "/Users/someuser/workspace/my-project/source/src/x_generator/endpoints.py", line 74, in get_public_api_endpoints
    metrics = _get_metrics()
  File "/Users/someuser/workspace/my-project/source/src/x_generator/endpoints.py", line 21, in _get_metrics
    print([metric for response in iterator for metric in response["Metrics"]])
  File "/Users/someuser/workspace/my-project/source/src/x_generator/endpoints.py", line 21, in <listcomp>
    print([metric for response in iterator for metric in response["Metrics"]])
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/paginate.py", line 255, in __iter__
    response = self._make_request(current_kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/paginate.py", line 332, in _make_request
    return self._method(**current_kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/client.py", line 357, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/client.py", line 662, in _make_api_call
    http, parsed_response = self._make_request(
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/client.py", line 682, in _make_request
    return self._endpoint.make_request(operation_model, request_dict)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/endpoint.py", line 102, in make_request
    return self._send_request(request_dict, operation_model)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/endpoint.py", line 136, in _send_request
    while self._needs_retry(attempts, operation_model, request_dict,
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/endpoint.py", line 253, in _needs_retry
    responses = self._event_emitter.emit(
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/hooks.py", line 356, in emit
    return self._emitter.emit(aliased_event_name, **kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/hooks.py", line 228, in emit
    return self._emit(event_name, kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/hooks.py", line 211, in _emit
    response = handler(**kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/retryhandler.py", line 183, in __call__
    if self._checker(attempts, response, caught_exception):
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/retryhandler.py", line 250, in __call__
    should_retry = self._should_retry(attempt_number, response,
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/retryhandler.py", line 269, in _should_retry
    return self._checker(attempt_number, response, caught_exception)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/retryhandler.py", line 316, in __call__
    checker_response = checker(attempt_number, response,
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/retryhandler.py", line 222, in __call__
    return self._check_caught_exception(
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/retryhandler.py", line 359, in _check_caught_exception
    raise caught_exception
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/endpoint.py", line 197, in _do_get_response
    responses = self._event_emitter.emit(event_name, request=request)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/hooks.py", line 356, in emit
    return self._emitter.emit(aliased_event_name, **kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/hooks.py", line 228, in emit
    return self._emit(event_name, kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/botocore/hooks.py", line 211, in _emit
    response = handler(**kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/core/models.py", line 322, in __call__
    status, headers, body = response_callback(
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/core/responses.py", line 202, in dispatch
    return cls()._dispatch(*args, **kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/core/responses.py", line 312, in _dispatch
    return self.call_action()
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/core/responses.py", line 397, in call_action
    response = method()
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/core/utils.py", line 277, in _wrapper
    response = f(*args, **kwargs)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/cloudwatch/responses.py", line 141, in list_metrics
    next_token, metrics = self.cloudwatch_backend.list_metrics(
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/cloudwatch/models.py", line 472, in list_metrics
    metrics = self.get_filtered_metrics(metric_name, namespace, dimensions)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/cloudwatch/models.py", line 477, in get_filtered_metrics
    metrics = [
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/cloudwatch/models.py", line 480, in <listcomp>
    if md.filter(namespace=namespace, name=metric_name, dimensions=dimensions)
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/cloudwatch/models.py", line 140, in filter
    if dimensions and any(
  File "/Users/someuser/workspace/my-project/.venv/lib/python3.9/site-packages/moto/cloudwatch/models.py", line 141, in <genexpr>
    Dimension(d["Name"], d["Value"]) not in self.dimensions for d in dimensions
TypeError: string indices must be integers
```

It seems like the query parameter handling transforms the dimensions parameter's value into a list with the keys as list items if no values are given. The CloudWatch filter code is not able to handle this list but expects a dict.


Discussion:
Thanks for raising this @marco-hoyer! Marking it as an enhancement

Resolving patch:
diff --git a/moto/cloudwatch/models.py b/moto/cloudwatch/models.py
--- a/moto/cloudwatch/models.py
+++ b/moto/cloudwatch/models.py
@@ -35,7 +35,9 @@ def __init__(self, name, value):
 
     def __eq__(self, item):
         if isinstance(item, Dimension):
-            return self.name == item.name and self.value == item.value
+            return self.name == item.name and (
+                self.value is None or item.value is None or self.value == item.value
+            )
         return False
 
     def __ne__(self, item):  # Only needed on Py2; Py3 defines it implicitly
@@ -222,7 +224,8 @@ def filter(self, namespace, name, dimensions, already_present_metrics=[]):
                 return False
 
         if dimensions and any(
-            Dimension(d["Name"], d["Value"]) not in self.dimensions for d in dimensions
+            Dimension(d["Name"], d.get("Value")) not in self.dimensions
+            for d in dimensions
         ):
             return False
         return True
diff --git a/moto/cloudwatch/responses.py b/moto/cloudwatch/responses.py
--- a/moto/cloudwatch/responses.py
+++ b/moto/cloudwatch/responses.py
@@ -212,7 +212,7 @@ def get_metric_statistics(self):
     def list_metrics(self):
         namespace = self._get_param("Namespace")
         metric_name = self._get_param("MetricName")
-        dimensions = self._get_multi_param("Dimensions.member")
+        dimensions = self._get_params().get("Dimensions", [])
         next_token = self._get_param("NextToken")
         next_token, metrics = self.cloudwatch_backend.list_metrics(
             next_token, namespace, metric_name, dimensions


Test patch:
diff --git a/tests/test_cloudwatch/test_cloudwatch_boto3.py b/tests/test_cloudwatch/test_cloudwatch_boto3.py
--- a/tests/test_cloudwatch/test_cloudwatch_boto3.py
+++ b/tests/test_cloudwatch/test_cloudwatch_boto3.py
@@ -379,6 +379,25 @@ def test_list_metrics_paginated():
     )
 
 
+@mock_cloudwatch
+def test_list_metrics_without_value():
+    cloudwatch = boto3.client("cloudwatch", "eu-west-1")
+    # Create some metrics to filter on
+    create_metrics_with_dimensions(cloudwatch, namespace="MyNamespace", data_points=3)
+    # Verify we can filter by namespace/metric name
+    res = cloudwatch.list_metrics(Namespace="MyNamespace")["Metrics"]
+    res.should.have.length_of(3)
+    # Verify we can filter by Dimension without value
+    results = cloudwatch.list_metrics(
+        Namespace="MyNamespace", MetricName="MyMetric", Dimensions=[{"Name": "D1"}]
+    )["Metrics"]
+
+    results.should.have.length_of(1)
+    results[0]["Namespace"].should.equals("MyNamespace")
+    results[0]["MetricName"].should.equal("MyMetric")
+    results[0]["Dimensions"].should.equal([{"Name": "D1", "Value": "V1"}])
+
+
 def create_metrics(cloudwatch, namespace, metrics=5, data_points=5):
     for i in range(0, metrics):
         metric_name = "metric" + str(i)
@@ -389,6 +408,20 @@ def create_metrics(cloudwatch, namespace, metrics=5, data_points=5):
             )
 
 
+def create_metrics_with_dimensions(cloudwatch, namespace, data_points=5):
+    for j in range(0, data_points):
+        cloudwatch.put_metric_data(
+            Namespace=namespace,
+            MetricData=[
+                {
+                    "MetricName": "MyMetric",
+                    "Dimensions": [{"Name": f"D{j}", "Value": f"V{j}"}],
+                    "Unit": "Seconds",
+                }
+            ],
+        )
+
+
 @mock_cloudwatch
 def test_get_metric_data_within_timeframe():
     utc_now = datetime.now(tz=pytz.utc)