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..8bfd71b6f 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,12 @@ 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)
+ if not subscription_item.schedule_id:
+ # A subscription round-tripped from an inline-schedule response
+ # (Cloud/TOL) has schedule_id=None. Updating it in that state
+ # sends with no id and hits the same wire-layer error
+ # that create() guards against. See tableau/server-client-python#1658.
+ raise ValueError("schedule_id is required to update a subscription")
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..91acbd218 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,10 @@ 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, so the value is non-None at this point.
+ assert subscription_item.schedule_id is not None
schedule_element = ET.SubElement(subscription_element, "schedule")
schedule_element.attrib["id"] = subscription_item.schedule_id
@@ -1368,6 +1378,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..991d67b84 100644
--- a/test/test_subscription.py
+++ b/test/test_subscription.py
@@ -100,3 +100,196 @@ 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_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