Skip to content

Commit 93d9c87

Browse files
jacalataclaude
andcommitted
fix: parse Tableau Cloud's inlined subscription <schedule> (#1627)
Tableau Server references a schedule by id inside a <subscription>: <schedule id="cfb2..." name="Weekday mornings"/> Tableau Cloud inlines the schedule instead -- no id attribute, but a `frequency`, `nextRunAt` and a nested `<frequencyDetails>`: <schedule frequency="Daily" nextRunAt="2026-08-29T16:55:00-0700"> <frequencyDetails start="16:55:00" end="16:55:00"> <intervals> <interval hours="24"/> <interval weekDay="Saturday"/> </intervals> </frequencyDetails> </schedule> Previously `SubscriptionItem` only pulled `id`/`name` off `<schedule>`, so on Cloud every subscription came back with `schedule_id == None` and no structured way to see what the API sent. Client code filtering `[s for s in subs if s.schedule_id == target]` silently returned `[]` on Cloud. This is the bug described in issue #1627. Model changes: * `SubscriptionItem` now populates a `schedule: ScheduleItem` attribute for both shapes. On Server, `schedule.id`/`.name` are set (and `schedule_id` remains populated for back-compat). On Cloud, `schedule.frequency`, `schedule.next_run_at`, and `schedule.interval_item` are set. `schedule_id` is `None` on Cloud -- the API does not send one, unavoidable. Docstring calls out the Cloud-vs-Server discriminator and warns callers who filter by `schedule_id`. Fixes a latent bug where the previous Cloud branch assigned a list (return of `ScheduleItem.from_element`) to `sub.schedule` instead of a single item. * `ScheduleItem` gains a `frequency` property. The XML attribute was already being read to select the interval type but was discarded; now it's exposed so callers can distinguish the Cloud shape without reaching into the interval object. The class `Attributes` docstring is rewritten to cover every public property and to note that only `frequency` / `next_run_at` / `interval_item` are populated when the item comes from an inlined Cloud subscription schedule. * `_parse_interval_item` is defensive against malformed Cloud data: a `<frequencyDetails>` without a `start` attribute no longer crashes on `strptime(None, ...)`, and an out-of-range `<interval hours="3"/>` (or unknown weekDay / monthDay) no longer raises `ValueError` out of `IntervalItem` and poisons sibling schedules in the same page. Bad data degrades to `interval_item = None` with a warning log. Datetime plumbing: * `parse_datetime` learns the Tableau Cloud `%Y-%m-%dT%H:%M:%S%z` form (e.g. `2026-08-29T16:55:00-0700`) in addition to the Server `...Z` form. Unparseable input still returns `None` on the read path (preserving the pre-change contract that a malformed server-side date cannot crash a page-through of unrelated data). * `property_is_datetime` now raises `ValueError` when `parse_datetime` returns `None` for a non-empty str value. Bad *user* input is surfaced at the assignment site instead of silently nulling the attribute. * `TABLEAU_CLOUD_DATE_FORMAT` is public, matching the existing `TABLEAU_DATE_FORMAT`. Tests: * New `test/assets/subscription_get_cloud.xml` mirroring the shape observed on `stage-dp1` (API 3.29) and matching parse tests for both Cloud and Server shapes. * Three edge-case Cloud fixtures + tests: no `<frequencyDetails>`, empty `<intervals/>`, out-of-set `<interval hours="3"/>`. * Existing subscription tests extended to cover sub 2 and `get_subscription_by_id`. * New `test/test_datetime_helpers.py` with direct coverage of `parse_datetime` (None / "" / garbage / Server / Cloud / colon-offset / microseconds) and `property_is_datetime` (valid / bad / non-str). * `test/test_schedule.py::test_get` asserts the new `ScheduleItem.frequency` property on the standard /schedules endpoint. Refs: #1627 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6820191 commit 93d9c87

11 files changed

Lines changed: 600 additions & 57 deletions

tableauserverclient/datetime_helpers.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,30 @@ def dst(self, dt):
2626

2727
utc = UTC()
2828
TABLEAU_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
29+
# Tableau Cloud emits some datetimes with a numeric UTC offset instead of the trailing "Z"
30+
# used by Tableau Server -- e.g. the ``nextRunAt`` attribute inlined into a subscription's
31+
# ``<schedule>`` element on Cloud looks like ``2026-08-29T16:55:00-0700``. Accept both.
32+
TABLEAU_CLOUD_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
2933

