Skip to content
26 changes: 24 additions & 2 deletions tableauserverclient/datetime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,37 @@ def dst(self, dt):

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


def parse_datetime(date):
"""Parse a Tableau API datetime string into a UTC-aware datetime, or None if absent or unparseable."""
"""Parse a Tableau API datetime string into a timezone-aware datetime, or ``None``.

Accepts both the Server ``...Z`` form and the Cloud ``...+/-HHMM`` form on
the wire. The returned ``datetime`` is **always tz-aware and always UTC**
(``tzinfo == utc``) regardless of which wire form was parsed -- the Cloud
branch converts the wire's numeric offset to UTC via ``astimezone`` so
callers see a single uniform tzinfo across Server and Cloud responses.

Returns ``None`` for both absent input (``None``) and unparseable non-empty
input -- matching the pre-Cloud lenient contract so a malformed server
response cannot crash a page-through of unrelated data. User-supplied
setter values are validated at the property-decorator boundary (see
:func:`tableauserverclient.models.property_decorators.property_is_datetime`).
"""
if date is None:
return None

try:
return datetime.datetime.strptime(date, TABLEAU_DATE_FORMAT).replace(tzinfo=utc)
except ValueError:
pass
try:
# strptime %z produces a datetime with the wire's offset; convert to UTC so
# callers get a single uniform tzinfo across Server (already UTC) and Cloud.
return datetime.datetime.strptime(date, TABLEAU_CLOUD_DATE_FORMAT).astimezone(utc)
except ValueError:
return None

Expand Down
26 changes: 22 additions & 4 deletions tableauserverclient/models/property_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,25 @@ def validate_regex_decorator(self, value):


def property_is_datetime(func):
"""Takes the following datetime format and turns it into a datetime object:
"""Coerces the incoming value into a timezone-aware ``datetime`` object.

2016-08-18T18:25:36Z
``parse_datetime`` (used on the read side) accepts two wire formats:

