HyukjinKwon commented on code in PR #59298:
URL: https://github.com/apache/spark/pull/59298#discussion_r4236503041


##########
python/pyspark/sql/tests/test_udf.py:
##########
@@ -809,6 +811,45 @@ def noop(x):
             self.assertEqual(df.schema[0].dataType.typeName(), "timestamp_ntz")
             self.assertEqual(df.first()[0], datetime.datetime(1970, 1, 1, 0, 
0))
 
+    def test_udf_timestamp_dst_fold_round_trip(self):
+        # SPARK-60081: preserve datetime.fold in the repeated DST hour for 
Python UDFs.
+        tz = "America/Los_Angeles"
+        tz_prev = os.environ.get("TZ", None)
+        try:
+            os.environ["TZ"] = tz

Review Comment:
   **Non-blocking (P2):** `os.environ["TZ"]` plus `time.tzset()` only affects 
this driver process. The UDF bodies and both changed converters run in Python 
workers, which the JVM starts with its launch-time environment plus 
`sc.environment`, so they never see America/Los_Angeles. In CI, PySpark jobs 
set no TZ, so the containers run in UTC and the workers have no repeated hour. 
All four assertions then also hold with the pre-fix code; I checked the old 
expressions under TZ=UTC and they give 1636277400/1636273800.
   
   Other TZ-sensitive suites set `cls.sc.environment["TZ"] = tz` (e.g. 
`goldenutils.setup_timezone`, `test_arrow_map.py`, 
`test_pandas_udf_scalar.py`). The Connect parity suite (`UDFParityTests`) has 
no `sc`, so in-process tests of the two converters under a set TZ may be the 
more reliable signal there. Also check how the inherited test behaves in 
`ArrowPythonUDFLegacyTests`, which uses the legacy pandas conversion path.
   
   See **Shared repair plan 2** in the review body.



##########
python/pyspark/sql/types.py:
##########
@@ -492,9 +492,7 @@ def needConversion(self) -> bool:
 
     def toInternal(self, dt: datetime.datetime) -> int:
         if dt is not None:
-            seconds = (
-                calendar.timegm(dt.utctimetuple()) if dt.tzinfo else 
time.mktime(dt.timetuple())
-            )
+            seconds = calendar.timegm(dt.utctimetuple()) if dt.tzinfo else 
dt.timestamp()

Review Comment:
   **Blocking (P1):** For a naive datetime, `dt.timestamp()` already includes 
`dt.microsecond / 1e6`, so `int(seconds) * 1000000 + dt.microsecond` counts the 
fraction twice. `int()` also truncates toward zero, so every pre-1970 value 
with non-zero microseconds comes out one second late, and far-future values 
with large microseconds round up. For example, with TZ=UTC, `datetime(1969, 12, 
31, 23, 59, 59, 500000)` used to give `-500000` and now gives `500000`. With 
TZ=America/Los_Angeles, `datetime(1950, 5, 3, 12, 0, 0, 123000)` moves by 
+1,000,000 us. The removed `time.mktime(dt.timetuple())` returned whole 
seconds, so this was exact before.
   
   This backs classic `createDataFrame` and non-Arrow UDF return values, so 
those timestamps are silently corrupted. Dropping the microseconds before the 
float conversion (`dt.replace(microsecond=0).timestamp()`) keeps fold and gives 
integral seconds, but see the separate note about `datetime.min`, which that 
form does not handle.
   
   See **Shared repair plan 1** in the review body.



##########
python/pyspark/sql/tests/test_udf.py:
##########
@@ -809,6 +811,45 @@ def noop(x):
             self.assertEqual(df.schema[0].dataType.typeName(), "timestamp_ntz")
             self.assertEqual(df.first()[0], datetime.datetime(1970, 1, 1, 0, 
0))
 