3034

3135
def parse_datetime(date):
32-
"""Parse a Tableau API datetime string into a UTC-aware datetime, or None if absent or unparseable."""
36+
"""Parse a Tableau API datetime string into a timezone-aware datetime, or ``None``.
37+
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
43+
:func:`tableauserverclient.models.property_decorators.property_is_datetime`).
44+
"""
3345
if date is None:
3446
return None
35-
3647
try:
3748
return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc)
49+
except ValueError:
50+
pass
51+
try:
52+
return datetime.datetime.strptime(date, TABLEAU_CLOUD_DATE_FORMAT)
3853
except ValueError:
3954
return None
4055

tableauserverclient/models/property_decorators.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ def property_is_datetime(func):
126126
127127
Because we return everything with Z as the timezone, we assume everything is in UTC and create
128128
a timezone aware datetime.
129+
130+
Setter-side strictness lives here: ``parse_datetime`` is deliberately lenient
131+
on the server-response side (unparseable -> ``None``), so bad user input would
132+
otherwise silently clear the attribute. We reject it here instead so misuse
133+
surfaces at the assignment site with the offending value in the message.
129134
"""
130135

131136
@wraps(func)
@@ -138,6 +143,11 @@ def wrapper(self, value):
138143
)
139144

140145
dt = parse_datetime(value)
146+
if dt is None:
147+
# ``value`` is a str (checked above) so a ``None`` result here can only
148+
# mean "neither format matched" -- i.e. a genuine parse failure. Bubble
149+
# it up so callers don't silently null out the attribute.
150+
raise ValueError(f"Cannot parse {value!r} as a datetime, cannot update {func.__name__}")
141151
return func(self, dt)
142152

143153
return wrapper

tableauserverclient/models/schedule_item.py

Lines changed: 124 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from defusedxml.ElementTree import fromstring
66

77
from tableauserverclient.datetime_helpers import parse_datetime
8+
from tableauserverclient.helpers.logging import logger
89
from .interval_item import (
910
IntervalItem,
1011
HourlyInterval,
@@ -65,20 +66,58 @@ class ScheduleItem:
6566
6667
Attributes
6768
----------
68-
created_at : datetime
69+
When a ``ScheduleItem`` is returned from the ``/schedules`` endpoint every
70+
field below is populated. When it is materialised out of the inlined
71+
``<schedule>`` element of a Tableau **Cloud** subscription response,
72+
only ``frequency``, ``next_run_at`` and ``interval_item`` are populated --
73+
``id``, ``name``, ``state``, ``created_at``, ``updated_at``, ``priority``,
74+
``execution_order`` and ``schedule_type`` will all be ``None`` on that path.
75+
76+
created_at : datetime | None
6977
The date and time the schedule was created.
7078
71-
end_schedule_at : datetime
79+
end_schedule_at : datetime | None
7280
The date and time the schedule ends.
7381
74-
id : str
75-
The unique identifier for the schedule.
76-
77-
next_run_at : datetime
82+
execution_order : str | None
83+
How the scheduled tasks run -- ``Parallel`` (uses all available background
84+
processes) or ``Serial`` (limits the schedule to one background process).
85+
See :class:`ScheduleItem.ExecutionOrder`.
86+
87+
frequency : str | None
88+
One of ``Hourly`` / ``Daily`` / ``Weekly`` / ``Monthly`` when known.
89+
Populated wherever the API returns ``<schedule frequency=...>`` --
90+
notably by Tableau Cloud when a schedule is inlined into a
91+
``<subscription>`` response, and by the standard ``/schedules`` endpoint.
92+
93+
id : str | None
94+
The unique identifier for the schedule. ``None`` for schedules inlined
95+
into a Tableau Cloud subscription (the API does not send one).
96+
97+
interval_item : Interval | None
98+
The parsed frequency detail as an :class:`IntervalItem` subclass
99+
(``DailyInterval`` / ``WeeklyInterval`` / ``MonthlyInterval`` /
100+
``HourlyInterval``). ``None`` if the response omitted
101+
``<frequencyDetails>`` or if the intervals were malformed enough that
102+
this client couldn't construct a strict-validated interval.
103+
104+
next_run_at : datetime | None
78105
The date and time the schedule is next run.
79106
80-
state : str
81-
The state of the schedule. See ScheduleItem.State for the possible values.
107+
priority : int | None
108+
The priority of the schedule. Lower values represent higher priority,
109+
with ``0`` indicating the highest priority.
110+
111+
schedule_type : str | None
112+
The type of task schedule. See :class:`ScheduleItem.Type` for the
113+
possible values (``Extract``, ``Flow``, ``Subscription``, ...).
114+
115+
state : str | None
116+
The state of the schedule. See :class:`ScheduleItem.State` for the
117+
possible values (``Active`` / ``Suspended``).
118+
119+
updated_at : datetime | None
120+
The date and time the schedule was last updated.
82121
"""
83122

