Python testing: Fix reporting on setup_class error (#35016)

* Python testing: Fix reporting on setup_class error

Also add error text to make the error easier to find.

* Restyled by isort

* Fix lint

* Change exemption to not carry chip_error reference

chip_error is a ctypes struct with a const char* pointer internally.
This cannot be pickled, so it's causing problems with the mobly
framework.

* Fix some tests using removed class member

---------

Co-authored-by: Restyled.io <commits@restyled.io>
diff --git a/src/controller/python/chip/exceptions/__init__.py b/src/controller/python/chip/exceptions/__init__.py
index c7f692e..b293a68 100644
--- a/src/controller/python/chip/exceptions/__init__.py
+++ b/src/controller/python/chip/exceptions/__init__.py
@@ -39,21 +39,17 @@
 
 
 class ChipStackError(ChipStackException):
-    def __init__(self, chip_error: PyChipError, msg=None):
-        self._chip_error = chip_error
-        self.msg = msg if msg else "Chip Stack Error %d" % chip_error.code
+    def __init__(self, code: int, msg=None):
+        self.code = code
+        self.msg = msg if msg else "Chip Stack Error %d" % self.code
 
     @classmethod
     def from_chip_error(cls, chip_error: PyChipError) -> ChipStackError:
-        return cls(chip_error, str(chip_error))
-
-    @property
-    def chip_error(self) -> PyChipError | None:
-        return self._chip_error
+        return cls(chip_error.code, str(chip_error))
 
     @property
     def err(self) -> int:
-        return self._chip_error.code
+        return self.code
 
     def __str__(self):
         return self.msg
diff --git a/src/controller/python/chip/native/__init__.py b/src/controller/python/chip/native/__init__.py
index 5183390..2528aec 100644
--- a/src/controller/python/chip/native/__init__.py
+++ b/src/controller/python/chip/native/__init__.py
@@ -69,7 +69,7 @@
 class PyChipError(ctypes.Structure):
     ''' The ChipError for Python library.
 
-    We are using the following struct for passing the infomations of CHIP_ERROR between C++ and Python:
+    We are using the following struct for passing the information of CHIP_ERROR between C++ and Python:
 
     ```c
     struct PyChipError
@@ -88,6 +88,10 @@
             if exception is not None:  # Ensure exception is not None to avoid mypy error and only raise valid exceptions
                 raise exception
 
+    @classmethod
+    def from_code(cls, code):
+        return cls(code=code, line=0, file=ctypes.c_void_p())
+
     @property
     def is_success(self) -> bool:
         return self.code == 0
diff --git a/src/python_testing/TC_CADMIN_1_9.py b/src/python_testing/TC_CADMIN_1_9.py
index f37e372..c3d67b9 100644
--- a/src/python_testing/TC_CADMIN_1_9.py
+++ b/src/python_testing/TC_CADMIN_1_9.py
@@ -31,6 +31,7 @@
 from chip import ChipDeviceCtrl
 from chip.ChipDeviceCtrl import CommissioningParameters
 from chip.exceptions import ChipStackError
+from chip.native import PyChipError
 from matter_testing_support import MatterBaseTest, TestStep, async_test_body, default_matter_test_main
 from mobly import asserts
 
@@ -74,7 +75,7 @@
             await self.th2.CommissionOnNetwork(
                 nodeId=self.dut_node_id, setupPinCode=setup_code,
                 filterType=ChipDeviceCtrl.DiscoveryFilterType.LONG_DISCRIMINATOR, filter=self.discriminator)
-        errcode = ctx.exception.chip_error
+        errcode = PyChipError.from_code(ctx.exception.err)
         return errcode
 
     async def CommissionAttempt(
diff --git a/src/python_testing/TC_CGEN_2_4.py b/src/python_testing/TC_CGEN_2_4.py
index 7d9d075..09fbc6d 100644
--- a/src/python_testing/TC_CGEN_2_4.py
+++ b/src/python_testing/TC_CGEN_2_4.py
@@ -38,6 +38,7 @@
 from chip import ChipDeviceCtrl
 from chip.ChipDeviceCtrl import CommissioningParameters
 from chip.exceptions import ChipStackError
+from chip.native import PyChipError
 from matter_testing_support import MatterBaseTest, async_test_body, default_matter_test_main
 from mobly import asserts
 
@@ -78,7 +79,7 @@
             await self.th2.CommissionOnNetwork(
                 nodeId=self.dut_node_id, setupPinCode=params.setupPinCode,
                 filterType=ChipDeviceCtrl.DiscoveryFilterType.LONG_DISCRIMINATOR, filter=self.discriminator)
-        errcode = ctx.exception.chip_error
+        errcode = PyChipError.from_code(ctx.exception.err)
         asserts.assert_true(errcode.sdk_part == expectedErrorPart, 'Unexpected error type returned from CommissioningComplete')
         asserts.assert_true(errcode.sdk_code == expectedErrCode, 'Unexpected error code returned from CommissioningComplete')
         revokeCmd = Clusters.AdministratorCommissioning.Commands.RevokeCommissioning()
diff --git a/src/python_testing/matter_testing_support.py b/src/python_testing/matter_testing_support.py
index d44754f..eb8a6ba 100644
--- a/src/python_testing/matter_testing_support.py
+++ b/src/python_testing/matter_testing_support.py
@@ -27,6 +27,7 @@
 import random
 import re
 import sys
+import textwrap
 import time
 import typing
 import uuid
@@ -1123,12 +1124,60 @@
         self.failed = True
         if self.runner_hook and not self.is_commissioning:
             exception = record.termination_signal.exception
-            step_duration = (datetime.now(timezone.utc) - self.step_start_time) / timedelta(microseconds=1)
-            test_duration = (datetime.now(timezone.utc) - self.test_start_time) / timedelta(microseconds=1)
+
+            try:
+                step_duration = (datetime.now(timezone.utc) - self.step_start_time) / timedelta(microseconds=1)
+            except AttributeError:
+                # If we failed during setup, these may not be populated
+                step_duration = 0
+            try:
+                test_duration = (datetime.now(timezone.utc) - self.test_start_time) / timedelta(microseconds=1)
+            except AttributeError:
+                test_duration = 0
             # TODO: I have no idea what logger, logs, request or received are. Hope None works because I have nothing to give
             self.runner_hook.step_failure(logger=None, logs=None, duration=step_duration, request=None, received=None)
             self.runner_hook.test_stop(exception=exception, duration=test_duration)
 
+            def extract_error_text() -> tuple[str, str]:
+                no_stack_trace = ("Stack Trace Unavailable", "")
+                if not record.termination_signal.stacktrace:
+                    return no_stack_trace
+                trace = record.termination_signal.stacktrace.splitlines()
+                if not trace:
+                    return no_stack_trace
+
+                if isinstance(exception, signals.TestError):
+                    # Exception gets raised by the mobly framework, so the proximal error is one line back in the stack trace
+                    assert_candidates = [idx for idx, line in enumerate(trace) if "asserts" in line and "asserts.py" not in line]
+                    if not assert_candidates:
+                        return "Unknown error, please see stack trace above", ""
+                    assert_candidate_idx = assert_candidates[-1]
+                else:
+                    # Normal assert is on the Last line
+                    assert_candidate_idx = -1
+                probable_error = trace[assert_candidate_idx]
+
+                # Find the file marker immediately above the probable error
+                file_candidates = [idx for idx, line in enumerate(trace[:assert_candidate_idx]) if "File" in line]
+                if not file_candidates:
+                    return probable_error, "Unknown file"
+                return probable_error.strip(), trace[file_candidates[-1]].strip()
+
+            probable_error, probable_file = extract_error_text()
+            logging.error(textwrap.dedent(f"""
+
+                                          ******************************************************************
+                                          *
+                                          * Test {self.current_test_info.name} failed for the following reason:
+                                          * {exception}
+                                          *
+                                          * {probable_file}
+                                          * {probable_error}
+                                          *
+                                          *******************************************************************
+
+                                          """))
+
     def on_pass(self, record):
         ''' Called by Mobly on test pass