Because we return everything with Z as the timezone, we assume everything is in UTC and create
a timezone aware datetime.
* trailing-``Z`` UTC, e.g. ``2016-08-18T18:25:36Z``
* explicit ``%z`` offset, e.g. ``2026-09-01T10:00:00-0700`` (produced by
Tableau Cloud's inlined ``<schedule nextRunAt=...>``)

Regardless of which wire form was accepted, the resulting ``datetime`` is
always tz-aware with ``tzinfo == utc`` -- the Cloud branch converts the
wire's numeric offset to UTC on parse so downstream callers see one
uniform tzinfo across Server and Cloud responses.

Setter-side strictness lives here: ``parse_datetime`` is deliberately
lenient on the server-response side (unparseable -> ``None``). This
decorator instead raises ``ValueError`` when the input is a string that
matches neither format, so bad user input surfaces at the assignment site
with the offending value in the message rather than silently clearing the
attribute.
"""

@wraps(func)
Expand All @@ -138,6 +151,11 @@ def wrapper(self, value):
)

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

return wrapper
Expand Down
171 changes: 125 additions & 46 deletions tableauserverclient/models/schedule_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from defusedxml.ElementTree import fromstring

from tableauserverclient.datetime_helpers import parse_datetime
from tableauserverclient.helpers.logging import logger
from .interval_item import (
IntervalItem,
HourlyInterval,
Expand Down Expand Up @@ -65,20 +66,58 @@ class ScheduleItem:

Attributes
----------
created_at : datetime
When a ``ScheduleItem`` is returned from the ``/schedules`` endpoint every
field below is populated. When it is materialised out of the inlined
``<schedule>`` element of a Tableau **Cloud** subscription response,
only ``frequency``, ``next_run_at`` and ``interval_item`` are populated --
``id``, ``name``, ``state``, ``created_at``, ``updated_at``, ``priority``,
``execution_order`` and ``schedule_type`` will all be ``None`` on that path.

created_at : datetime | None
The date and time the schedule was created.

end_schedule_at : datetime
end_schedule_at : datetime | None
The date and time the schedule ends.

id : str
The unique identifier for the schedule.

next_run_at : datetime
execution_order : str | None
How the scheduled tasks run -- ``Parallel`` (uses all available background
processes) or ``Serial`` (limits the schedule to one background process).
See :class:`ScheduleItem.ExecutionOrder`.

frequency : str | None
One of ``Hourly`` / ``Daily`` / ``Weekly`` / ``Monthly`` when known.
Populated wherever the API returns ``<schedule frequency=...>`` --
notably by Tableau Cloud when a schedule is inlined into a
``<subscription>`` response, and by the standard ``/schedules`` endpoint.

id : str | None
The unique identifier for the schedule. ``None`` for schedules inlined
into a Tableau Cloud subscription (the API does not send one).

interval_item : Interval | None
The parsed frequency detail as an :class:`IntervalItem` subclass
(``DailyInterval`` / ``WeeklyInterval`` / ``MonthlyInterval`` /
``HourlyInterval``). ``None`` if the response omitted
``<frequencyDetails>`` or if the intervals were malformed enough that
this client couldn't construct a strict-validated interval.

next_run_at : datetime | None
The date and time the schedule is next run.

state : str
The state of the schedule. See ScheduleItem.State for the possible values.
priority : int | None
The priority of the schedule. Lower values represent higher priority,
with ``0`` indicating the highest priority.

schedule_type : str | None
The type of task schedule. See :class:`ScheduleItem.Type` for the
possible values (``Extract``, ``Flow``, ``Subscription``, ...).

state : str | None
The state of the schedule. See :class:`ScheduleItem.State` for the
possible values (``Active`` / ``Suspended``).

updated_at : datetime | None
The date and time the schedule was last updated.
"""

class Type:
Expand All @@ -100,6 +139,7 @@ class State:
def __init__(self, name: str, priority: int, schedule_type: str, execution_order: str, interval_item: Interval):
self._created_at: datetime | None = None
self._end_schedule_at: datetime | None = None
self._frequency: str | None = None
self._id: str | None = None
self._next_run_at: datetime | None = None
self._state: str | None = None
Expand Down Expand Up @@ -133,6 +173,15 @@ def execution_order(self) -> str:
def execution_order(self, value: str):
self._execution_order = value

@property
def frequency(self) -> str | None:
"""One of ``Hourly``, ``Daily``, ``Weekly``, ``Monthly`` when known.

Populated when the API returns ``<schedule frequency=...>`` -- notably by
Tableau Cloud when a schedule is inlined into a ``<subscription>`` response.
"""
return self._frequency

@property
def id(self) -> str | None:
return self._id
Expand Down Expand Up @@ -194,6 +243,7 @@ def _parse_common_tags(self, schedule_xml, ns):
_,
updated_at,
_,
frequency,
next_run_at,
end_schedule_at,
execution_order,
Expand All @@ -213,6 +263,7 @@ def _parse_common_tags(self, schedule_xml, ns):
execution_order=execution_order,
priority=priority,
interval_item=interval_item,
frequency=frequency,
)

return self
Expand All @@ -231,6 +282,7 @@ def _set_values(
priority,
interval_item,
warnings=None,
frequency=None,
):
if id_ is not None:
self._id = id_
Expand All @@ -256,6 +308,8 @@ def _set_values(
self._interval_item = interval_item
if warnings:
self._warnings = warnings
if frequency:
self._frequency = frequency

@classmethod
def from_response(cls, resp, ns):
Expand All @@ -276,6 +330,7 @@ def from_element(cls, parsed_response, ns):
created_at,
updated_at,
schedule_type,
frequency,
next_run_at,
end_schedule_at,
execution_order,
Expand All @@ -298,15 +353,19 @@ def from_element(cls, parsed_response, ns):
priority=None,
interval_item=None,
warnings=warnings,
frequency=frequency,
)

all_schedule_items.append(schedule_item)
return all_schedule_items