84123
class Type:
@@ -100,6 +139,7 @@ class State:
100139
def __init__(self, name: str, priority: int, schedule_type: str, execution_order: str, interval_item: Interval):
101140
self._created_at: datetime | None = None
102141
self._end_schedule_at: datetime | None = None
142+
self._frequency: str | None = None
103143
self._id: str | None = None
104144
self._next_run_at: datetime | None = None
105145
self._state: str | None = None
@@ -133,6 +173,15 @@ def execution_order(self) -> str:
133173
def execution_order(self, value: str):
134174
self._execution_order = value
135175

176+
@property
177+
def frequency(self) -> str | None:
178+
"""One of ``Hourly``, ``Daily``, ``Weekly``, ``Monthly`` when known.
179+
180+
Populated when the API returns ``<schedule frequency=...>`` -- notably by
181+
Tableau Cloud when a schedule is inlined into a ``<subscription>`` response.
182+
"""
183+
return self._frequency
184+
136185
@property
137186
def id(self) -> str | None:
138187
return self._id
@@ -194,6 +243,7 @@ def _parse_common_tags(self, schedule_xml, ns):
194243
_,
195244
updated_at,
196245
_,
246+
_,
197247
next_run_at,
198248
end_schedule_at,
199249
execution_order,
@@ -231,6 +281,7 @@ def _set_values(
231281
priority,
232282
interval_item,
233283
warnings=None,
284+
frequency=None,
234285
):
235286
if id_ is not None:
236287
self._id = id_
@@ -256,6 +307,8 @@ def _set_values(
256307
self._interval_item = interval_item
257308
if warnings:
258309
self._warnings = warnings
310+
if frequency:
311+
self._frequency = frequency
259312

260313
@classmethod
261314
def from_response(cls, resp, ns):
@@ -276,6 +329,7 @@ def from_element(cls, parsed_response, ns):
276329
created_at,
277330
updated_at,
278331
schedule_type,
332+
frequency,
279333
next_run_at,
280334
end_schedule_at,
281335
execution_order,
@@ -298,15 +352,19 @@ def from_element(cls, parsed_response, ns):
298352
priority=None,
299353
interval_item=None,
300354
warnings=warnings,
355+
frequency=frequency,
301356
)
302357

303358
all_schedule_items.append(schedule_item)
304359
return all_schedule_items
305360

306361
@staticmethod
307362
def _parse_interval_item(parsed_response, frequency, ns):
363+
# Cloud <frequencyDetails> can omit ``start`` -- guard so we don't crash
364+
# the whole subscriptions.get() page on ``datetime.strptime(None, ...)``.
308365
start_time = parsed_response.get("start", None)
309-
start_time = datetime.strptime(start_time, "%H:%M:%S").time()
366+
if start_time is not None:
367+
start_time = datetime.strptime(start_time, "%H:%M:%S").time()
310368
end_time = parsed_response.get("end", None)
311369
if end_time is not None:
312370
end_time = datetime.strptime(end_time, "%H:%M:%S").time()
@@ -315,44 +373,63 @@ def _parse_interval_item(parsed_response, frequency, ns):
315373
for interval_elem in interval_elems:
316374
interval.extend(interval_elem.attrib.items())
317375

