Repository: firebase/firebase-functions-python

Issue:
Log incorrectly prints [Circular] for object property when it shares reference with another property in the same object
Same problem was seen in https://github.com/firebase/firebase-functions/issues/1510 for JS/TS

### Test case

```python
from firebase_functions import https_fn
from firebase_functions import logger

@https_fn.on_request()
def on_request_example(req: https_fn.Request) -> https_fn.Response:
    common_data = {"foo": "bar"}
    obj1 = {
        "data": common_data,
    }
    obj2 = {
        "data": common_data,
    }
    logger.debug("TEST 1", {"obj1": obj1, "obj2": obj2})
    return https_fn.Response("Oki doki")
```

### Steps to reproduce

- Call the function
- View the logs

### Expected behavior

When calling the function (e.g. locally) it should log with the json payload:
```json
{
  "obj1": { "data": { "foo": "bar" } },
  "obj2": { "data": { "foo": "bar" } },
  "severity": "DEBUG",
  "message": "TEST 1"
}

```

### Actual behavior

Instead it prints the following payload:
```json
{
  "obj1": { "data": { "foo": "bar" } },
  "obj2": { "data": "[Circular]" },
  "severity": "DEBUG",
  "message": "TEST 1"
}

```

### Were you able to successfully deploy your functions?

Yes, I had the same issue when viewing the logs in the log explorer.

Discussion:

Solution patch:
diff --git a/src/firebase_functions/logger.py b/src/firebase_functions/logger.py
index 63ec8de..62562bb 100644
--- a/src/firebase_functions/logger.py
+++ b/src/firebase_functions/logger.py
@@ -75,21 +75,32 @@ def _remove_circular(obj: _typing.Any,
     if refs is None:
         refs = set()
 
+    # Check if the object is already in the current recursion stack
     if id(obj) in refs:
         return "[CIRCULAR]"
 
+    # For non-primitive objects, add the current object's id to the recursion stack
     if not isinstance(obj, (str, int, float, bool, type(None))):
         refs.add(id(obj))
 
+    # Recursively process the object based on its type
+    result: _typing.Any
     if isinstance(obj, dict):
-        return {key: _remove_circular(value, refs) for key, value in obj.items()}
+        result = {
+            key: _remove_circular(value, refs) for key, value in obj.items()
+        }
     elif isinstance(obj, list):
-        return [_remove_circular(value, refs) for _, value in enumerate(obj)]
+        result = [_remove_circular(item, refs) for item in obj]
     elif isinstance(obj, tuple):
-        return tuple(
-            _remove_circular(value, refs) for _, value in enumerate(obj))
+        result = tuple(_remove_circular(item, refs) for item in obj)
     else:
-        return obj
+        result = obj
+
+    # Remove the object's id from the recursion stack after processing
+    if not isinstance(obj, (str, int, float, bool, type(None))):
+        refs.remove(id(obj))
+
+    return result
 
 
 def _get_write_file(severity: LogSeverity) -> _typing.TextIO:


Test patch:
diff --git a/tests/test_logger.py b/tests/test_logger.py
index 721898a..4595429 100644
--- a/tests/test_logger.py
+++ b/tests/test_logger.py
@@ -1,3 +1,4 @@
+# mypy: ignore-errors
 """
 Logger module tests.
 """
@@ -79,3 +80,147 @@ class TestLogger:
         raw_log_output = capsys.readouterr().out
         log_output = json.loads(raw_log_output)
         assert log_output["message"] == expected_message
+
+    def test_remove_circular_references(self,
+                                        capsys: pytest.CaptureFixture[str]):
+        # Create an object with a circular reference.
+        circ = {"b": "foo"}
+        circ["circ"] = circ
+
+        entry = {
+            "severity": "ERROR",
+            "message": "testing circular",
+            "circ": circ,
+        } 
+        logger.write(entry)
+        raw_log_output = capsys.readouterr().err
+        log_output = json.loads(raw_log_output)
+
+        expected = {
+            "severity": "ERROR",
+            "message": "testing circular",
+            "circ": {
+                "b": "foo",
+                "circ": "[CIRCULAR]"
+            },
+        }
+        assert log_output == expected
+
+    def test_remove_circular_references_in_arrays(
+            self, capsys: pytest.CaptureFixture[str]):
+        # Create an object with a circular reference inside an array.
+        circ = {"b": "foo"}
+        circ["circ"] = [circ]
+
+        entry = {
+            "severity": "ERROR",
+            "message": "testing circular",
+            "circ": circ,
+        }
+        logger.write(entry)
+        raw_log_output = capsys.readouterr().err
+        log_output = json.loads(raw_log_output)
+
+        expected = {
+            "severity": "ERROR",
+            "message": "testing circular",
+            "circ": {
+                "b": "foo",
+                "circ": ["[CIRCULAR]"]
+            },
+        }
+        assert log_output == expected
+
+    def test_no_false_circular_for_duplicates(
+            self, capsys: pytest.CaptureFixture[str]):
+        # Ensure that duplicate objects (used in multiple keys) are not marked as circular.
+        obj = {"a": "foo"}
+        entry = {
+            "severity": "ERROR",
+            "message": "testing circular",
+            "a": obj,
+            "b": obj,
+        }
+        logger.write(entry)
+        raw_log_output = capsys.readouterr().err
+        log_output = json.loads(raw_log_output)
+
+        expected = {
+            "severity": "ERROR",
+            "message": "testing circular",
+            "a": {
+                "a": "foo"
+            },
+            "b": {
+                "a": "foo"
+            },
+        }
+        assert log_output == expected
+
+    def test_no_false_circular_in_array_duplicates(
+            self, capsys: pytest.CaptureFixture[str]):
+        # Ensure that duplicate objects in arrays are not falsely detected as circular.
+        obj = {"a": "foo"}
+        arr = [
+            {
+                "a": obj,
+                "b": obj
+            },
+            {
+                "a": obj,
+                "b": obj
+            },
+        ]
+        entry = {
+            "severity": "ERROR",
+            "message": "testing circular",
+            "a": arr,
+            "b": arr,
+        }
+        logger.write(entry)
+        raw_log_output = capsys.readouterr().err
+        log_output = json.loads(raw_log_output)
+
+        expected = {
+            "severity":
+                "ERROR",
+            "message":
+                "testing circular",
+            "a": [
+                {
+                    "a": {
+                        "a": "foo"
+                    },
+                    "b": {
+                        "a": "foo"
+                    }
+                },
+                {
+                    "a": {
+                        "a": "foo"
+                    },
+                    "b": {
+                        "a": "foo"
+                    }
+                },
+            ],
+            "b": [
+                {
+                    "a": {
+                        "a": "foo"
+                    },
+                    "b": {
+                        "a": "foo"
+                    }
+                },
+                {
+                    "a": {
+                        "a": "foo"
+                    },
+                    "b": {
+                        "a": "foo"
+                    }
+                },
+            ],
+        }
+        assert log_output == expected