diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc1430d38..5e3985bd8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,16 @@
## Unreleased
+* Added support for "On Extract Refresh" subscriptions. These are Tableau
+ Cloud subscriptions that fire when a referenced extract-refresh schedule
+ completes, rather than on a time trigger, so recipients always get the
+ freshest data. New `SubscriptionItem.on_extract_refresh(subject,
+ extract_refresh_schedule_id, user_id, target)` classmethod is the
+ recommended way to construct them, and `SubscriptionItem.refresh_extract_triggered`
+ is a boolean property that reflects the `refreshExtractTriggered`
+ attribute on the wire. `subscriptions.create()` and `.update()` now
+ raise `ValueError` if `schedule_id` is missing (previously a confusing
+ server-side error). Fixes #1658.
* Added `Projects.get_by_path(path)` to look up a project by its slash-separated
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
level using the REST API name filter, so a path with *n* components issues *n*
diff --git a/samples/create_extract_refresh_subscription.py b/samples/create_extract_refresh_subscription.py
new file mode 100644
index 000000000..ae032a0bd
--- /dev/null
+++ b/samples/create_extract_refresh_subscription.py
@@ -0,0 +1,108 @@
+####
+# This script creates a Tableau Cloud "On Extract Refresh" subscription:
+# a subscription that fires when an extract-refresh schedule completes,
+# rather than on the schedule's time trigger. Recipients get the email
+# alongside the refresh, so they always see the freshest data.
+#
+# What it does:
+# 1. Sign in.
+# 2. Look up the target view or workbook by name.
+# 3. List extract-refresh schedules and pick the one you named.
+# 4. Build the subscription via SubscriptionItem.on_extract_refresh().
+# 5. Call subscriptions.create() and print the new subscription id.
+#
+# On Tableau Server this same script works as long as the schedule you
+# reference is an extract-refresh schedule; the "On Extract Refresh"
+# terminology is Cloud-UI-specific but the REST attribute
+# (refreshExtractTriggered) is the same on both.
+#
+# Requires Python 3.10 or later.
+####
+
+
+import argparse
+import logging
+
+import tableauserverclient as TSC
+
+
+def usage(args):
+ parser = argparse.ArgumentParser(description="Create an On Extract Refresh subscription for a view or workbook.")
+ # Common options; keep in sync across samples.
+ parser.add_argument("--server", "-s", required=True, help="server address")
+ parser.add_argument("--site", "-S", default="", help="site content URL")
+ parser.add_argument("--token-name", "-p", required=True, help="personal access token name")
+ parser.add_argument("--token-value", "-v", required=True, help="personal access token value")
+ parser.add_argument(
+ "--logging-level",
+ "-l",
+ choices=["debug", "info", "error"],
+ default="error",
+ )
+ # Sample-specific options.
+ parser.add_argument("--subject", required=True, help="subscription subject line")
+ parser.add_argument("--schedule", required=True, help="name of the extract-refresh schedule to attach to")
+ parser.add_argument("--user", required=True, help="username of the subscription recipient")
+ target = parser.add_mutually_exclusive_group(required=True)
+ target.add_argument("--view", help="name of the view to send")
+ target.add_argument("--workbook", help="name of the workbook to send")
+ return parser.parse_args(args)
+
+
+def _find_one(items, label, name):
+ matches = [i for i in items if i.name == name]
+ if len(matches) != 1:
+ raise SystemExit(f"expected exactly one {label} named {name!r}, found {len(matches)}")
+ return matches[0]
+
+
+def find_extract_refresh_schedule(server, name):
+ schedules = [s for s in TSC.Pager(server.schedules) if s.schedule_type == TSC.ScheduleItem.Type.Extract]
+ return _find_one(schedules, "extract-refresh schedule", name)
+
+
+def find_view(server, name):
+ return _find_one(list(TSC.Pager(server.views)), "view", name)
+
+
+def find_workbook(server, name):
+ return _find_one(list(TSC.Pager(server.workbooks)), "workbook", name)
+
+
+def find_user(server, name):
+ return _find_one(list(TSC.Pager(server.users)), "user", name)
+
+
+def run(args):
+ logging.basicConfig(level=getattr(logging, args.logging_level.upper()))
+
+ auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site)
+ server = TSC.Server(args.server, use_server_version=True)
+ with server.auth.sign_in(auth):
+ schedule = find_extract_refresh_schedule(server, args.schedule)
+ user = find_user(server, args.user)
+ if args.view:
+ content = find_view(server, args.view)
+ target = TSC.Target(content.id, "view")
+ else:
+ content = find_workbook(server, args.workbook)
+ target = TSC.Target(content.id, "workbook")
+
+ subscription = TSC.SubscriptionItem.on_extract_refresh(
+ subject=args.subject,
+ extract_refresh_schedule_id=schedule.id,
+ user_id=user.id,
+ target=target,
+ )
+ created = server.subscriptions.create(subscription)
+ print(f"Created subscription {created.id}: {created.subject!r} on schedule {schedule.name!r}")
+
+
+def main():
+ import sys
+
+ run(usage(sys.argv[1:]))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py
index 9ae99e398..17fcc3dff 100644
--- a/tableauserverclient/models/subscription_item.py
+++ b/tableauserverclient/models/subscription_item.py
@@ -11,7 +11,51 @@
class SubscriptionItem:
- def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None:
+ """A subscription that sends a view or workbook to a user on a schedule.
+
+ Subscriptions fire on one of two triggers:
+
+ 1. **Time-based** (the common case): the referenced schedule's time
+ trigger runs -- e.g. a "Weekly Monday 8am" schedule fires the
+ subscription every Monday at 8am. Construct these with the normal
+ ``SubscriptionItem(subject, schedule_id, user_id, target)`` form.
+
+ 2. **Extract-refresh-triggered**: the referenced schedule's extract
+ refresh completes -- the subscription fires alongside the refresh,
+ so recipients always get the freshest data. Use the
+ :meth:`on_extract_refresh` classmethod to construct these; it sets
+ :attr:`refresh_extract_triggered` to ``True`` for you.
+
+ In the Cloud web UI, extract-refresh-triggered subscriptions show up
+ as schedule "On Extract Refresh". At the REST API level there is no
+ "On Extract Refresh" schedule type; instead the subscription
+ references an existing extract-refresh schedule *and* sets
+ ``refreshExtractTriggered=true`` on the payload.
+
+ Examples
+ --------
+ Time-based subscription:
+
+ >>> sub = TSC.SubscriptionItem(
+ ... subject="Weekly report",
+ ... schedule_id=weekly_schedule.id,
+ ... user_id=user.id,
+ ... target=TSC.Target(view.id, "view"),
+ ... )
+ >>> server.subscriptions.create(sub)
+
+ Extract-refresh-triggered subscription:
+
+ >>> sub = TSC.SubscriptionItem.on_extract_refresh(
+ ... subject="Send when refresh finishes",
+ ... extract_refresh_schedule_id=nightly_refresh_schedule.id,
+ ... user_id=user.id,
+ ... target=TSC.Target(view.id, "view"),
+ ... )
+ >>> server.subscriptions.create(sub)
+ """
+
+ def __init__(self, subject: str, schedule_id: str | None, user_id: str, target: "Target") -> None:
self._id = None
self.attach_image = True
self.attach_pdf = False
@@ -25,6 +69,56 @@ def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target
self.target = target
self.user_id = user_id
self.schedule = None
+ self._refresh_extract_triggered: bool = False
+
+ @classmethod
+ def on_extract_refresh(
+ cls,
+ subject: str,
+ extract_refresh_schedule_id: str,
+ user_id: str,
+ target: "Target",
+ ) -> "SubscriptionItem":
+ """Construct a subscription that fires when an extract refresh runs.
+
+ The subscription references an existing extract-refresh schedule and
+ will fire alongside that schedule's extract refresh, so recipients
+ get the freshest data. Server-side this maps to
+ ``refreshExtractTriggered=true`` on the subscription entity; the Cloud
+ UI surfaces the same state as schedule type "On Extract Refresh".
+
+ Parameters
+ ----------
+ subject : str
+ Subscription subject line, shown in the delivered email.
+ extract_refresh_schedule_id : str
+ ID of an existing schedule that owns an extract refresh. On Cloud
+ list schedules with ``server.schedules.get()`` and filter to the
+ extract-refresh schedules; on-prem the same list is populated by
+ the site's server-authored schedules.
+ user_id : str
+ ID of the recipient user.
+ target : Target
+ The workbook or view to send.
+
+ Returns
+ -------
+ SubscriptionItem
+ A subscription with ``refresh_extract_triggered`` set to True.
+ Pass to ``server.subscriptions.create(...)`` to create it.
+
+ Notes
+ -----
+ This factory does not validate that ``extract_refresh_schedule_id``
+ actually references an extract-refresh schedule. Referencing a
+ non-extract schedule with ``refresh_extract_triggered=True`` is a
+ server-side error and will surface when ``create()`` is called.
+
+ Related to tableau/server-client-python#1658.
+ """
+ sub = cls(subject, extract_refresh_schedule_id, user_id, target)
+ sub.refresh_extract_triggered = True
+ return sub
def __repr__(self) -> str:
if self.id is not None:
@@ -74,6 +168,49 @@ def suspended(self) -> bool:
def suspended(self, value: bool) -> None:
self._suspended = value
+ @property
+ def refresh_extract_triggered(self) -> bool:
+ """Whether this subscription fires when its schedule's extract refresh runs.
+
+ When True, the subscription must reference an existing extract-refresh
+ schedule (via ``schedule_id``) and will fire alongside that schedule's
+ extract refresh. When False (the default), the subscription fires on
+ the schedule's time trigger like every other subscription.
+
+ The Cloud web UI surfaces the True state as schedule type "On Extract
+ Refresh"; there is no such REST-API schedule type, so callers must set
+ this flag explicitly. Prefer :meth:`on_extract_refresh` when
+ constructing new extract-refresh-triggered subscriptions -- it wires
+ up ``schedule_id`` and this flag together in one call.
+
+ Setting this to True on a subscription that references a non-extract
+ schedule (Subscription, Flow, System, etc.) is a server-side error;
+ the ``create()``/``update()`` call will raise. TSC does not fetch the
+ referenced schedule to validate this client-side.
+
+ **Updating an existing subscription:** if an update changes the
+ referenced schedule, the server silently forces this flag back to
+ False on that same call, regardless of what the client sent. To
+ convert a time-based subscription into an extract-refresh-triggered
+ one, issue two updates: first change ``schedule_id``, then set
+ ``refresh_extract_triggered = True`` on a second call.
+
+ **Manual-build update() footgun:** every ``subscriptions.update()``
+ payload now carries ``refreshExtractTriggered="true"`` or
+ ``"false"``. The safe pattern is fetch-then-mutate-then-update, so
+ the value round-trips through the parser. If instead you build a
+ fresh ``SubscriptionItem`` locally, assign ``_id`` yourself, and
+ call ``update()``, the default False on the new item will flip an
+ existing extract-refresh-triggered subscription off on the server.
+ Fetch first.
+ """
+ return self._refresh_extract_triggered
+
+ @refresh_extract_triggered.setter
+ @property_is_boolean
+ def refresh_extract_triggered(self, value: bool) -> None:
+ self._refresh_extract_triggered = value
+
@classmethod
def from_response(cls: type, xml: bytes, ns) -> list["SubscriptionItem"]:
parsed_response = fromstring(xml)
@@ -119,6 +256,7 @@ def _parse_element(cls, element, ns):
page_orientation = element.get("pageOrientation", None)
page_size_option = element.get("pageSizeOption", None)
suspended = string_to_bool(element.get("suspended", ""))
+ refresh_extract_triggered = string_to_bool(element.get("refreshExtractTriggered", ""))
# Create SubscriptionItem and set fields
sub = cls(subject, schedule_id, user_id, target)
@@ -131,6 +269,7 @@ def _parse_element(cls, element, ns):
sub.send_if_view_empty = send_if_view_empty
sub.suspended = suspended
sub.schedule = schedule
+ sub.refresh_extract_triggered = refresh_extract_triggered
return sub
diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py
index d69424e44..1b45972fb 100644
--- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py
+++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py
@@ -43,6 +43,14 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem:
if not subscription_item:
error = "No Susbcription provided"
raise ValueError(error)
+ if not subscription_item.schedule_id:
+ # See tableau/server-client-python#1658: users trying to create an
+ # "On Extract Refresh" subscription pass schedule_id=None and hit
+ # a confusing wire-layer error. Point them at the factory.
+ raise ValueError(
+ "schedule_id is required; for on-extract-refresh subscriptions "
+ "use SubscriptionItem.on_extract_refresh(...)"
+ )
logger.info(f"Creating a subscription ({subscription_item})")
url = self.baseurl
create_req = RequestFactory.Subscription.create_req(subscription_item)
@@ -63,6 +71,23 @@ def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem:
if not subscription_item.id:
error = "Subscription item missing ID. Subscription must be retrieved from server first."
raise MissingRequiredFieldError(error)
+ # Cloud subscriptions parsed from a GET response arrive with
+ # schedule_id=None because the schedule is inlined without an id
+ # attribute (see SubscriptionItem._parse_element). Fall back to the
+ # parsed schedule object's id so fetch-then-update -- the safe pattern
+ # the refresh_extract_triggered docstring recommends -- works on Cloud.
+ schedule_id = subscription_item.schedule_id
+ if schedule_id is None and subscription_item.schedule is not None:
+ schedule_id = subscription_item.schedule.id
+ if schedule_id is not None:
+ subscription_item.schedule_id = schedule_id
+ if not schedule_id:
+ raise ValueError(
+ "schedule_id is required to update a subscription. On Tableau "
+ "Cloud, subscriptions parsed from a GET response may not carry "
+ "a schedule id; if you constructed this SubscriptionItem "
+ "manually, set schedule_id explicitly."
+ )
url = f"{self.baseurl}/{subscription_item.id}"
update_req = RequestFactory.Subscription.update_req(subscription_item)
server_response = self.put_request(url, update_req)
diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py
index fc4694c01..d8682abb8 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -1334,6 +1334,13 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt
subscription_element.attrib["pageOrientation"] = subscription_item.page_orientation
if subscription_item.page_size_option is not None:
subscription_element.attrib["pageSizeOption"] = subscription_item.page_size_option
+ # On create, only emit refreshExtractTriggered when True -- server default
+ # is False, and emitting the attribute unconditionally would surface as a
+ # payload change on servers that treat absence differently from an explicit
+ # False. update_req is asymmetric here: it must emit False to enable the
+ # True -> False transition on an existing subscription.
+ if subscription_item.refresh_extract_triggered:
+ subscription_element.attrib["refreshExtractTriggered"] = "true"
# Content element
content_element = ET.SubElement(subscription_element, "content")
@@ -1342,7 +1349,19 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt
if subscription_item.send_if_view_empty is not None:
content_element.attrib["sendIfViewEmpty"] = str(subscription_item.send_if_view_empty).lower()
- # Schedule element
+ # Schedule element. schedule_id can be None on items parsed from
+ # inline-schedule responses; subscriptions.create() guards against
+ # that before we get here. Re-check explicitly with a raise (not
+ # assert) so callers importing RequestFactory directly get an
+ # actionable error rather than the TypeError ElementTree raises when
+ # attrib["id"] is set to None, and so python -O doesn't strip the
+ # check entirely.
+ if subscription_item.schedule_id is None:
+ raise ValueError(
+ "schedule_id is required to build a subscription create "
+ "request; for on-extract-refresh subscriptions use "
+ "SubscriptionItem.on_extract_refresh(...)"
+ )
schedule_element = ET.SubElement(subscription_element, "schedule")
schedule_element.attrib["id"] = subscription_item.schedule_id
@@ -1368,6 +1387,10 @@ def update_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt
subscription.attrib["pageSizeOption"] = subscription_item.page_size_option
if subscription_item.suspended is not None:
subscription.attrib["suspended"] = str(subscription_item.suspended).lower()
+ # update_req always emits the flag so callers can turn it off. The
+ # server retains the prior value when the attribute is absent, so
+ # omission would silently prevent True -> False transitions.
+ subscription.attrib["refreshExtractTriggered"] = str(subscription_item.refresh_extract_triggered).lower()
# Schedule element
schedule = ET.SubElement(subscription, "schedule")
diff --git a/test/test_subscription.py b/test/test_subscription.py
index 7c78cc57d..19c0e85a1 100644
--- a/test/test_subscription.py
+++ b/test/test_subscription.py
@@ -1,3 +1,4 @@
+from datetime import time
from pathlib import Path
import pytest
@@ -100,3 +101,354 @@ 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")
+
+
+# -----------------------------------------------------------------
+# refresh_extract_triggered (aka "On Extract Refresh" subscriptions)
+# -----------------------------------------------------------------
+
+
+def test_create_rejects_none_schedule_id(server: TSC.Server) -> None:
+ """Regression for tableau/server-client-python#1658: users trying to create
+ an 'On Extract Refresh' subscription would pass schedule_id=None. Point them
+ at on_extract_refresh() instead of letting the failure surface deep in
+ the wire layer as a ServerResponseError. The check lives in create() (not
+ __init__) so parse can still build items from server responses that use
+ the inline-schedule form (no schedule id on the wire).
+ """
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem("subject", None, "user-id", target)
+ with pytest.raises(ValueError, match="on_extract_refresh"):
+ server.subscriptions.create(sub)
+
+
+def test_create_rejects_empty_schedule_id(server: TSC.Server) -> None:
+ """Same failure mode as None: an empty-string schedule_id would serialize
+ as and hit a confusing server error.
+ """
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem("subject", "", "user-id", target)
+ with pytest.raises(ValueError, match="on_extract_refresh"):
+ server.subscriptions.create(sub)
+
+
+def test_subscription_defaults_refresh_extract_triggered_false(server: TSC.Server) -> None:
+ """A default SubscriptionItem does not opt into extract-refresh triggering."""
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target)
+ assert sub.refresh_extract_triggered is False
+
+
+def test_on_extract_refresh_factory_sets_flag(server: TSC.Server) -> None:
+ """The on_extract_refresh factory produces a subscription with the flag set
+ and the extract-refresh schedule id in place -- server rejects a payload
+ that has the flag without a schedule reference, so both must be set together.
+ """
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem.on_extract_refresh(
+ subject="On refresh",
+ extract_refresh_schedule_id="refresh-sched-id",
+ user_id="user-id",
+ target=target,
+ )
+ assert sub.refresh_extract_triggered is True
+ assert sub.schedule_id == "refresh-sched-id"
+ assert sub.subject == "On refresh"
+ assert sub.user_id == "user-id"
+ assert sub.target is target
+
+
+def test_create_req_emits_refresh_extract_triggered_when_set(server: TSC.Server) -> None:
+ """When the flag is set, the outbound XML should carry
+ refreshExtractTriggered='true' on the element.
+ """
+ from tableauserverclient.server.request_factory import RequestFactory
+
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem.on_extract_refresh(
+ subject="On refresh",
+ extract_refresh_schedule_id="refresh-sched-id",
+ user_id="user-id",
+ target=target,
+ )
+ body = RequestFactory.Subscription.create_req(sub).decode("utf-8")
+ assert 'refreshExtractTriggered="true"' in body
+
+
+def test_create_req_omits_refresh_extract_triggered_when_false(server: TSC.Server) -> None:
+ """A default subscription must not emit refreshExtractTriggered=false. Some
+ servers treat absence and False differently; we send only when the caller
+ has explicitly opted in.
+ """
+ from tableauserverclient.server.request_factory import RequestFactory
+
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target)
+ body = RequestFactory.Subscription.create_req(sub).decode("utf-8")
+ assert "refreshExtractTriggered" not in body
+
+
+def test_parse_response_reads_refresh_extract_triggered(server: TSC.Server) -> None:
+ """A subscription XML element carrying refreshExtractTriggered='true'
+ parses back into refresh_extract_triggered=True on the SubscriptionItem.
+ """
+ xml = (
+ b''
+ b" "
+ b' '
+ b' '
+ b' '
+ b' '
+ b" "
+ b" "
+ b""
+ )
+ subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"})
+ assert len(subs) == 1
+ assert subs[0].refresh_extract_triggered is True
+ assert subs[0].schedule_id == "refresh-sched-1"
+
+
+def test_update_rejects_missing_schedule_id(server: TSC.Server) -> None:
+ """A subscription round-tripped from an inline-schedule response has
+ schedule_id=None. Calling update() on it would send with no id
+ and hit a confusing wire-layer error. Catch it at the endpoint instead.
+ """
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target)
+ sub._id = "existing-sub-id" # type: ignore[assignment]
+ sub.schedule_id = None
+ with pytest.raises(ValueError, match="schedule_id is required"):
+ server.subscriptions.update(sub)
+
+
+def test_update_req_emits_refresh_extract_triggered_when_true(server: TSC.Server) -> None:
+ """When the flag is True, update_req must emit refreshExtractTriggered='true'."""
+ from tableauserverclient.server.request_factory import RequestFactory
+
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem.on_extract_refresh(
+ subject="On refresh",
+ extract_refresh_schedule_id="refresh-sched-id",
+ user_id="user-id",
+ target=target,
+ )
+ body = RequestFactory.Subscription.update_req(sub).decode("utf-8")
+ assert 'refreshExtractTriggered="true"' in body
+
+
+def test_update_req_emits_refresh_extract_triggered_when_false(server: TSC.Server) -> None:
+ """update_req must emit refreshExtractTriggered='false' so callers can turn
+ the flag off. The server retains the prior value when the attribute is
+ absent, so omission would silently prevent True -> False transitions.
+ """
+ from tableauserverclient.server.request_factory import RequestFactory
+
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem("subject", "sched-id", "user-id", target)
+ assert sub.refresh_extract_triggered is False
+ body = RequestFactory.Subscription.update_req(sub).decode("utf-8")
+ assert 'refreshExtractTriggered="false"' in body
+
+
+def test_parse_response_with_inline_schedule_no_id(server: TSC.Server) -> None:
+ """Regression: on Cloud/TOL the server may return a element with
+ no id attribute (the full schedule is inlined instead). Parse must handle
+ this without raising -- the constructor cannot demand a schedule_id here.
+ """
+ xml = (
+ b''
+ b" "
+ b' '
+ b' '
+ b' '
+ b' '
+ b" "
+ b' '
+ b" "
+ b" "
+ b""
+ )
+ subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"})
+ assert len(subs) == 1
+ assert subs[0].schedule_id is None
+ assert subs[0].schedule is not None
+
+
+def test_update_manually_built_subscription_emits_flag_false(server: TSC.Server) -> None:
+ """Manual-build update() footgun coverage. A fresh SubscriptionItem
+ constructed locally, with _id assigned to point at an existing
+ subscription, must round-trip refresh_extract_triggered=False on the wire
+ when the caller has not touched the flag. This is the exact case the
+ property docstring warns callers away from -- pin the behavior so we
+ notice if the emit changes.
+ """
+ from tableauserverclient.server.request_factory import RequestFactory
+
+ response_xml = (
+ ''
+ ' '
+ ' '
+ ' '
+ ' '
+ " "
+ ""
+ )
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem("Updated subject", "sched-id", "user-id", target)
+ sub._id = "existing-sub-id" # type: ignore[assignment]
+
+ with requests_mock.mock() as m:
+ m.put(server.subscriptions.baseurl + "/existing-sub-id", text=response_xml)
+ server.subscriptions.update(sub)
+ body = m.last_request.text or ""
+
+ assert 'refreshExtractTriggered="false"' in body
+ # Sanity: also confirm update_req produces the same thing without a server
+ body_direct = RequestFactory.Subscription.update_req(sub).decode("utf-8")
+ assert 'refreshExtractTriggered="false"' in body_direct
+
+
+def test_update_manually_built_extract_refresh_subscription_emits_flag_true(server: TSC.Server) -> None:
+ """Manual-build update() coverage for the on_extract_refresh() variant. A
+ fresh SubscriptionItem constructed via on_extract_refresh(), with _id
+ assigned, must emit refreshExtractTriggered='true' when sent through
+ update().
+ """
+ response_xml = (
+ ''
+ ' '
+ ' '
+ ' '
+ ' '
+ " "
+ ""
+ )
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem.on_extract_refresh(
+ subject="On refresh",
+ extract_refresh_schedule_id="refresh-sched-id",
+ user_id="user-id",
+ target=target,
+ )
+ sub._id = "existing-sub-id" # type: ignore[assignment]
+
+ with requests_mock.mock() as m:
+ m.put(server.subscriptions.baseurl + "/existing-sub-id", text=response_xml)
+ server.subscriptions.update(sub)
+ body = m.last_request.text or ""
+
+ assert 'refreshExtractTriggered="true"' in body
+
+
+def test_update_round_trip_preserves_refresh_extract_triggered(server: TSC.Server) -> None:
+ """Fetch-then-mutate-then-update round-trip preserves the flag. Parse a
+ subscription XML with refreshExtractTriggered='true', mutate an unrelated
+ field (subject), send through update(), and confirm the emitted body
+ still carries refreshExtractTriggered='true'. This is the safe pattern
+ the property docstring recommends and it deserves an explicit test.
+ """
+ parsed = TSC.SubscriptionItem.from_response(
+ (
+ b''
+ b" "
+ b' '
+ b' '
+ b' '
+ b' '
+ b" "
+ b" "
+ b""
+ ),
+ {"t": "http://tableau.com/api"},
+ )
+ assert len(parsed) == 1
+ sub = parsed[0]
+ assert sub.refresh_extract_triggered is True
+ sub.subject = "Mutated subject"
+
+ response_xml = (
+ ''
+ ' '
+ ' '
+ ' '
+ ' '
+ " "
+ ""
+ )
+ with requests_mock.mock() as m:
+ m.put(server.subscriptions.baseurl + "/existing-sub-id", text=response_xml)
+ server.subscriptions.update(sub)
+ body = m.last_request.text or ""
+
+ assert 'refreshExtractTriggered="true"' in body
+ assert 'subject="Mutated subject"' in body
+
+
+def test_update_falls_back_to_schedule_object_id_on_cloud(server: TSC.Server) -> None:
+ """Cloud fetch-then-update guard. When a SubscriptionItem arrives with
+ schedule_id=None but a parsed schedule object whose id is populated
+ (a shape a future _parse_element could produce, or a shape a caller can
+ build directly), update() must fall back to schedule.id instead of
+ rejecting the item. This unblocks the fetch-then-update pattern the
+ refresh_extract_triggered property docstring recommends on Cloud.
+ """
+ target = TSC.Target("view-id", "view")
+ sub = TSC.SubscriptionItem("Cloud sub", None, "user-id", target)
+ sub._id = "existing-sub-id" # type: ignore[assignment]
+ # Build a schedule object with an id but leave schedule_id None -- the
+ # Cloud inline-schedule case the endpoint fallback exists to handle.
+ schedule = TSC.ScheduleItem(
+ "Nightly refresh",
+ 50,
+ TSC.ScheduleItem.Type.Extract,
+ "Parallel",
+ TSC.DailyInterval(time(2, 0)),
+ )
+ schedule._id = "inline-sched-id"
+ sub.schedule = schedule # type: ignore[assignment]
+ assert sub.schedule_id is None
+ assert sub.schedule.id == "inline-sched-id" # type: ignore[attr-defined]
+
+ response_xml = (
+ ''
+ ' '
+ ' '
+ ' '
+ ' '
+ " "
+ ""
+ )
+ with requests_mock.mock() as m:
+ m.put(server.subscriptions.baseurl + "/existing-sub-id", text=response_xml)
+ server.subscriptions.update(sub)
+ body = m.last_request.text or ""
+
+ assert 'id="inline-sched-id"' in body
+
+
+def test_parse_response_missing_refresh_extract_triggered_defaults_false(server: TSC.Server) -> None:
+ """Backward compatibility: a subscription XML element without the attribute
+ parses back to refresh_extract_triggered=False.
+ """
+ xml = (
+ b''
+ b" "
+ b' '
+ b' '
+ b' '
+ b' '
+ b" "
+ b" "
+ b""
+ )
+ subs = TSC.SubscriptionItem.from_response(xml, {"t": "http://tableau.com/api"})
+ assert len(subs) == 1
+ assert subs[0].refresh_extract_triggered is False