This is an automated email from the ASF dual-hosted git repository.
claudevdm pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git
The following commit(s) were added to refs/heads/master by this push:
new e344ec03fb7 Python timestamp fixes. (#39722)
e344ec03fb7 is described below
commit e344ec03fb7b3bdac74471d7356a687905da6008
Author: claudevdm <[email protected]>
AuthorDate: Tue Aug 11 14:03:06 2026 -0400
Python timestamp fixes. (#39722)
---
sdks/python/apache_beam/io/gcp/pubsub_test.py | 42 ++++++++++++++++++++++
.../runners/direct/transform_evaluator.py | 5 +++
sdks/python/apache_beam/typehints/schemas.py | 10 ++++--
sdks/python/apache_beam/typehints/schemas_test.py | 10 ++++++
sdks/python/apache_beam/utils/timestamp.py | 5 ++-
sdks/python/apache_beam/utils/timestamp_test.py | 17 +++++++++
6 files changed, 86 insertions(+), 3 deletions(-)
diff --git a/sdks/python/apache_beam/io/gcp/pubsub_test.py
b/sdks/python/apache_beam/io/gcp/pubsub_test.py
index 050a69aff6c..5a8f2bf617b 100644
--- a/sdks/python/apache_beam/io/gcp/pubsub_test.py
+++ b/sdks/python/apache_beam/io/gcp/pubsub_test.py
@@ -25,6 +25,7 @@ import unittest
import hamcrest as hc
import mock
+import pytest
import apache_beam as beam
from apache_beam import Pipeline
@@ -712,6 +713,47 @@ class TestReadFromPubSub(unittest.TestCase):
mock_pubsub.return_value.close.assert_not_called()
+ @pytest.mark.timeout(60)
+ def test_read_messages_timestamp_attribute_sub_micro_rfc3339(
+ self, mock_pubsub):
+ # Publishers may emit 7-9 fractional digits. Sub-microsecond digits
+ # must be truncated when the attribute is parsed; element timestamps
+ # are limited to microsecond resolution and messages are acked before
+ # the bundle is output.
+ data = b'data'
+ attributes = {'time': '2018-03-12T13:37:01.2345678Z'}
+ publish_time_secs = 1337000000
+ publish_time_nanos = 133700000
+ ack_id = 'ack_id'
+ pull_response = test_utils.create_pull_response([
+ test_utils.PullResponseMessage(
+ data, attributes, publish_time_secs, publish_time_nanos, ack_id)
+ ])
+ expected_elements = [
+ TestWindowedValue(
+ PubsubMessage(data, attributes),
+ timestamp.Timestamp(1520861821, micros=234567),
+ [window.GlobalWindow()]),
+ ]
+ mock_pubsub.return_value.pull.return_value = pull_response
+
+ options = PipelineOptions([])
+ options.view_as(StandardOptions).streaming = True
+ with TestPipeline(options=options) as p:
+ pcoll = (
+ p
+ | ReadFromPubSub(
+ 'projects/fakeprj/topics/a_topic',
+ None,
+ None,
+ with_attributes=True,
+ timestamp_attribute='time'))
+ assert_that(pcoll, equal_to(expected_elements), reify_windows=True)
+ mock_pubsub.return_value.acknowledge.assert_has_calls(
+ [mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
+
+ mock_pubsub.return_value.close.assert_not_called()
+
def test_read_messages_timestamp_attribute_missing(self, mock_pubsub):
data = b'data'
attributes = {}
diff --git a/sdks/python/apache_beam/runners/direct/transform_evaluator.py
b/sdks/python/apache_beam/runners/direct/transform_evaluator.py
index 6702ec3362b..d60c840e8c7 100644
--- a/sdks/python/apache_beam/runners/direct/transform_evaluator.py
+++ b/sdks/python/apache_beam/runners/direct/transform_evaluator.py
@@ -724,6 +724,11 @@ class _PubSubReadEvaluator(_TransformEvaluator):
timestamp = Timestamp.from_rfc3339(rfc3339_or_milli)
except ValueError as e:
raise ValueError('Bad timestamp value: %s' % e)
+ if timestamp.precision() > Timestamp.MICROS_PRECISION:
+ # Element timestamps are limited to microsecond resolution, so
+ # ignore sub-microsecond digits, as the Dataflow service does.
+ timestamp = timestamp.to_precision(
+ Timestamp.MICROS_PRECISION, allow_lossy_conversion=True)
else:
if message.publish_time is None:
raise ValueError('No publish time present in message: %s' % message)
diff --git a/sdks/python/apache_beam/typehints/schemas.py
b/sdks/python/apache_beam/typehints/schemas.py
index 2fd3c22e1e5..80bee60ec95 100644
--- a/sdks/python/apache_beam/typehints/schemas.py
+++ b/sdks/python/apache_beam/typehints/schemas.py
@@ -1025,7 +1025,12 @@ class ParameterizedTimestamp(LogicalType[Timestamp,
Timestamp to this logical type, re-register using
:func:`~LogicalType.register_logical_type(ParameterizedTimestamp)`.
"""
- def __init__(self, precision: int = Timestamp.MICROS_PRECISION) -> None:
+ def __init__(self, precision: Optional[int] = None) -> None:
+ if precision is None:
+ # A timestamp:v1 proto without its precision argument is malformed;
+ # decoding at a guessed precision would silently misscale subseconds.
+ raise ValueError(
+ 'beam:logical_type:timestamp:v1 requires a precision argument.')
# The argument arrives as np.int32 when decoded from a schema proto.
precision = int(precision)
if not 0 <= precision <= Timestamp.NANOS_PRECISION:
@@ -1077,7 +1082,8 @@ class ParameterizedTimestamp(LogicalType[Timestamp,
@classmethod
def _from_typing(cls, typ):
- return cls()
+ # A bare Timestamp typehint has no precision; default to micros.
+ return cls(Timestamp.MICROS_PRECISION)
@LogicalType._register_internal
diff --git a/sdks/python/apache_beam/typehints/schemas_test.py
b/sdks/python/apache_beam/typehints/schemas_test.py
index 327fe7947ca..5e66a491090 100644
--- a/sdks/python/apache_beam/typehints/schemas_test.py
+++ b/sdks/python/apache_beam/typehints/schemas_test.py
@@ -879,6 +879,16 @@ class ParameterizedTimestampTest(unittest.TestCase):
representation = logical_type.to_representation_type(millis_value)
self.assertEqual(representation.subseconds, 500000)
+ def test_from_runner_api_rejects_missing_argument(self):
+ # A proto without the precision argument must be rejected; guessing a
+ # default precision would silently misscale subseconds.
+ proto = schema_pb2.LogicalType(
+ urn=common_urns.timestamp.urn,
+ representation=typing_to_runner_api(
+ schemas.ParameterizedTimestampShortRepresentation))
+ with self.assertRaises(ValueError):
+ schemas.LogicalType.from_runner_api(proto)
+
class HypothesisTest(unittest.TestCase):
# There is considerable variablility in runtime for this test, disable
diff --git a/sdks/python/apache_beam/utils/timestamp.py
b/sdks/python/apache_beam/utils/timestamp.py
index 2953541b42f..925467044f3 100644
--- a/sdks/python/apache_beam/utils/timestamp.py
+++ b/sdks/python/apache_beam/utils/timestamp.py
@@ -166,7 +166,10 @@ class Timestamp(object):
if dt.tzinfo != pytz.utc and dt.tzinfo != datetime.timezone.utc:
raise ValueError('dt not in UTC: %s' % dt)
duration = dt - cls._epoch_datetime_utc()
- return Timestamp(duration.total_seconds())
+ # Avoid total_seconds(): its float result can be off by a microsecond.
+ return Timestamp(
+ seconds=duration.days * 86400 + duration.seconds,
+ micros=duration.microseconds)
@classmethod
def from_rfc3339(cls, rfc3339: str) -> 'Timestamp':
diff --git a/sdks/python/apache_beam/utils/timestamp_test.py
b/sdks/python/apache_beam/utils/timestamp_test.py
index e1d120da471..ec1c3604652 100644
--- a/sdks/python/apache_beam/utils/timestamp_test.py
+++ b/sdks/python/apache_beam/utils/timestamp_test.py
@@ -369,6 +369,23 @@ class TimestampPrecisionTest(unittest.TestCase):
with self.assertRaises(ValueError):
_ = ts % Duration(seconds=1)
+ def test_from_rfc3339_fraction_is_exact(self):
+ # Expected values are integers taken from the string, never
+ # Timestamp(float): both sides would share the same lossy float path.
+ # Seconds just above a power of two maximize float error.
+ for rfc, want_sec, want_sub, want_p in [
+ ('2038-01-19T03:14:08.510215590Z', 2147483648, 510215590, 9),
+ ('2004-01-10T13:37:04.611178002Z', 1073741824, 611178002, 9),
+ ('1970-01-01T00:00:00.1252641Z', 0, 1252641, 7),
+ ('1970-01-01T00:00:00.254229935Z', 0, 254229935, 9),
+ ('1969-12-31T23:59:59.746939251Z', -1, 746939251, 9),
+ ('9999-12-31T23:59:59.389694109Z', 253402300799, 389694109, 9),
+ ]:
+ ts = Timestamp.from_rfc3339(rfc)
+ self.assertEqual((ts.seconds(), ts.subseconds(), ts.precision()),
+ (want_sec, want_sub, want_p),
+ rfc)
+
class DurationTest(unittest.TestCase):
def test_of(self):