318-
if frequency == IntervalItem.Frequency.Daily:
319-
converted_intervals = []
320-
321-
for i in interval:
322-
# We use fractional hours for the two minute-based intervals.
323-
# Need to convert to hours from minutes here
324-
if i[0] == IntervalItem.Occurrence.Minutes:
325-
converted_intervals.append(float(i[1]) / 60)
326-
elif i[0] == IntervalItem.Occurrence.Hours:
327-
converted_intervals.append(float(i[1]))
328-
else:
329-
converted_intervals.append(i[1])
330-
331-
return DailyInterval(start_time, *converted_intervals)
332-
333-
if frequency == IntervalItem.Frequency.Hourly:
334-
converted_intervals = []
335-
336-
for i in interval:
337-
# We use fractional hours for the two minute-based intervals.
338-
# Need to convert to hours from minutes here
339-
if i[0] == IntervalItem.Occurrence.Minutes:
340-
converted_intervals.append(float(i[1]) / 60)
341-
elif i[0] == IntervalItem.Occurrence.Hours:
342-
converted_intervals.append(i[1])
343-
else:
344-
converted_intervals.append(i[1])
345-
346-
return HourlyInterval(start_time, end_time, tuple(converted_intervals))
347-
348-
if frequency == IntervalItem.Frequency.Weekly:
349-
interval_values = [i[1] for i in interval]
350-
return WeeklyInterval(start_time, *interval_values)
351-
352-
if frequency == IntervalItem.Frequency.Monthly:
353-
interval_values = [i[1] for i in interval]
376+
# IntervalItem constructors validate against a fixed VALID_INTERVALS set
377+
# (e.g. ``{0.25, 0.5, 1, 2, 4, 6, 8, 12, 24}`` for hours) and raise
378+
# ``ValueError`` on anything outside it. On Cloud we've seen values like
379+
# ``hours="3"`` that Server never emits; letting that propagate would kill
380+
# the whole subscription list. Degrade the single malformed schedule to
381+
# ``interval_item = None`` so its siblings still parse.
382+
try:
383+
if frequency == IntervalItem.Frequency.Daily:
384+
converted_intervals = []
385+
386+
for i in interval:
387+
# We use fractional hours for the two minute-based intervals.
388+
# Need to convert to hours from minutes here
389+
if i[0] == IntervalItem.Occurrence.Minutes:
390+
converted_intervals.append(float(i[1]) / 60)
391+
elif i[0] == IntervalItem.Occurrence.Hours:
392+
converted_intervals.append(float(i[1]))
393+
else:
394+
converted_intervals.append(i[1])
395+
396+
return DailyInterval(start_time, *converted_intervals)
397+
398+
if frequency == IntervalItem.Frequency.Hourly:
399+
converted_intervals = []
400+
401+
for i in interval:
402+
# We use fractional hours for the two minute-based intervals.
403+
# Need to convert to hours from minutes here
404+
if i[0] == IntervalItem.Occurrence.Minutes:
405+
converted_intervals.append(float(i[1]) / 60)
406+
elif i[0] == IntervalItem.Occurrence.Hours:
407+
converted_intervals.append(i[1])
408+
else:
409+
converted_intervals.append(i[1])
410+
411+
return HourlyInterval(start_time, end_time, tuple(converted_intervals))
412+
413+
if frequency == IntervalItem.Frequency.Weekly:
414+
interval_values = [i[1] for i in interval]
415+
return WeeklyInterval(start_time, *interval_values)
416+
417+
if frequency == IntervalItem.Frequency.Monthly:
418+
interval_values = [i[1] for i in interval]
419+
420+
return MonthlyInterval(start_time, tuple(interval_values))
421+
except ValueError as exc:
422+
logger.warning(
423+
"Skipping malformed <frequencyDetails> " "(frequency=%s, start=%s, end=%s, intervals=%s): %s",
424+
frequency,
425+
start_time,
426+
end_time,
427+
interval,
428+
exc,
429+
)
430+
return None
354431

355-
return MonthlyInterval(start_time, tuple(interval_values))
432+
return None
356433

357434
@staticmethod
358435
def _parse_element(schedule_xml, ns):
@@ -383,6 +460,7 @@ def _parse_element(schedule_xml, ns):
383460
created_at,
384461
updated_at,
385462
schedule_type,
463+
frequency,
386464
next_run_at,
387465
end_schedule_at,
388466
execution_order,

0 commit comments

Comments
 (0)