Repository: pytest-dev/pytest
Pull request: Fix representation of tuples in approx
closes #9917 

* Refactored `_recursive_list_map` to work on all sequences, and renamed it to `recursive_sequence_map`

Behavior now works as expected. The example originally given in the issue now works as follows:
```
    def test_ab():
>       assert a == pytest.approx(b, abs=0.01)
E       assert ((1+0j), (2+0j)) == approx((1 ± 1....2 ± 1.0e-02))
E         comparison failed. Mismatched elements: 1 / 2:
E         Max absolute difference: 0.20000000000000018
E         Max relative difference: 0.10000000000000009
E         Index | Obtained | Expected     
E         1     | (2+0j)   | 2.2 ± 1.0e-02

my_test.py:7: AssertionError
```




Problem statement:
pytest.approx fails to print representation of tuples
Hi,
This looks like a bug in pytest.approx

a = (1+0j, 2+0j)
b = (1, 2.2)

def test_ab():
    assert a == pytest.approx(b, abs=0.01)

E       AssertionError: assert ((1+0j), (2+0j)) == approx((1 ± 1....2 ± 1.0e-02))
E         (pytest_assertion plugin: representation of details failed: /opt/anaconda3/envs/py310/lib/python3.10/site-packages/_pytest/python_api.py:331: TypeError: object of type 'ApproxScalar' has no len().
E          Probably an object has a faulty __repr__.)

Converting the tuples to numpy arrays helps.

(pytest 7.1.2, python 3.10.4)

Hints from issue discussion:
I was able to reproduce this bug with pytest=7.1.2 and python=3.10.4.

It seems that the intended behavior on line 329 of `_pytest/python_api.py` is for a collection of `ApproxScalar`s to be returned via mapping `self._approx_scalar` over an arbitrary depth collection of scalars.

But in this case, `_recursive_list_map` receives a tuple instead of a list and just applies `self._approx_scalar` to the tuple, not the tuple's elements:

```python
# _pytest/python_api.py line 329
approx_side_as_map = _recursive_list_map(self._approx_scalar, self.expected)  # self.expected is a tuple: (1, 2.2)
number_of_elements = len(approx_side_as_map)  # approx_side_as_map is an ApproxScalar with no __len__()
```

```python
# _pytest/python_api.py line 136
def _recursive_list_map(f, x):
    if isinstance(x, list):   # This is False, because x is a tuple
        return [_recursive_list_map(f, xi) for xi in x]
    else:
        return f(x)  # You get an ApproxScalar(tuple), not a tuple with elements converted to ApproxScalar
```

So we have `approx_side_as_map` is an `ApproxScalar`. This causes the call to `len` to fail.


I will take a shot at fixing this.

Commit message: fix (docs): Fix wording in changelog

Patch to review:
diff --git a/AUTHORS b/AUTHORS
index 1a8c5306f75..ef0b125d135 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -357,5 +357,6 @@ Yoav Caspi
 Yuval Shimon
 Zac Hatfield-Dodds
 Zachary Kneupper
+Zachary OBrien
 Zoltán Máté
 Zsolt Cserna
diff --git a/changelog/9917.bugfix.rst b/changelog/9917.bugfix.rst
new file mode 100644
index 00000000000..80866a1bb56
--- /dev/null
+++ b/changelog/9917.bugfix.rst
@@ -0,0 +1,2 @@
+String representation for `ApproxSequenceLike` was failing on tuples. The bug was fixed by
+adding support for tuples to `_recursive_list_map`. Renamed that function to: `_recursive_sequence_map`.
diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py
index 5fa21961924..443996b0e0b 100644
--- a/src/_pytest/python_api.py
+++ b/src/_pytest/python_api.py
@@ -133,9 +133,11 @@ def _check_type(self) -> None:
         # raise if there are any non-numeric elements in the sequence.
 
 
-def _recursive_list_map(f, x):
-    if isinstance(x, list):
-        return [_recursive_list_map(f, xi) for xi in x]
+def _recursive_sequence_map(f, x):
+    """Recursively map a function over a sequence of arbitary depth"""
+    seq_type = type(x)
+    if seq_type in (list, tuple):
+        return seq_type(_recursive_sequence_map(f, xi) for xi in x)
     else:
         return f(x)
 
@@ -144,7 +146,9 @@ class ApproxNumpy(ApproxBase):
     """Perform approximate comparisons where the expected value is numpy array."""
 
     def __repr__(self) -> str:
-        list_scalars = _recursive_list_map(self._approx_scalar, self.expected.tolist())
+        list_scalars = _recursive_sequence_map(
+            self._approx_scalar, self.expected.tolist()
+        )
         return f"approx({list_scalars!r})"
 
     def _repr_compare(self, other_side: "ndarray") -> List[str]:
