Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions tableauserverclient/datetime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,30 @@ 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``.

Handles both the Server ``...Z`` form and the Cloud ``...+/-HHMM`` form. 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:
return datetime.datetime.strptime(date, TABLEAU_CLOUD_DATE_FORMAT)
except ValueError:
return None

Expand Down
10 changes: 10 additions & 0 deletions tableauserverclient/models/property_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ def property_is_datetime(func):

Because we return everything with Z as the timezone, we assume everything is in UTC and create
a timezone aware datetime.

Setter-side strictness lives here: ``parse_datetime`` is deliberately lenient
on the server-response side (unparseable -> ``None``), so bad user input would
otherwise silently clear the attribute. We reject it here instead so misuse
surfaces at the assignment site with the offending value in the message.
"""

@wraps(func)
Expand All @@ -138,6 +143,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
170 changes: 124 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,
_,
_,
next_run_at,
end_schedule_at,
execution_order,
Expand Down Expand Up @@ -231,6 +281,7 @@ def _set_values(
priority,
interval_item,
warnings=None,
frequency=None,
):
if id_ is not None:
self._id = id_
Expand All @@ -256,6 +307,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 +329,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 +352,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()
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 +373,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(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 +460,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