Skip to content

Commit af11671

Browse files
jacalataclaude
andcommitted
Normalize Cloud <schedule nextRunAt=...> to UTC on parse
Previously parse_datetime returned Cloud datetimes with the wire's offset attached (e.g. tzinfo == timezone(-7:00)), so the same field had different tzinfo depending on cell type. Callers using .strftime("...Z") got wrong output on Cloud; callers comparing to a UTC constant with == got False even on the same instant. Convert the Cloud branch's strptime result via .astimezone(utc), so every next_run_at returned from TSC is uniformly tzinfo == utc regardless of whether the wire used trailing Z or an explicit numeric offset. Callers who want the site's local zone can inspect the wire elsewhere (schedule name, admin API); nobody was relying on the offset today. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9895995 commit af11671

4 files changed

Lines changed: 45 additions & 23 deletions

File tree

‎tableauserverclient/datetime_helpers.py‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,16 @@ def dst(self, dt):
3535
def parse_datetime(date):
3636
"""Parse a Tableau API datetime string into a timezone-aware datetime, or ``None``.
3737
38-
Handles both the Server ``...Z`` form and the Cloud ``...+/-HHMM`` form. Returns
39-
``None`` for both absent input (``None``) and unparseable non-empty input --
40-
matching the pre-Cloud lenient contract so a malformed server response cannot
41-
crash a page-through of unrelated data. User-supplied setter values are
42-
validated at the property-decorator boundary (see
38+
Accepts both the Server ``...Z`` form and the Cloud ``...+/-HHMM`` form on
39+
the wire. The returned ``datetime`` is **always tz-aware and always UTC**
40+
(``tzinfo == utc``) regardless of which wire form was parsed -- the Cloud
41+
branch converts the wire's numeric offset to UTC via ``astimezone`` so
42+
callers see a single uniform tzinfo across Server and Cloud responses.
43+
44+
Returns ``None`` for both absent input (``None``) and unparseable non-empty
45+
input -- matching the pre-Cloud lenient contract so a malformed server
46+
response cannot crash a page-through of unrelated data. User-supplied
47+
setter values are validated at the property-decorator boundary (see
4348
:func:`tableauserverclient.models.property_decorators.property_is_datetime`).
4449
"""
4550
if date is None:
@@ -49,7 +54,9 @@ def parse_datetime(date):
4954
except ValueError:
5055
pass
5156
try:
52-
return datetime.datetime.strptime(date, TABLEAU_CLOUD_DATE_FORMAT)
57+
# strptime %z produces a datetime with the wire's offset; convert to UTC so
58+
# callers get a single uniform tzinfo across Server (already UTC) and Cloud.
59+
return datetime.datetime.strptime(date, TABLEAU_CLOUD_DATE_FORMAT).astimezone(utc)
5360
except ValueError:
5461
return None
5562

