This is an automated email from the ASF dual-hosted git repository.

gaogaotiantian pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new a9209c5c0ca9 [SPARK-57271][PYTHON] Add capability to print locals in 
traceback for Python UDF
a9209c5c0ca9 is described below

commit a9209c5c0ca9285fc359986427ed3210a64db11a
Author: Tian Gao <[email protected]>
AuthorDate: Fri Jun 12 13:11:21 2026 -0700

    [SPARK-57271][PYTHON] Add capability to print locals in traceback for 
Python UDF
    
    ### What changes were proposed in this pull request?
    
    A new configuration 
`spark.sql.execution.pyspark.udf.tracebackWithLocals.enabled` is introduced for 
UDF tracebacks to include local variable values.
    
    ```
    pyspark.errors.exceptions.captured.PythonException: An exception was thrown 
from the Python worker:
    Traceback (most recent call last):
      File "/Users/tian.gao/programs/spark/python/example.py", line 10, in f
        return g(y)
               ^^^^
        x = 1
        y = 0
      File "/Users/tian.gao/programs/spark/python/example.py", line 6, in g
        return 1 / y
               ~~^~~
        y = 0
    ```
    
    ### Why are the changes needed?
    
    This is very helpful for UDF debugging - it includes all the local 
variables in each frame for users to understand the crash site. This is off by 
default so the original behavior is kept.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, opt-in configuration to enable extra information in the UDF traceback.
    
    ### How was this patch tested?
    
    Some local manual tests were done. Two test cases were added. Pending CI.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Tests were generated by Claude Code (Opus 4.8).
    
    Closes #56336 from gaogaotiantian/exception-with-locals.
    
    Authored-by: Tian Gao <[email protected]>
    Signed-off-by: Tian Gao <[email protected]>
    (cherry picked from commit bef8152d6fb1df0f8eb34808411dcd61125f18cc)
    Signed-off-by: Tian Gao <[email protected]>
---
 .../org/apache/spark/api/python/PythonRunner.scala   |  4 ++++
 python/pyspark/sql/tests/test_udf.py                 | 14 ++++++++++++++
 python/pyspark/tests/test_util.py                    | 20 +++++++++++++++++++-
 python/pyspark/util.py                               | 15 +++++++++++----
 .../org/apache/spark/sql/internal/SQLConf.scala      | 13 +++++++++++++
 .../sql/execution/python/ArrowPythonRunner.scala     |  1 +
 .../sql/execution/python/ArrowPythonUDTFRunner.scala |  1 +
 .../python/CoGroupedArrowPythonRunner.scala          |  1 +
 .../sql/execution/python/PythonPlannerRunner.scala   |  1 +
 .../spark/sql/execution/python/PythonUDFRunner.scala |  1 +
 .../ApplyInPandasWithStatePythonRunner.scala         |  1 +
 .../python/streaming/PythonForeachWriter.scala       |  1 +
 12 files changed, 68 insertions(+), 5 deletions(-)

diff --git a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala 
b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
index 1524ff455ec7..896ab6e3b19d 100644
--- a/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
+++ b/core/src/main/scala/org/apache/spark/api/python/PythonRunner.scala
@@ -213,6 +213,7 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
      conf.get(PYTHON_DAEMON_KILL_WORKER_ON_FLUSH_FAILURE)
   protected val hideTraceback: Boolean = false
   protected val simplifiedTraceback: Boolean = false
+  protected val tracebackWithLocals: Boolean = false
 
   protected def runnerConf: Map[String, String] = Map.empty
   protected def evalConf: Map[String, String] = Map.empty
