From 93d9c874dd8f4458083ed7450a80a9345bec85ae Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Wed, 26 Aug 2026 13:26:54 -0700 Subject: [PATCH] fix: parse Tableau Cloud's inlined subscription (#1627) Tableau Server references a schedule by id inside a : Tableau Cloud inlines the schedule instead -- no id attribute, but a `frequency`, `nextRunAt` and a nested ``: Previously `SubscriptionItem` only pulled `id`/`name` off ``, 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 `` without a `start` attribute no longer crashes on `strptime(None, ...)`, and an out-of-range `` (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 ``, empty ``, out-of-set ``. * 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) --- tableauserverclient/datetime_helpers.py | 19 +- .../models/property_decorators.py | 10 ++ tableauserverclient/models/schedule_item.py | 170 +++++++++++++----- .../models/subscription_item.py | 72 +++++++- test/assets/subscription_get_cloud.xml | 20 +++ .../subscription_get_cloud_bad_hours.xml | 30 ++++ ...subscription_get_cloud_empty_intervals.xml | 17 ++ ...ription_get_cloud_no_frequency_details.xml | 13 ++ test/test_datetime_helpers.py | 157 ++++++++++++++++ test/test_schedule.py | 6 + test/test_subscription.py | 143 +++++++++++++++ 11 files changed, 600 insertions(+), 57 deletions(-) create mode 100644 test/assets/subscription_get_cloud.xml create mode 100644 test/assets/subscription_get_cloud_bad_hours.xml create mode 100644 test/assets/subscription_get_cloud_empty_intervals.xml create mode 100644 test/assets/subscription_get_cloud_no_frequency_details.xml create mode 100644 test/test_datetime_helpers.py diff --git a/tableauserverclient/datetime_helpers.py b/tableauserverclient/datetime_helpers.py index ce5d60697..5cca13b89 100644 --- a/tableauserverclient/datetime_helpers.py +++ b/tableauserverclient/datetime_helpers.py @@ -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 +# ```` 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 diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 050346594..88a8aae34 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -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) @@ -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 diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index d15a15345..383f1d55a 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -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, @@ -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 + ```` 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 ```` -- + notably by Tableau Cloud when a schedule is inlined into a + ```` 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 + ```` 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: @@ -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 @@ -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 ```` -- notably by + Tableau Cloud when a schedule is inlined into a ```` response. + """ + return self._frequency + @property def id(self) -> str | None: return self._id @@ -194,6 +243,7 @@ def _parse_common_tags(self, schedule_xml, ns): _, updated_at, _, + _, next_run_at, end_schedule_at, execution_order, @@ -231,6 +281,7 @@ def _set_values( priority, interval_item, warnings=None, + frequency=None, ): if id_ is not None: self._id = id_ @@ -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): @@ -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, @@ -298,6 +352,7 @@ def from_element(cls, parsed_response, ns): priority=None, interval_item=None, warnings=warnings, + frequency=frequency, ) all_schedule_items.append(schedule_item) @@ -305,8 +360,11 @@ def from_element(cls, parsed_response, ns): @staticmethod def _parse_interval_item(parsed_response, frequency, ns): + # Cloud 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() @@ -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 " "(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): @@ -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, diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 9ae99e398..4a29cd510 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -1,16 +1,63 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from defusedxml.ElementTree import fromstring from .property_decorators import property_is_boolean +from .schedule_item import ScheduleItem from .target import Target -from tableauserverclient.models import ScheduleItem if TYPE_CHECKING: from .target import Target class SubscriptionItem: + """Represents a subscription returned by the Tableau REST API. + + Tableau **Server** and Tableau **Cloud** return a subscription's schedule in + two different shapes: + + * **Server** references a named schedule by id:: + + + + * **Cloud** inlines the schedule -- there is *no* ``id`` attribute; the + ``frequency``, ``nextRunAt`` and a nested ```` describe + it directly:: + + + + + + + + + + + The reliable Cloud-vs-Server discriminator on a parsed ``SubscriptionItem`` + is ``schedule.interval_item is not None`` (Cloud inlines the interval + detail; Server only sends an ``id``/``name`` reference and this field will + always be ``None`` there). ``schedule.id is None`` also identifies Cloud + but only in combination with ``schedule is not None`` -- see below. + + Attributes + ---------- + schedule_id : str | None + The referenced schedule's id. **Populated on Server**; **always** ``None`` + on Tableau Cloud because the REST API does not send a schedule id for + inlined schedules. Client code that filters subscriptions with + ``sub.schedule_id == some_id`` will silently return no results on Cloud + -- use :attr:`schedule` instead, or branch on the server type. + + schedule : ScheduleItem | None + A :class:`ScheduleItem` populated with whatever the server returned. + On Server: ``schedule.id`` and ``schedule.name`` are set. On Cloud: + ``schedule.frequency``, ``schedule.next_run_at`` and + ``schedule.interval_item`` (a ``DailyInterval`` / ``WeeklyInterval`` / + etc. carrying the parsed ````) are set. May be + ``None`` if the API returned a subscription with no ```` + child at all -- always guard before dereferencing. + """ + def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -24,7 +71,7 @@ def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target self.suspended = False self.target = target self.user_id = user_id - self.schedule = None + self.schedule: Optional[ScheduleItem] = None def __repr__(self) -> str: if self.id is not None: @@ -88,15 +135,22 @@ def _parse_element(cls, element, ns): content_element = element.find(".//t:content", namespaces=ns) user_element = element.find(".//t:user", namespaces=ns) - # Schedule element + # Schedule element -- shape differs between Server (id + name reference) + # and Cloud (inlined frequency + frequencyDetails, no id). Populate the + # structured ``schedule`` attribute for both, and keep ``schedule_id`` + # populated on Server for backward compatibility. schedule_id = None - schedule = None + schedule: Optional[ScheduleItem] = None if schedule_element is not None: schedule_id = schedule_element.get("id", None) - - # If schedule id is not provided, then TOL with full schedule provided - if schedule_id is None: - schedule = ScheduleItem.from_element(element, ns) + # ScheduleItem.from_element does its own ``.//t:schedule`` lookup; + # pass the element so the descendant search finds + # the inlined child. The XSD constrains this to ``1..1`` so the + # returned list is length 1; if the API ever returns two we take + # the first. + parsed = ScheduleItem.from_element(element, ns) + if parsed: + schedule = parsed[0] # Content element target = None diff --git a/test/assets/subscription_get_cloud.xml b/test/assets/subscription_get_cloud.xml new file mode 100644 index 000000000..62e0e173c --- /dev/null +++ b/test/assets/subscription_get_cloud.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/test/assets/subscription_get_cloud_bad_hours.xml b/test/assets/subscription_get_cloud_bad_hours.xml new file mode 100644 index 000000000..fb91a6753 --- /dev/null +++ b/test/assets/subscription_get_cloud_bad_hours.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/subscription_get_cloud_empty_intervals.xml b/test/assets/subscription_get_cloud_empty_intervals.xml new file mode 100644 index 000000000..888cff991 --- /dev/null +++ b/test/assets/subscription_get_cloud_empty_intervals.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/test/assets/subscription_get_cloud_no_frequency_details.xml b/test/assets/subscription_get_cloud_no_frequency_details.xml new file mode 100644 index 000000000..97125aa5b --- /dev/null +++ b/test/assets/subscription_get_cloud_no_frequency_details.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/test/test_datetime_helpers.py b/test/test_datetime_helpers.py new file mode 100644 index 000000000..4d40e2b8f --- /dev/null +++ b/test/test_datetime_helpers.py @@ -0,0 +1,157 @@ +"""Direct coverage for ``tableauserverclient.datetime_helpers.parse_datetime`` +and the setter-side ``property_is_datetime`` decorator. + +Motivation: + +``parse_datetime`` is called from every ``ScheduleItem`` / ``SubscriptionItem`` / +property-decorator site that turns a date attribute into a ``datetime``. Its +contract is deliberately lenient on the read side and strict on the write side: + +* Absent input (``None``) -> ``None``. +* Well-formed Server form -> UTC-aware ``datetime``. +* Well-formed Cloud form -> aware ``datetime`` with the on-the-wire offset + preserved (**not** normalised to UTC). +* Unparseable non-empty input -> ``None``. A malformed server response should + not crash a page-through of unrelated data. This matches the pre-Cloud + behaviour. + +The strictness lives one layer up, in ``property_is_datetime``: an unparseable +*setter* value (user assigning garbage to ``item.created_at``) raises +``ValueError`` so it surfaces at the assignment site rather than silently +nulling the attribute. Server-response paths go through ``parse_datetime`` +directly and get the lenient contract. + +The tests below lock in each of those behaviours so a future edit can't shift +strictness across the boundary. +""" + +from datetime import datetime, timedelta + +import pytest + +from tableauserverclient.datetime_helpers import ( + TABLEAU_CLOUD_DATE_FORMAT, + TABLEAU_DATE_FORMAT, + parse_datetime, + utc, +) +from tableauserverclient.models.property_decorators import property_is_datetime + + +def test_parse_datetime_none_returns_none(): + assert parse_datetime(None) is None + + +def test_parse_datetime_empty_string_returns_none(): + # Empty string does not match either format. parse_datetime is lenient on + # the read side (server response) and returns None. Strictness on the + # write side lives in property_is_datetime -- see below. + assert parse_datetime("") is None + + +def test_parse_datetime_garbage_returns_none(): + assert parse_datetime("garbage") is None + + +def test_parse_datetime_server_z_form(): + result = parse_datetime("2026-08-29T16:55:00Z") + assert result == datetime(2026, 8, 29, 16, 55, 0, tzinfo=utc) + # Server form is normalised to UTC. + assert timedelta(0) == result.utcoffset() + + +def test_parse_datetime_cloud_offset_form_preserved(): + result = parse_datetime("2026-08-29T16:55:00-0700") + assert result is not None + # Cloud offsets are deliberately kept -- a future .replace(tzinfo=utc) + # after strptime would silently shift the instant. Lock that in. + assert timedelta(hours=-7) == result.utcoffset() + assert 2026 == result.year + assert 16 == result.hour + + +def test_parse_datetime_cloud_offset_with_colon(): + # Python 3.7+ %z accepts the `+HH:MM` colon form as well. + result = parse_datetime("2026-08-29T16:55:00+00:00") + assert result is not None + assert timedelta(0) == result.utcoffset() + + +def test_parse_datetime_microseconds_returns_none(): + # Neither format has ``%f`` / ``.ffffff`` -- the current implementation + # rejects the microsecond form. Because parse_datetime is lenient it + # returns None rather than raising. Locking that in so a broad "add more + # formats" patch doesn't slip in without a decision. + assert parse_datetime("2026-08-29T16:55:00.123456Z") is None + + +def test_format_constants_shape(): + # Guard against a rename regressing the module's public surface -- both + # constants are documented as module-level and are imported by the + # subscription/schedule parsers. + assert TABLEAU_DATE_FORMAT.endswith("Z") + assert TABLEAU_CLOUD_DATE_FORMAT.endswith("%z") + + +# --------------------------------------------------------------------------- +# property_is_datetime: the strictness that used to live in parse_datetime +# now lives here. Setter-side unparseable strings raise; server-response-side +# unparseable strings are silently None (covered above). +# --------------------------------------------------------------------------- + + +class _DateHolder: + """Minimal host for the ``property_is_datetime`` decorator so we can + exercise its wrapper directly. Mirrors the ``@X.setter`` + + ``@property_is_datetime`` stacking used on real model classes (see + ``MetricItem.created_at``).""" + + def __init__(self): + self._value = "unset" + + @property + def created_at(self): + return self._value + + @created_at.setter + @property_is_datetime + def created_at(self, value): + self._value = value + + +def test_property_is_datetime_accepts_valid_server_string(): + holder = _DateHolder() + holder.created_at = "2026-08-29T16:55:00Z" + assert holder._value == datetime(2026, 8, 29, 16, 55, 0, tzinfo=utc) + + +def test_property_is_datetime_accepts_valid_cloud_string(): + holder = _DateHolder() + holder.created_at = "2026-08-29T16:55:00-0700" + assert holder._value is not None + assert timedelta(hours=-7) == holder._value.utcoffset() + + +def test_property_is_datetime_accepts_datetime_instance(): + holder = _DateHolder() + dt = datetime(2026, 1, 1, tzinfo=utc) + holder.created_at = dt + assert holder._value is dt + + +def test_property_is_datetime_raises_on_garbage_string(): + holder = _DateHolder() + with pytest.raises(ValueError, match="Cannot parse"): + holder.created_at = "garbage" + + +def test_property_is_datetime_raises_on_empty_string(): + holder = _DateHolder() + with pytest.raises(ValueError, match="Cannot parse"): + holder.created_at = "" + + +def test_property_is_datetime_raises_on_non_string_non_datetime(): + holder = _DateHolder() + with pytest.raises(ValueError, match="Cannot convert"): + holder.created_at = 12345 diff --git a/test/test_schedule.py b/test/test_schedule.py index 823a87607..03ec1ce82 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -66,6 +66,9 @@ def test_get(server: TSC.Server) -> None: assert "2016-09-13T11:00:32Z" == format_datetime(extract.updated_at) assert "Extract" == extract.schedule_type assert "2016-09-14T11:00:00Z" == format_datetime(extract.next_run_at) + # The standard /schedules endpoint has always sent frequency=... on ; + # ScheduleItem now surfaces it as a property. Lock that in. + assert "Weekly" == extract.frequency assert "bcb79d07-6e47-472f-8a65-d7f51f40c36c" == subscription.id assert "Saturday night" == subscription.name @@ -75,6 +78,7 @@ def test_get(server: TSC.Server) -> None: assert "2016-09-12T16:39:38Z" == format_datetime(subscription.updated_at) assert "Subscription" == subscription.schedule_type assert "2016-09-18T06:00:00Z" == format_datetime(subscription.next_run_at) + assert "Weekly" == subscription.frequency assert "f456e8f2-aeb2-4a8e-b823-00b6f08640f0" == flow.id assert "First of the month 1:00AM" == flow.name @@ -84,6 +88,7 @@ def test_get(server: TSC.Server) -> None: assert "2019-02-19T18:55:51Z" == format_datetime(flow.updated_at) assert "Flow" == flow.schedule_type assert "2019-03-01T09:00:00Z" == format_datetime(flow.next_run_at) + assert "Monthly" == flow.frequency assert "3cfa4713-ce7c-4fa7-aa2e-f752bfc8dd04" == system.id assert "First of the month 2:00AM" == system.name @@ -93,6 +98,7 @@ def test_get(server: TSC.Server) -> None: assert "2019-02-19T18:55:51Z" == format_datetime(system.updated_at) assert "System" == system.schedule_type assert "2019-03-01T09:00:00Z" == format_datetime(system.next_run_at) + assert "Monthly" == system.frequency def test_get_empty(server: TSC.Server) -> None: diff --git a/test/test_subscription.py b/test/test_subscription.py index 7c78cc57d..bee47c03c 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -1,3 +1,4 @@ +from datetime import timedelta from pathlib import Path import pytest @@ -10,6 +11,10 @@ CREATE_XML = TEST_ASSET_DIR / "subscription_create.xml" GET_XML = TEST_ASSET_DIR / "subscription_get.xml" GET_XML_BY_ID = TEST_ASSET_DIR / "subscription_get_by_id.xml" +GET_XML_CLOUD = TEST_ASSET_DIR / "subscription_get_cloud.xml" +GET_XML_CLOUD_NO_FREQ_DETAILS = TEST_ASSET_DIR / "subscription_get_cloud_no_frequency_details.xml" +GET_XML_CLOUD_EMPTY_INTERVALS = TEST_ASSET_DIR / "subscription_get_cloud_empty_intervals.xml" +GET_XML_CLOUD_BAD_HOURS = TEST_ASSET_DIR / "subscription_get_cloud_bad_hours.xml" @pytest.fixture(scope="function") @@ -46,6 +51,11 @@ def test_get_subscriptions(server: TSC.Server) -> None: assert "View" == subscription.target.type assert "c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e" == subscription.user_id assert "7617c389-cdca-4940-a66e-69956fcebf3e" == subscription.schedule_id + # Server shape also populates the structured schedule attribute with id + name. + assert subscription.schedule is not None + assert "7617c389-cdca-4940-a66e-69956fcebf3e" == subscription.schedule.id + assert "Subscribe daily [00:00 - 04:00, Pacific US] [migrated at 1490824351877]" == subscription.schedule.name + assert subscription.schedule.frequency is None # server referenced-schedule shape has no frequency subscription = all_subscriptions[1] assert "23cb7630-afc8-4c8e-b6cd-83ae0322ec66" == subscription.id @@ -61,6 +71,12 @@ def test_get_subscriptions(server: TSC.Server) -> None: assert "Workbook" == subscription.target.type assert "c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e" == subscription.user_id assert "3407cd38-7b39-4983-86a6-67a1506a5e3f" == subscription.schedule_id + # Server shape also populates the structured schedule attribute for the second subscription. + assert subscription.schedule is not None + assert "3407cd38-7b39-4983-86a6-67a1506a5e3f" == subscription.schedule.id + assert "SSS_27212a85-6b28-41f6-8c69-29b02043d7a5" == subscription.schedule.name + assert subscription.schedule.frequency is None # server referenced-schedule shape has no frequency + assert subscription.schedule.interval_item is None # no in a Server reference def test_get_subscription_by_id(server: TSC.Server) -> None: @@ -75,6 +91,12 @@ def test_get_subscription_by_id(server: TSC.Server) -> None: assert "c0d5fc44-ad8c-4957-bec0-b70ed0f8df1e" == subscription.user_id assert "Not Found Alert" == subscription.subject assert "7617c389-cdca-4940-a66e-69956fcebf3e" == subscription.schedule_id + # get_by_id also parses the structured schedule attribute on the Server referenced-schedule shape. + assert subscription.schedule is not None + assert "7617c389-cdca-4940-a66e-69956fcebf3e" == subscription.schedule.id + assert "Subscribe daily [00:00 - 04:00, Pacific US] [migrated at 1490824351877]" == subscription.schedule.name + assert subscription.schedule.frequency is None + assert subscription.schedule.interval_item is None def test_create_subscription(server: TSC.Server) -> None: @@ -100,3 +122,124 @@ def test_delete_subscription(server: TSC.Server) -> None: with requests_mock.mock() as m: m.delete(server.subscriptions.baseurl + "/78e9318d-2d29-4d67-b60f-3f2f5fd89ecc", status_code=204) server.subscriptions.delete("78e9318d-2d29-4d67-b60f-3f2f5fd89ecc") + + +def test_get_subscriptions_cloud_inline_schedule(server: TSC.Server) -> None: + """Tableau Cloud inlines the schedule into without an id. + + See github.com/tableau/server-client-python/issues/1627 -- the previous + parser silently dropped everything about the schedule on Cloud responses, + so ``sub.schedule_id`` was always None and there was no structured way to + read the inlined frequency / next-run / intervals. This test locks in that + ``schedule_id`` remains None (unavoidable -- the API doesn't send one) but + the structured ``schedule`` attribute is populated with the inlined data. + """ + response_xml = GET_XML_CLOUD.read_text() + with requests_mock.mock() as m: + m.get(server.subscriptions.baseurl, text=response_xml) + all_subscriptions, pagination_item = server.subscriptions.get() + + assert 1 == pagination_item.total_available + subscription = all_subscriptions[0] + assert "df1c0a85-1234-4b6f-a2c4-1234567890ab" == subscription.id + assert "Cloud daily digest" == subscription.subject + assert "bbbb1111-2222-3333-4444-555555555555" == subscription.user_id + assert "View" == subscription.target.type + + # schedule_id is unavoidably None on Cloud -- the API does not send one for + # inlined schedules. Callers filtering by schedule_id must switch to + # subscription.schedule for Cloud parity. + assert subscription.schedule_id is None + + assert subscription.schedule is not None + assert subscription.schedule.id is None # inlined -- no referenceable id + assert "Daily" == subscription.schedule.frequency + assert subscription.schedule.next_run_at is not None + assert 2026 == subscription.schedule.next_run_at.year + assert 8 == subscription.schedule.next_run_at.month + assert 29 == subscription.schedule.next_run_at.day + + # The Cloud path deliberately preserves the ``-0700`` offset that came off + # the wire rather than normalising to UTC. A future regression to + # ``.replace(tzinfo=utc)`` after ``strptime`` would silently shift the + # instant by seven hours -- lock the non-UTC offset in here. + assert timedelta(hours=-7) == subscription.schedule.next_run_at.utcoffset() + + # nested parsed into a DailyInterval carrying + # the (hours, weekDay) pairs from the XML. + interval_item = subscription.schedule.interval_item + assert interval_item is not None + assert len(interval_item.interval) >= 1 + # The two children map to (24.0, 'Saturday'). ``24 == 24.0`` so + # the numeric equality holds regardless of int/float representation. + assert 24 in interval_item.interval + assert "Saturday" in interval_item.interval + + +def test_get_subscriptions_cloud_no_frequency_details(server: TSC.Server) -> None: + """Cloud sometimes returns ```` without any ```` child. + + Before B2 landed this crashed the whole ``subscriptions.get()`` call with + ``TypeError: strptime() argument 1 must be str, not None``. It should now + degrade to ``interval_item = None`` while ``frequency`` and ``next_run_at`` + remain populated. + """ + response_xml = GET_XML_CLOUD_NO_FREQ_DETAILS.read_text() + with requests_mock.mock() as m: + m.get(server.subscriptions.baseurl, text=response_xml) + all_subscriptions, _ = server.subscriptions.get() + + subscription = all_subscriptions[0] + assert subscription.schedule is not None + assert "Daily" == subscription.schedule.frequency + assert subscription.schedule.next_run_at is not None + assert subscription.schedule.interval_item is None + + +def test_get_subscriptions_cloud_malformed_interval(server: TSC.Server) -> None: + """A single malformed ```` used to poison the whole page. + + ``IntervalItem`` validates against a fixed ``VALID_INTERVALS`` set that + rejects ``3`` as an hourly interval; that ``ValueError`` used to escape + ``_parse_element`` and abort the entire subscription list. B3 catches it + per-schedule so the malformed subscription degrades to + ``interval_item = None`` and its healthy siblings still parse. + """ + response_xml = GET_XML_CLOUD_BAD_HOURS.read_text() + with requests_mock.mock() as m: + m.get(server.subscriptions.baseurl, text=response_xml) + all_subscriptions, _ = server.subscriptions.get() + + assert 2 == len(all_subscriptions) + malformed = all_subscriptions[0] + healthy = all_subscriptions[1] + + assert malformed.schedule is not None + assert "Daily" == malformed.schedule.frequency + assert malformed.schedule.next_run_at is not None + # ``hours="3"`` is not in DailyInterval.VALID_INTERVALS -- degrade to None. + assert malformed.schedule.interval_item is None + + # Sibling with a valid ``hours="24"`` still parses. + assert healthy.schedule is not None + assert healthy.schedule.interval_item is not None + assert 24 in healthy.schedule.interval_item.interval + + +def test_get_subscriptions_cloud_empty_intervals(server: TSC.Server) -> None: + """An empty ```` element parses to an interval item with no children. + + ``DailyInterval`` accepts an empty interval tuple (nothing to validate against + ``VALID_INTERVALS``) -- so this stays populated with an ``interval_item`` + whose ``.interval`` is empty rather than degrading to ``None``. + """ + response_xml = GET_XML_CLOUD_EMPTY_INTERVALS.read_text() + with requests_mock.mock() as m: + m.get(server.subscriptions.baseurl, text=response_xml) + all_subscriptions, _ = server.subscriptions.get() + + subscription = all_subscriptions[0] + assert subscription.schedule is not None + assert "Daily" == subscription.schedule.frequency + assert subscription.schedule.interval_item is not None + assert () == tuple(subscription.schedule.interval_item.interval)