‎tableauserverclient/models/property_decorators.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,10 @@ def property_is_datetime(func):
128128
* explicit ``%z`` offset, e.g. ``2026-09-01T10:00:00-0700`` (produced by
129129
Tableau Cloud's inlined ``<schedule nextRunAt=...>``)
130130
131-
The returned ``datetime`` preserves the offset that was on the wire; it is
132-
always offset-aware but is only guaranteed to be UTC when the input used
133-
the trailing-``Z`` form.
131+
Regardless of which wire form was accepted, the resulting ``datetime`` is
132+
always tz-aware with ``tzinfo == utc`` -- the Cloud branch converts the
133+
wire's numeric offset to UTC on parse so downstream callers see one
134+
uniform tzinfo across Server and Cloud responses.
134135
135136
Setter-side strictness lives here: ``parse_datetime`` is deliberately
136137
lenient on the server-response side (unparseable -> ``None``). This

‎test/test_datetime_helpers.py‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
1010
* Absent input (``None``) -> ``None``.
1111
* Well-formed Server form -> UTC-aware ``datetime``.
12-
* Well-formed Cloud form -> aware ``datetime`` with the on-the-wire offset
13-
preserved (**not** normalised to UTC).
12+
* Well-formed Cloud form -> UTC-aware ``datetime``; the wire's numeric offset
13+
is converted to UTC on parse so every returned ``datetime`` carries the same
14+
``tzinfo`` regardless of which wire form arrived.
1415
* Unparseable non-empty input -> ``None``. A malformed server response should
1516
not crash a page-through of unrelated data. This matches the pre-Cloud
1617
behaviour.
@@ -60,14 +61,21 @@ def test_parse_datetime_server_z_form():
6061
assert timedelta(0) == result.utcoffset()
6162

6263

63-
def test_parse_datetime_cloud_offset_form_preserved():
64+
def test_parse_datetime_cloud_offset_form_normalised_to_utc():
6465
result = parse_datetime("2026-08-29T16:55:00-0700")
6566
assert result is not None
66-
# Cloud offsets are deliberately kept -- a future .replace(tzinfo=utc)
67-
# after strptime would silently shift the instant. Lock that in.
68-
assert timedelta(hours=-7) == result.utcoffset()
67+
# The Cloud branch normalises to UTC via astimezone(utc) so every parsed
68+
# datetime carries the same tzinfo regardless of wire form. A future
69+
# regression that drops the astimezone would leave tzinfo == timezone(-7:00)
70+
# and break callers doing .strftime("...Z") or == against a UTC constant.
71+
assert timedelta(0) == result.utcoffset()
72+
assert result.tzinfo is utc
73+
# 16:55 -07:00 == 23:55 UTC on the same wall date.
6974
assert 2026 == result.year
70-
assert 16 == result.hour
75+
assert 8 == result.month
76+
assert 29 == result.day
77+
assert 23 == result.hour
78+
assert 55 == result.minute
7179

7280

7381
def test_parse_datetime_cloud_offset_with_colon():
@@ -129,7 +137,10 @@ def test_property_is_datetime_accepts_valid_cloud_string():
129137
holder = _DateHolder()
130138
holder.created_at = "2026-08-29T16:55:00-0700"
131139
assert holder._value is not None
132-
assert timedelta(hours=-7) == holder._value.utcoffset()
140+
# parse_datetime normalises Cloud offsets to UTC on the read side, and the
141+
# property decorator funnels through parse_datetime for string input.
142+
assert timedelta(0) == holder._value.utcoffset()
143+
assert holder._value.tzinfo is utc
133144

134145

135146
def test_property_is_datetime_accepts_datetime_instance():

‎test/test_subscription.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,16 @@ def test_get_subscriptions_cloud_inline_schedule(server: TSC.Server) -> None:
157157
assert subscription.schedule.next_run_at is not None
158158
assert 2026 == subscription.schedule.next_run_at.year
159159
assert 8 == subscription.schedule.next_run_at.month
160+
# 16:55 -07:00 on the wire -> 23:55 UTC after normalisation; wall date is unchanged.
160161
assert 29 == subscription.schedule.next_run_at.day
161-
162-
# The Cloud path deliberately preserves the ``-0700`` offset that came off
163-
# the wire rather than normalising to UTC. A future regression to
164-
# ``.replace(tzinfo=utc)`` after ``strptime`` would silently shift the
165-
# instant by seven hours -- lock the non-UTC offset in here.
166-
assert timedelta(hours=-7) == subscription.schedule.next_run_at.utcoffset()
162+
assert 23 == subscription.schedule.next_run_at.hour
163+
assert 55 == subscription.schedule.next_run_at.minute
164+
165+
# parse_datetime normalises Cloud's numeric wire offset to UTC so callers
166+
# see a single uniform tzinfo across Server (already ``Z``) and Cloud
167+
# (converted from ``-0700``). Locking that in guards against a future
168+
# regression that would drop the ``astimezone(utc)`` on the Cloud branch.
169+
assert timedelta(0) == subscription.schedule.next_run_at.utcoffset()
167170

168171
# <frequencyDetails> nested <intervals> parsed into a DailyInterval carrying
169172
# the (hours, weekDay) pairs from the XML.

0 commit comments

Comments
 (0)