@@ -302,6 +303,9 @@ private[spark] abstract class BasePythonRunner[IN, OUT](
     if (simplifiedTraceback) {
       envVars.put("SPARK_SIMPLIFIED_TRACEBACK", "1")
     }
+    if (tracebackWithLocals) {
+      envVars.put("SPARK_TRACEBACK_WITH_LOCALS", "1")
+    }
     // SPARK-30299 this could be wrong with standalone mode when executor
     // cores might not be correct because it defaults to all cores on the box.
     val execCores = 
execCoresProp.map(_.toInt).getOrElse(conf.get(EXECUTOR_CORES))
diff --git a/python/pyspark/sql/tests/test_udf.py 
b/python/pyspark/sql/tests/test_udf.py
index ce7a42469e18..5701fb6dcda9 100644
--- a/python/pyspark/sql/tests/test_udf.py
+++ b/python/pyspark/sql/tests/test_udf.py
@@ -1232,6 +1232,20 @@ class BaseUDFTestsMixin:
         with self.assertRaisesRegex(PythonException, "StopIteration"):
             self.spark.range(10).select(test_udf(col("id"))).show()
 
+    def test_udf_traceback_with_locals(self):
+        with 
self.sql_conf({"spark.sql.execution.pyspark.udf.tracebackWithLocals.enabled": 
True}):
+
+            @udf("int")
+            def test_udf(a):
+                local_marker = a + 1
+                if local_marker:
+                    raise ValueError("boom")
+                return local_marker
+
+            # The captured locals should include the local variable and its 
value.
+            with self.assertRaisesRegex(PythonException, "local_marker = 1"):
+                self.spark.range(1).select(test_udf(col("id"))).collect()
+
     def test_python_udf_segfault(self):
         with 
self.sql_conf({"spark.sql.execution.pyspark.udf.faulthandler.enabled": True}):
             with self.assertRaisesRegex(Exception, "Segmentation fault"):
diff --git a/python/pyspark/tests/test_util.py 
b/python/pyspark/tests/test_util.py
index c837c8dbdb97..686e946773b0 100644
--- a/python/pyspark/tests/test_util.py
+++ b/python/pyspark/tests/test_util.py
@@ -176,7 +176,9 @@ class HandleWorkerExceptionTests(unittest.TestCase):
         from pyspark.util import handle_worker_exception
 
         try:
-            raise ValueError("test_message")
+            local_marker = "marker_value_42"
+            if local_marker:
+                raise ValueError("test_message")
         except Exception as e:
             with io.BytesIO() as stream:
                 handle_worker_exception(e, stream, hide_traceback)
@@ -206,6 +208,22 @@ class HandleWorkerExceptionTests(unittest.TestCase):
         self.assertIn(self.exception_bytes, result)
         self.assertNotIn(self.traceback_bytes, result)
 
+    @patch.dict(os.environ, {"SPARK_TRACEBACK_WITH_LOCALS": "1"})
+    def test_env_traceback_with_locals(self):
+        result = self.run_handle_worker_exception()
+        self.assertIn(self.exception_bytes, result)
+        self.assertIn(self.traceback_bytes, result)
+        # The local variable's value should be captured in the traceback.
+        self.assertIn(b"marker_value_42", result)
+
+    @patch.dict(os.environ, {"SPARK_TRACEBACK_WITH_LOCALS": ""})
+    def test_env_no_traceback_with_locals(self):
+        result = self.run_handle_worker_exception()
+        self.assertIn(self.exception_bytes, result)
+        self.assertIn(self.traceback_bytes, result)
+        # Without the environment variable, locals must not be captured.
+        self.assertNotIn(b"marker_value_42", result)
+
 
 if __name__ == "__main__":
     from pyspark.testing import main
diff --git a/python/pyspark/util.py b/python/pyspark/util.py
index 4d3c64e6a1f5..e66f6283ead1 100644
--- a/python/pyspark/util.py
+++ b/python/pyspark/util.py
@@ -527,12 +527,19 @@ def handle_worker_exception(
     def format_exception() -> str:
         if hide_traceback:
             return "".join(traceback.format_exception_only(type(e), e))
+        tb = sys.exc_info()[-1]
         if os.environ.get("SPARK_SIMPLIFIED_TRACEBACK", False):
-            tb = try_simplify_traceback(sys.exc_info()[-1])  # type: 
ignore[arg-type]
-            if tb is not None:
+            simplified_tb = try_simplify_traceback(tb)  # type: 
ignore[arg-type]
+            if simplified_tb is not None:
+                tb = simplified_tb
                 e.__cause__ = None
-                return "".join(traceback.format_exception(type(e), e, tb))
-        return traceback.format_exc()
+        # We only set SPARK_TRACEBACK_WITH_LOCALS=1 for now. This equivalent 
to a
+        # check for the existence of the environment variable.
+        capture_locals = bool(os.environ.get("SPARK_TRACEBACK_WITH_LOCALS", 
False))
+        te = traceback.TracebackException(
+            type(e), e, tb, compact=True, capture_locals=capture_locals
+        )
+        return "".join(te.format())
 
     try:
         exc_info = format_exception()
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 4ea181330acb..abe8a9a59227 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -4802,6 +4802,17 @@ object SQLConf {
       // show full stacktrace in tests but hide in production by default.
       .createWithDefault(!Utils.isTesting)
 
+  val PYSPARK_TRACEBACK_WITH_LOCALS =
+    buildConf("spark.sql.execution.pyspark.udf.tracebackWithLocals.enabled")
+      .doc(
+        "When true, include the local variables in the traceback from Python 
UDFs. " +
+        "Note that this config will print the value of every local variable in 
the call stack, " +
+        "including sensitive data like passwords or API keys. Please use this 
config with caution.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.SESSION)
+      .booleanConf
+      .createWithDefault(false)
+
   val PYSPARK_ARROW_VALIDATE_SCHEMA =
     buildConf("spark.sql.execution.arrow.pyspark.validateSchema.enabled")
       .doc(
@@ -8432,6 +8443,8 @@ class SQLConf extends Serializable with Logging with 
SqlApiConf {
 
   def pysparkSimplifiedTraceback: Boolean = 
getConf(PYSPARK_SIMPLIFIED_TRACEBACK)
 
+  def pysparkTracebackWithLocals: Boolean = 
getConf(PYSPARK_TRACEBACK_WITH_LOCALS)
+
   def pysparkArrowValidateSchema: Boolean = 
getConf(PYSPARK_ARROW_VALIDATE_SCHEMA)
 
   def pandasGroupedMapAssignColumnsByName: Boolean =
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
index df9b0e241b74..75b8465e2607 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonRunner.scala
@@ -67,6 +67,7 @@ abstract class BaseArrowPythonRunner[IN, OUT <: AnyRef](
 
   override val hideTraceback: Boolean = SQLConf.get.pysparkHideTraceback
   override val simplifiedTraceback: Boolean = 
SQLConf.get.pysparkSimplifiedTraceback
+  override val tracebackWithLocals: Boolean = 
SQLConf.get.pysparkTracebackWithLocals
 
   override val bufferSize: Int = SQLConf.get.pandasUDFBufferSize
   require(
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonUDTFRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonUDTFRunner.scala
index a3c9f2f45926..59e78ae81e3b 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonUDTFRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/ArrowPythonUDTFRunner.scala
@@ -93,6 +93,7 @@ class ArrowPythonUDTFRunner(
 
   override val hideTraceback: Boolean = SQLConf.get.pysparkHideTraceback
   override val simplifiedTraceback: Boolean = 
SQLConf.get.pysparkSimplifiedTraceback
+  override val tracebackWithLocals: Boolean = 
SQLConf.get.pysparkTracebackWithLocals
 
   override val bufferSize: Int = SQLConf.get.pandasUDFBufferSize
   require(
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/CoGroupedArrowPythonRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/CoGroupedArrowPythonRunner.scala
index be36b368719a..3bf22a84613e 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/CoGroupedArrowPythonRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/CoGroupedArrowPythonRunner.scala
@@ -78,6 +78,7 @@ class CoGroupedArrowPythonRunner(
 
   override val hideTraceback: Boolean = SQLConf.get.pysparkHideTraceback
   override val simplifiedTraceback: Boolean = 
SQLConf.get.pysparkSimplifiedTraceback
+  override val tracebackWithLocals: Boolean = 
SQLConf.get.pysparkTracebackWithLocals
 
   private val maxRecordsPerBatch: Int = {
     val v = SQLConf.get.arrowMaxRecordsPerBatch
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonPlannerRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonPlannerRunner.scala
index 5311f34f41c0..80887dd469d6 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonPlannerRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonPlannerRunner.scala
@@ -63,6 +63,7 @@ abstract class PythonPlannerRunner[T](func: PythonFunction) 
extends Logging {
     val killWorkerOnFlushFailure: Boolean = 
SQLConf.get.pythonUDFDaemonKillWorkerOnFlushFailure
     val hideTraceback: Boolean = SQLConf.get.pysparkHideTraceback
     val simplifiedTraceback: Boolean = SQLConf.get.pysparkSimplifiedTraceback
+    val tracebackWithLocals: Boolean = SQLConf.get.pysparkTracebackWithLocals
     val workerMemoryMb = SQLConf.get.pythonPlannerExecMemory
 
     val jobArtifactUUID = JobArtifactSet.getCurrentJobArtifactState.map(_.uuid)
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonUDFRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonUDFRunner.scala
index e59b942ab1b4..b2eaec1674b7 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonUDFRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/PythonUDFRunner.scala
@@ -60,6 +60,7 @@ abstract class BasePythonUDFRunner(
 
   override val hideTraceback: Boolean = SQLConf.get.pysparkHideTraceback
   override val simplifiedTraceback: Boolean = 
SQLConf.get.pysparkSimplifiedTraceback
+  override val tracebackWithLocals: Boolean = 
SQLConf.get.pysparkTracebackWithLocals
 
   override val faultHandlerEnabled: Boolean = 
SQLConf.get.pythonUDFWorkerFaulthandlerEnabled
   override val idleTimeoutSeconds: Long = 
SQLConf.get.pythonUDFWorkerIdleTimeoutSeconds
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/ApplyInPandasWithStatePythonRunner.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/ApplyInPandasWithStatePythonRunner.scala
index 786c5fc408dc..70d23ff89c35 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/ApplyInPandasWithStatePythonRunner.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/ApplyInPandasWithStatePythonRunner.scala
@@ -90,6 +90,7 @@ class ApplyInPandasWithStatePythonRunner(
 
   override val hideTraceback: Boolean = sqlConf.pysparkHideTraceback
   override val simplifiedTraceback: Boolean = 
sqlConf.pysparkSimplifiedTraceback
+  override val tracebackWithLocals: Boolean = 
sqlConf.pysparkTracebackWithLocals
 
   override protected val largeVarTypes: Boolean = sqlConf.arrowUseLargeVarTypes
 
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/PythonForeachWriter.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/PythonForeachWriter.scala
index cc7745210a4d..ebcd49931da4 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/PythonForeachWriter.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/python/streaming/PythonForeachWriter.scala
@@ -111,6 +111,7 @@ class PythonForeachWriter(func: PythonFunction, schema: 
StructType)
 
       override val hideTraceback: Boolean = SQLConf.get.pysparkHideTraceback
       override val simplifiedTraceback: Boolean = 
SQLConf.get.pysparkSimplifiedTraceback
+      override val tracebackWithLocals: Boolean = 
SQLConf.get.pysparkTracebackWithLocals
     }
   }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to