@@ -164,7 +168,7 @@ def get_value_from_nested_list(
             return value
 
         np_array_shape = self.expected.shape
-        approx_side_as_list = _recursive_list_map(
+        approx_side_as_list = _recursive_sequence_map(
             self._approx_scalar, self.expected.tolist()
         )
 
@@ -326,7 +330,7 @@ def _repr_compare(self, other_side: Sequence[float]) -> List[str]:
                 f"Lengths: {len(self.expected)} and {len(other_side)}",
             ]
 
-        approx_side_as_map = _recursive_list_map(self._approx_scalar, self.expected)
+        approx_side_as_map = _recursive_sequence_map(self._approx_scalar, self.expected)
 
         number_of_elements = len(approx_side_as_map)
         max_abs_diff = -math.inf
diff --git a/testing/python/approx.py b/testing/python/approx.py
index 7b4fbad156e..6acb466ffb1 100644
--- a/testing/python/approx.py
+++ b/testing/python/approx.py
@@ -2,12 +2,14 @@
 from contextlib import contextmanager
 from decimal import Decimal
 from fractions import Fraction
+from math import sqrt
 from operator import eq
 from operator import ne
 from typing import Optional
 
 import pytest
 from _pytest.pytester import Pytester
+from _pytest.python_api import _recursive_sequence_map
 from pytest import approx
 
 inf, nan = float("inf"), float("nan")
@@ -133,6 +135,18 @@ def test_error_messages_native_dtypes(self, assert_approx_raises_regex):
             ],
         )
 
+        assert_approx_raises_regex(
+            (1, 2.2, 4),
+            (1, 3.2, 4),
+            [
+                r"  comparison failed. Mismatched elements: 1 / 3:",
+                rf"  Max absolute difference: {SOME_FLOAT}",
+                rf"  Max relative difference: {SOME_FLOAT}",
+                r"  Index \| Obtained\s+\| Expected   ",
+                rf"  1     \| {SOME_FLOAT} \| {SOME_FLOAT} ± {SOME_FLOAT}",
+            ],
+        )
+
         # Specific test for comparison with 0.0 (relative diff will be 'inf')
         assert_approx_raises_regex(
             [0.0],
@@ -878,3 +892,31 @@ def test_allow_ordered_sequences_only(self) -> None:
         """pytest.approx() should raise an error on unordered sequences (#9692)."""
         with pytest.raises(TypeError, match="only supports ordered sequences"):
             assert {1, 2, 3} == approx({1, 2, 3})
+
+
+class TestRecursiveSequenceMap:
+    def test_map_over_scalar(self):
+        assert _recursive_sequence_map(sqrt, 16) == 4
+
+    def test_map_over_empty_list(self):
+        assert _recursive_sequence_map(sqrt, []) == []
+
+    def test_map_over_list(self):
+        assert _recursive_sequence_map(sqrt, [4, 16, 25, 676]) == [2, 4, 5, 26]
+
+    def test_map_over_tuple(self):
+        assert _recursive_sequence_map(sqrt, (4, 16, 25, 676)) == (2, 4, 5, 26)
+
+    def test_map_over_nested_lists(self):
+        assert _recursive_sequence_map(sqrt, [4, [25, 64], [[49]]]) == [
+            2,
+            [5, 8],
+            [[7]],
+        ]
+
+    def test_map_over_mixed_sequence(self):
+        assert _recursive_sequence_map(sqrt, [4, (25, 64), [(49)]]) == [
+            2,
+            (5, 8),
+            [(7)],
+        ]


Code review comments:
diff_hunk: @@ -133,9 +133,11 @@ def _check_type(self) -> None:
         # raise if there are any non-numeric elements in the sequence.
 
 
-def _recursive_list_map(f, x):
-    if isinstance(x, list):
-        return [_recursive_list_map(f, xi) for xi in x]
+def _recursive_sequence_map(f, x):
+    """Recursively map a function over a sequence of arbitary depth"""
+    seq_type = type(x)
+    if seq_type in (list, tuple):
path: src/_pytest/python_api.py
text: @user1:
Isinstance should be used here 

@author:
fixed

diff_hunk: @@ -164,7 +168,7 @@ def get_value_from_nested_list(
             return value
 
         np_array_shape = self.expected.shape
-        approx_side_as_list = _recursive_list_map(
+        approx_side_as_list = _recursive_sequence_map(
path: src/_pytest/python_api.py
text: @user1:
```suggestion
        approx_side_as_seq = _recursive_sequence_map(
```


@author:
fixed