Repository: python-attrs/cattrs

Issue:
Python 3.11: it's  __notes__, not __note__
cattrs is using `__note__`, but it's actually `__notes__`, and a tuple of strings, and should be (python version conditionally) added via `.add_note(string)`, perhaps? (depending on the backport, I guess). `exceptiongroup` was updated in 1.0.0rc4, see https://github.com/agronholm/exceptiongroup/blob/main/CHANGES.rst.

Also, the message seems pretty poor on pytest + Python 3.10, is pytest and exceptiongroup clashing?

```pytb
      except Exception as e:
        e.__note__ = 'Structuring class Index @ attribute reply'
        errors.append(e)
>     if errors: raise __c_cve('While structuring Index', errors, __cl)
E     cattrs.errors.ClassValidationError: While structuring Index (1 sub-exception)

<cattrs generated structure scikit_build_core.file_api.model.index.Index>:19: ClassValidationError
```

(There's no more helpful err message below it, just that)

I tried running this outside of pytest, and it's much better, I can see the errors. But not inside pytest. I'm guessing it's the monkey patching warnings in https://pypi.org/project/exceptiongroup/. Maybe something to bring up with pytest? Or exceptiongroup? I think that might be https://github.com/agronholm/exceptiongroup/issues/23.

This is really cool when it works, though! (though the notes are missing, due to this issue, unless I force `exceptiongroup==1.0.0rc3`)

Discussion:
Tinche: LGTM, could you also add a fragment to the release notes?
codecov-commenter: # [Codecov](https://codecov.io/gh/python-attrs/cattrs/pull/303?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs) Report
> Merging [#303](https://codecov.io/gh/python-attrs/cattrs/pull/303?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs) (2792351) into [main](https://codecov.io/gh/python-attrs/cattrs/commit/bcaf2a7572de3582beb00f3f1ffb06007bbfda89?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs) (bcaf2a7) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

> :exclamation: Current head 2792351 differs from pull request most recent head 6b531ad. Consider uploading reports for the commit 6b531ad to get more accurate results

```diff
@@            Coverage Diff             @@
##             main     #303      +/-   ##
==========================================
+ Coverage   96.96%   96.97%   +0.01%     
==========================================
  Files          16       16              
  Lines        1382     1387       +5     
==========================================
+ Hits         1340     1345       +5     
  Misses         42       42              
```


| [Impacted Files](https://codecov.io/gh/python-attrs/cattrs/pull/303?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs) | Coverage Δ | |
|---|---|---|
| [src/cattrs/gen.py](https://codecov.io/gh/python-attrs/cattrs/pull/303/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs#diff-c3JjL2NhdHRycy9nZW4ucHk=) | `94.18% <ø> (ø)` | |
| [src/cattrs/converters.py](https://codecov.io/gh/python-attrs/cattrs/pull/303/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs#diff-c3JjL2NhdHRycy9jb252ZXJ0ZXJzLnB5) | `97.86% <100.00%> (+0.02%)` | :arrow_up: |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-attrs)

Tinche: Thanks!

Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index 4c00d1a..194a1fc 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -15,13 +15,18 @@ History
 * Fix propagating the `detailed_validation` flag to mapping and counter structuring generators.
 * Fix ``typing.Set`` applying too broadly when used with the ``GenConverter.unstruct_collection_overrides`` parameter on Python versions below 3.9. Switch to ``typing.AbstractSet`` on those versions to restore the old behavior.
   (`#264 <https://github.com/python-attrs/cattrs/issues/264>`_)
-* Uncap the required Python version, to avoid problems detailed in https://iscinumpy.dev/post/bound-version-constraints/#pinning-the-python-version-is-special (`#275 <https://github.com/python-attrs/cattrs/issues/275>`_)
+* Uncap the required Python version, to avoid problems detailed in https://iscinumpy.dev/post/bound-version-constraints/#pinning-the-python-version-is-special
+  (`#275 <https://github.com/python-attrs/cattrs/issues/275>`_)
 * Fix `Converter.register_structure_hook_factory` and `cattrs.gen.make_dict_unstructure_fn` type annotations.
   (`#281 <https://github.com/python-attrs/cattrs/issues/281>`_)
-* Expose all error classes in the `cattr.errors` namespace. Note that it is deprecated, just use `cattrs.errors`. (`#252 <https://github.com/python-attrs/cattrs/issues/252>`_)
+* Expose all error classes in the `cattr.errors` namespace. Note that it is deprecated, just use `cattrs.errors`.
+  (`#252 <https://github.com/python-attrs/cattrs/issues/252>`_)
 * ``cattrs.Converter`` and ``cattrs.BaseConverter`` can now copy themselves using the ``copy`` method.
   (`#284 <https://github.com/python-attrs/cattrs/pull/284>`_)
-* Fix generating structuring functions for types with quotes in the name. (`#291 <https://github.com/python-attrs/cattrs/issues/291>`_ `#277 <https://github.com/python-attrs/cattrs/issues/277>`_)
+* Fix generating structuring functions for types with quotes in the name.
+  (`#291 <https://github.com/python-attrs/cattrs/issues/291>`_ `#277 <https://github.com/python-attrs/cattrs/issues/277>`_)
+* Fix usage of notes for the final version of `PEP 678 <https://peps.python.org/pep-0678/>`_, supported since ``exceptiongroup>=1.0.0rc4``.
+  (`#303 <303 <https://github.com/python-attrs/cattrs/pull/303>`_)
 
 22.1.0 (2022-04-03)
 -------------------
diff --git a/docs/validation.rst b/docs/validation.rst
index a004341..721dca8 100644
--- a/docs/validation.rst
+++ b/docs/validation.rst
@@ -14,7 +14,7 @@ In essence, ExceptionGroups are trees of exceptions.
 When un/structuring a class, `cattrs` will gather any exceptions on a field-by-field basis and raise them as a ``cattrs.ClassValidationError``, which is a subclass of ``BaseValidationError``.
 When structuring sequences and mappings, `cattrs` will gather any exceptions on a key- or index-basis and raise them as a ``cattrs.IterableValidationError``, which is a subclass of ``BaseValidationError``.
 
-The exceptions will also have their ``__note__`` attributes set, as per `PEP 678`_, showing the field, key or index for each inner exception.
+The exceptions will also have their ``__notes__`` attributes set, as per `PEP 678`_, showing the field, key or index for each inner exception.
 
 A simple example involving a class containing a list and a dictionary:
 
diff --git a/src/cattrs/converters.py b/src/cattrs/converters.py
index 088bade..4d031cd 100644
--- a/src/cattrs/converters.py
+++ b/src/cattrs/converters.py
@@ -495,7 +495,8 @@ class BaseConverter:
                     try:
                         res.append(handler(e, elem_type))
                     except Exception as e:
-                        e.__note__ = f"Structuring {cl} @ index {ix}"
+                        msg = f"Structuring {cl} @ index {ix}"
+                        e.__notes__ = getattr(e, "__notes__", ()) + (msg,)
                         errors.append(e)
                     finally:
                         ix += 1
@@ -522,9 +523,8 @@ class BaseConverter:
                 try:
                     res.add(handler(e, elem_type))
                 except Exception as exc:
-                    exc.__note__ = (
-                        f"Structuring {structure_to.__name__} @ element {e!r}"
-                    )
+                    msg = f"Structuring {structure_to.__name__} @ element {e!r}"
+                    exc.__notes__ = getattr(e, "__notes__", ()) + (msg,)
                     errors.append(exc)
             if errors:
                 raise IterableValidationError(f"While structuring {cl!r}", errors, cl)
@@ -593,7 +593,8 @@ class BaseConverter:
                     try:
                         res.append(conv(e, tup_type))
                     except Exception as exc:
-                        exc.__note__ = f"Structuring {tup} @ index {ix}"
+                        msg = f"Structuring {tup} @ index {ix}"
+                        exc.__notes__ = getattr(e, "__notes__", ()) + (msg,)
                         errors.append(exc)
                 if errors:
                     raise IterableValidationError(
@@ -620,14 +621,16 @@ class BaseConverter:
                         conv = self._structure_func.dispatch(t)
                         res.append(conv(e, t))
                     except Exception as exc:
-                        exc.__note__ = f"Structuring {tup} @ index {ix}"
+                        msg = f"Structuring {tup} @ index {ix}"
+                        exc.__notes__ = getattr(e, "__notes__", ()) + (msg,)
                         errors.append(exc)
                 if len(res) < exp_len:
                     problem = "Not enough" if len(res) < len(tup_params) else "Too many"
                     exc = ValueError(
                         f"{problem} values in {obj!r} to structure as {tup!r}"
                     )
-                    exc.__note__ = f"Structuring {tup}"
+                    msg = f"Structuring {tup}"
+                    exc.__notes__ = getattr(e, "__notes__", ()) + (msg,)
                     errors.append(exc)
                 if errors:
                     raise IterableValidationError(
diff --git a/src/cattrs/gen.py b/src/cattrs/gen.py
index 597a672..a999a8d 100644
--- a/src/cattrs/gen.py
+++ b/src/cattrs/gen.py
@@ -354,7 +354,7 @@ def make_dict_structure_fn(
             lines.append(f"{i}except Exception as e:")
             i = f"{i}  "
             lines.append(
-                f"{i}e.__note__ = 'Structuring class ' + {cl.__qualname__!r} + ' @ attribute {an}'"
+                f"{i}e.__notes__ = getattr(e, '__notes__', ()) + (\"Structuring class {cl.__qualname__} @ attribute {an}\",)"
             )
             lines.append(f"{i}errors.append(e)")
 
@@ -736,7 +736,7 @@ def make_mapping_structure_fn(
             lines.append(f"      value = {v_s}")
             lines.append("    except Exception as e:")
             lines.append(
-                "      e.__note__ = 'Structuring mapping value @ key ' + repr(k)"
+                "      e.__notes__ = getattr(e, '__notes__', ()) + ('Structuring mapping value @ key ' + repr(k),)"
             )
             lines.append("      errors.append(e)")
             lines.append("      continue")
@@ -745,7 +745,7 @@ def make_mapping_structure_fn(
             lines.append("      res[key] = value")
             lines.append("    except Exception as e:")
             lines.append(
-                "      e.__note__ = 'Structuring mapping key @ key ' + repr(k)"
+                "      e.__notes__ = getattr(e, '__notes__', ()) + ('Structuring mapping key @ key ' + repr(k),)"
             )
             lines.append("      errors.append(e)")
             lines.append("  if errors:")


Test patch:
diff --git a/tests/test_validation.py b/tests/test_validation.py
index 5fceab2..d0d691d 100644
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -27,15 +27,13 @@ def test_class_validation():
     assert repr(exc.value.exceptions[0]) == repr(
         ValueError("invalid literal for int() with base 10: 'a'")
     )
-    assert (
-        exc.value.exceptions[0].__note__
-        == "Structuring class test_class_validation.<locals>.Test @ attribute a"
+    assert exc.value.exceptions[0].__notes__ == (
+        "Structuring class test_class_validation.<locals>.Test @ attribute a",
     )
 
     assert repr(exc.value.exceptions[1]) == repr(KeyError("c"))
-    assert (
-        exc.value.exceptions[1].__note__
-        == "Structuring class test_class_validation.<locals>.Test @ attribute c"
+    assert exc.value.exceptions[1].__notes__ == (
+        "Structuring class test_class_validation.<locals>.Test @ attribute c",
     )
 
 
@@ -66,12 +64,16 @@ def test_list_validation():
     assert repr(exc.value.exceptions[0]) == repr(
         ValueError("invalid literal for int() with base 10: 'a'")
     )
-    assert exc.value.exceptions[0].__note__ == "Structuring typing.List[int] @ index 2"
+    assert exc.value.exceptions[0].__notes__ == (
+        "Structuring typing.List[int] @ index 2",
+    )
 
     assert repr(exc.value.exceptions[1]) == repr(
         ValueError("invalid literal for int() with base 10: 'c'")
     )
-    assert exc.value.exceptions[1].__note__ == "Structuring typing.List[int] @ index 4"
+    assert exc.value.exceptions[1].__notes__ == (
+        "Structuring typing.List[int] @ index 4",
+    )
 
 
 @given(...)
@@ -86,12 +88,16 @@ def test_mapping_validation(detailed_validation: bool):
         assert repr(exc.value.exceptions[0]) == repr(
             ValueError("invalid literal for int() with base 10: 'b'")
         )
-        assert exc.value.exceptions[0].__note__ == "Structuring mapping value @ key '2'"
+        assert exc.value.exceptions[0].__notes__ == (
+            "Structuring mapping value @ key '2'",
+        )
 
         assert repr(exc.value.exceptions[1]) == repr(
             ValueError("invalid literal for int() with base 10: 'c'")
         )
-        assert exc.value.exceptions[1].__note__ == "Structuring mapping key @ key 'c'"
+        assert exc.value.exceptions[1].__notes__ == (
+            "Structuring mapping key @ key 'c'",
+        )
     else:
         with pytest.raises(ValueError):
             c.structure({"1": 1, "2": "b", "c": 3}, Dict[int, int])
@@ -109,7 +115,9 @@ def test_counter_validation(detailed_validation: bool):
         assert repr(exc.value.exceptions[0]) == repr(
             ValueError("invalid literal for int() with base 10: 'b'")
         )
-        assert exc.value.exceptions[0].__note__ == "Structuring mapping value @ key 'b'"
+        assert exc.value.exceptions[0].__notes__ == (
+            "Structuring mapping value @ key 'b'",
+        )
 
     else:
         with pytest.raises(ValueError):
@@ -126,7 +134,7 @@ def test_set_validation():
     assert repr(exc.value.exceptions[0]) == repr(
         ValueError("invalid literal for int() with base 10: 'a'")
     )
-    assert exc.value.exceptions[0].__note__ == "Structuring set @ element 'a'"
+    assert exc.value.exceptions[0].__notes__ == ("Structuring set @ element 'a'",)
 
 
 def test_frozenset_validation():
@@ -139,7 +147,7 @@ def test_frozenset_validation():
     assert repr(exc.value.exceptions[0]) == repr(
         ValueError("invalid literal for int() with base 10: 'a'")
     )
-    assert exc.value.exceptions[0].__note__ == "Structuring frozenset @ element 'a'"
+    assert exc.value.exceptions[0].__notes__ == ("Structuring frozenset @ element 'a'",)
 
 
 def test_homo_tuple_validation():
@@ -152,9 +160,8 @@ def test_homo_tuple_validation():
     assert repr(exc.value.exceptions[0]) == repr(
         ValueError("invalid literal for int() with base 10: 'a'")
     )
-    assert (
-        exc.value.exceptions[0].__note__
-        == "Structuring typing.Tuple[int, ...] @ index 2"
+    assert exc.value.exceptions[0].__notes__ == (
+        "Structuring typing.Tuple[int, ...] @ index 2",
     )
 
 
@@ -168,7 +175,6 @@ def test_hetero_tuple_validation():
     assert repr(exc.value.exceptions[0]) == repr(
         ValueError("invalid literal for int() with base 10: 'a'")
     )
-    assert (
-        exc.value.exceptions[0].__note__
-        == "Structuring typing.Tuple[int, int, int] @ index 2"
+    assert exc.value.exceptions[0].__notes__ == (
+        "Structuring typing.Tuple[int, int, int] @ index 2",
     )