Repository: ipython/ipython

Issue:
The traceback logic incorrectly prefers `.msg` attribute over string representation in `%xmode plain`
In #1229 a custom `.msg` attribute was added to `SyntaxError`s to pass around a custom message to the traceback handler. However, the handler checks for `.msg` in all exceptions, not only `SyntaxError` exceptions:


https://github.com/ipython/ipython/blob/cbac235320fe91056a0fb6dba74cd8bef7fc21d9/IPython/core/ultratb.py#L367-L371

which means that sometimes a sub-optimal message is shown to the user.  For example, executing `json.loads('{"a": }')` currently will show just `JSONDecodeError: Expecting value` in `%xmode plain`, whereas in normal exception handling we would expect `JSONDecodeError: Expecting value: line 1 column 7 (char 6)`:

<img width="733" height="357" alt="Image" src="https://github.com/user-attachments/assets/d4c41735-c461-4268-aad7-879b00340692" />

This is because `json.JSONDecodeError` stores both `msg` and additional attributes such as the source code and position in source and composes them to create a final message:

```python
e = json.JSONDecodeError("Incorrect value", '{"a": a}', 5)
assert e.msg == 'Incorrect value'
assert str(e) == 'Incorrect value: line 1 column 6 (char 5)'
```

In this case going straight for `_some_str` is preferred.

Identical behaviour for `msg` was added for built-in `tomllib.TOMLDecodeError` in Python 3.14.

Tracebacks from third-party libraries are affected too, for example `pyparsing` or `tomli`.

Some background: for a short while Python 2 had `.message` attribute on exceptions (in Python 2.5). This was deprecated in Python 2.6 and removed in Python 3 (although some exceptions sill define `.message`). Since then using the string representation was the preferred way of getting the message for the user.


Discussion:

Patch:
diff --git a/IPython/core/ultratb.py b/IPython/core/ultratb.py
index be5b508c815..f68ddedd5f4 100644
--- a/IPython/core/ultratb.py
+++ b/IPython/core/ultratb.py
@@ -363,11 +363,8 @@ def _format_exception_only(
                                 [(Token.Caret, s + "^"), (Token, "\n")]
                             )
                         )
-
-            try:
-                assert hasattr(value, "msg")
                 s = value.msg
-            except Exception:
+            else:
                 s = self._some_str(value)
             if s:
                 output_list.append(


Test patch:
diff --git a/tests/test_ultratb.py b/tests/test_ultratb.py
index adc60c66f58..5bada825e24 100644
--- a/tests/test_ultratb.py
+++ b/tests/test_ultratb.py
@@ -437,6 +437,21 @@ def test_plain_reports_notes(self):
             ip.run_cell("%xmode Verbose")
 
 
+class ExceptionMessagePreferenceTest(unittest.TestCase):
+    """
+    Test that exception string representation is preferred over .msg attribute
+    for non-SyntaxError exceptions in %xmode plain.
+    """
+
+    def test_jsondecodeerror_message(self):
+        cell = "import json;json.loads('{\"a\": }')"
+        expected = "JSONDecodeError: Expecting value: line 1 column 7 (char 6)"
+        ip.run_cell("%xmode plain")
+        with tt.AssertPrints(expected):
+            ip.run_cell(cell)
+        ip.run_cell("%xmode context")
+
+
 # ----------------------------------------------------------------------------