+    def test_udf_timestamp_dst_fold_round_trip(self):
+        # SPARK-60081: preserve datetime.fold in the repeated DST hour for 
Python UDFs.
+        tz = "America/Los_Angeles"
+        tz_prev = os.environ.get("TZ", None)
+        try:
+            os.environ["TZ"] = tz
+            time.tzset()
+            with self.sql_conf(
+                {
+                    "spark.sql.session.timeZone": tz,
+                    "spark.sql.execution.pythonUDF.arrow.enabled": "true",
+                }
+            ):
+                second_fold = self.spark.sql("SELECT timestamp'2021-11-07 
09:30:00Z' AS t")
+                first_fold = self.spark.sql("SELECT timestamp'2021-11-07 
08:30:00Z' AS t")
+
+                for kwargs in [{}, {"useArrow": False}]:
+                    to_epoch = udf(lambda x: int(x.timestamp()), LongType(), 
**kwargs)
+                    identity = udf(lambda x: x, TimestampType(), **kwargs)
+
+                    self.assertEqual(1636277400, 
second_fold.select(to_epoch("t")).first()[0])

Review Comment:
   **Nit (P3):** The `convert_timestamp` change also changes what Spark Connect 
`collect()` returns: a collected TIMESTAMP in the repeated hour now carries 
fold=1. Every assertion here reads a Long computed in a worker (`to_epoch`) or 
in the JVM (`unix_timestamp`), so reverting the conversion.py change would not 
fail anything under `UDFParityTests`. This test already sets the driver TZ, so 
an assertion such as `self.assertEqual(second_fold.first()[0].timestamp(), 
1636277400)` would cover the client-side converter in Connect mode, and 
`fromInternal` in classic mode.
   
   See **Shared repair plan 2** in the review body.



##########
python/pyspark/sql/conversion.py:
##########
@@ -1429,7 +1429,7 @@ def convert_timestamp(value: Any) -> Any:
                     # lossless path), so drop any sub-microsecond digits to 
match classic collect().
                     if hasattr(value, "to_pydatetime"):
                         value = value.to_pydatetime(warn=False)
-                    return value.astimezone().replace(tzinfo=None)
+                    return datetime.datetime.fromtimestamp(value.timestamp())

Review Comment:
   **Non-blocking (P2):** `datetime.datetime.fromtimestamp(value.timestamp())` 
sends the instant through a float. Once |epoch seconds| goes past about 2^33 
(roughly before 1685 or after 2255), a double can no longer hold microseconds; 
for example, `3000-01-01 00:00:00.123457Z` comes back as `.123459`. Near the 
top of the range it can also round across the year limit: with TZ=UTC, 
`9999-12-31 23:59:59.999999Z` now raises `ValueError: year 10000 is out of 
range`, and under America/Los_Angeles it becomes `16:00:00`. The previous 
`astimezone()` path was exact.
   
   This converter backs Arrow-optimized Python UDF inputs and Spark Connect 
`collect()`/`toLocalIterator()`, so values that classic `collect()` returns 
exactly can now drift or fail. `TimestampType.fromInternal` avoids this with 
integer seconds. Something like 
`datetime.datetime.fromtimestamp(calendar.timegm(value.utctimetuple())).replace(microsecond=value.microsecond)`
 keeps fold, because `fromtimestamp` of an int sets it, without the precision 
loss. I checked that this form returns 01:30 fold=1 for 09:30Z and stays exact 
for 9999-12-31 23:59:59.999999 under UTC.
   
   **Verification:**
   - **Regression:** Far-range aware inputs with microseconds (e.g. 3000-01-01 
00:00:00.123457Z, 0001-01-02 08:00:00.123457Z, 9999-12-31 23:59:59.999999Z) 
convert to the exact local wall time, without drift or ValueError, under UTC 
and America/Los_Angeles.
   - **Behavior:** Under America/Los_Angeles, 2021-11-07 09:30:00Z converts to 
01:30 fold=1 and 08:30:00Z to 01:30 fold=0, and .timestamp() of each returns 
the original instant.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to