@staticmethod
def _parse_interval_item(parsed_response, frequency, ns):
# Cloud <frequencyDetails> can omit ``start`` -- guard so we don't crash
# the whole subscriptions.get() page on ``datetime.strptime(None, ...)``.
start_time = parsed_response.get("start", None)
start_time = datetime.strptime(start_time, "%H:%M:%S").time()
if start_time is not None:
start_time = datetime.strptime(start_time, "%H:%M:%S").time()
Comment on lines 366 to +368
end_time = parsed_response.get("end", None)
if end_time is not None:
end_time = datetime.strptime(end_time, "%H:%M:%S").time()
Expand All @@ -315,44 +374,63 @@ def _parse_interval_item(parsed_response, frequency, ns):
for interval_elem in interval_elems:
interval.extend(interval_elem.attrib.items())

if frequency == IntervalItem.Frequency.Daily:
converted_intervals = []

for i in interval:
# We use fractional hours for the two minute-based intervals.
# Need to convert to hours from minutes here
if i[0] == IntervalItem.Occurrence.Minutes:
converted_intervals.append(float(i[1]) / 60)
elif i[0] == IntervalItem.Occurrence.Hours:
converted_intervals.append(float(i[1]))
else:
converted_intervals.append(i[1])

return DailyInterval(start_time, *converted_intervals)

if frequency == IntervalItem.Frequency.Hourly:
converted_intervals = []

for i in interval:
# We use fractional hours for the two minute-based intervals.
# Need to convert to hours from minutes here
if i[0] == IntervalItem.Occurrence.Minutes:
converted_intervals.append(float(i[1]) / 60)
elif i[0] == IntervalItem.Occurrence.Hours:
converted_intervals.append(i[1])
else:
converted_intervals.append(i[1])

return HourlyInterval(start_time, end_time, tuple(converted_intervals))

if frequency == IntervalItem.Frequency.Weekly:
interval_values = [i[1] for i in interval]
return WeeklyInterval(start_time, *interval_values)

if frequency == IntervalItem.Frequency.Monthly:
interval_values = [i[1] for i in interval]
# IntervalItem constructors validate against a fixed VALID_INTERVALS set
# (e.g. ``{0.25, 0.5, 1, 2, 4, 6, 8, 12, 24}`` for hours) and raise
# ``ValueError`` on anything outside it. On Cloud we've seen values like
# ``hours="3"`` that Server never emits; letting that propagate would kill
# the whole subscription list. Degrade the single malformed schedule to
# ``interval_item = None`` so its siblings still parse.
try:
if frequency == IntervalItem.Frequency.Daily:
converted_intervals = []

for i in interval:
# We use fractional hours for the two minute-based intervals.
# Need to convert to hours from minutes here
if i[0] == IntervalItem.Occurrence.Minutes:
converted_intervals.append(float(i[1]) / 60)
elif i[0] == IntervalItem.Occurrence.Hours:
converted_intervals.append(float(i[1]))
else:
converted_intervals.append(i[1])

return DailyInterval(start_time, *converted_intervals)

if frequency == IntervalItem.Frequency.Hourly:
converted_intervals = []

for i in interval:
# We use fractional hours for the two minute-based intervals.
# Need to convert to hours from minutes here
if i[0] == IntervalItem.Occurrence.Minutes:
converted_intervals.append(float(i[1]) / 60)
elif i[0] == IntervalItem.Occurrence.Hours:
converted_intervals.append(float(i[1]))
else:
converted_intervals.append(i[1])

return HourlyInterval(start_time, end_time, tuple(converted_intervals))

if frequency == IntervalItem.Frequency.Weekly:
interval_values = [i[1] for i in interval]
return WeeklyInterval(start_time, *interval_values)

if frequency == IntervalItem.Frequency.Monthly:
interval_values = [i[1] for i in interval]

return MonthlyInterval(start_time, tuple(interval_values))
except ValueError as exc:
logger.warning(
"Skipping malformed <frequencyDetails> (frequency=%s, start=%s, end=%s, intervals=%s): %s",
frequency,
start_time,
end_time,
interval,
exc,
)
return None

return MonthlyInterval(start_time, tuple(interval_values))
return None

@staticmethod
def _parse_element(schedule_xml, ns):
Expand Down Expand Up @@ -383,6 +461,7 @@ def _parse_element(schedule_xml, ns):
created_at,
updated_at,
schedule_type,
frequency,
next_run_at,
end_schedule_at,
execution_order,
Expand Down
Loading
Loading