From 5b5c90aa026cd9ab094cd063e312171fc2ec06b3 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Sun, 16 Aug 2026 05:06:20 -0700 Subject: [PATCH 1/2] feat: expose refreshExtractTriggered on SubscriptionItem (#1658) The Tableau REST API supports a `refreshExtractTriggered="true"` attribute on subscription payloads that makes the subscription fire when its referenced schedule's extract refresh completes, rather than on the schedule's time trigger. On Tableau Cloud, this is the wire form of an "On Extract Refresh" subscription. TSC never exposed this attribute; users trying to create these subscriptions were passing `schedule_id=None` and hitting a confusing wire error deep in the endpoint layer. Changes: - `SubscriptionItem.on_extract_refresh(...)` classmethod factory constructs a subscription with an extract-refresh schedule id and the flag set. - `refresh_extract_triggered` exposed as a property with a docstring covering the two ways the server surprises callers (server rejects True with a non-extract schedule; server silently clears the flag when a schedule change is included in an update). - `Subscriptions.create()` and `.update()` now raise `ValueError` up front when `schedule_id` is missing, so the wire error becomes an actionable client-side message. - `create_req` emits `refreshExtractTriggered="true"` only when set; `update_req` emits both true and false so callers can turn the flag off on an existing subscription. - `_parse_element` reads the attribute back into the property; parse continues to accept inline-schedule responses (schedule_id=None). Tests cover: factory sets flag + schedule id; default false; create_req emit-when-set/omit-when-false; update_req always emits; parse round-trip for both true and missing; parse of inline-schedule responses; create() and update() reject missing schedule_id. Related to #1658. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../models/subscription_item.py | 130 ++++++++++++ .../server/endpoint/subscriptions_endpoint.py | 11 + tableauserverclient/server/request_factory.py | 11 + test/test_subscription.py | 193 ++++++++++++++++++ 4 files changed, 345 insertions(+) diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index 9ae99e398..660bd12b6 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -11,6 +11,50 @@ class SubscriptionItem: + """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, user_id: str, target: "Target") -> None: self._id = None self.attach_image = True @@ -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,40 @@ 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. + """ + 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 +247,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 +260,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..6360ebf14 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -43,6 +43,11 @@ 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; see 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 +68,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..c2dabc330 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") @@ -1368,6 +1375,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..045943aac 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) # type: ignore[arg-type] + 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 # type: ignore[assignment] + 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 From 4207f7f668d1762bc954757c0071887c879673c1 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 18 Aug 2026 01:02:50 -0700 Subject: [PATCH 2/2] Address fresh-eyes review on refreshExtractTriggered subscriptions - Docstring on `refresh_extract_triggered` now warns about the manual- build update() footgun: because every subscriptions.update() payload carries the attribute, a caller who builds a fresh SubscriptionItem locally, stamps _id, and updates will silently flip an existing on-extract-refresh subscription off. Fetch first. - Soften create()'s "schedule_id is required" error so someone who just forgot to set schedule_id on a time-based subscription doesn't get steered exclusively toward SubscriptionItem.on_extract_refresh(...); the factory is now mentioned as a conditional pointer. - __init__'s schedule_id parameter is now typed str | None, matching the real state: _parse_element sets it to None on inline-schedule responses. Drop the two `# type: ignore` markers in test/test_subscription.py that were papering over the earlier lie. - create_req asserts schedule_id non-None to satisfy mypy after the parameter widening; subscriptions.create() already guards this path before request emission. - Add samples/create_extract_refresh_subscription.py demonstrating the full flow: sign in, resolve view/workbook and user by name, pick an extract-refresh schedule from the schedules list, build the subscription via on_extract_refresh(), post it. Highest-leverage discoverability artifact for callers searching "on extract refresh". - CHANGELOG entry. --- CHANGELOG.md | 10 ++ .../create_extract_refresh_subscription.py | 108 ++++++++++++++++++ .../models/subscription_item.py | 11 +- .../server/endpoint/subscriptions_endpoint.py | 5 +- tableauserverclient/server/request_factory.py | 5 +- test/test_subscription.py | 4 +- 6 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 samples/create_extract_refresh_subscription.py 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 660bd12b6..17fcc3dff 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -55,7 +55,7 @@ class SubscriptionItem: >>> server.subscriptions.create(sub) """ - def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target") -> None: + 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 @@ -194,6 +194,15 @@ def refresh_extract_triggered(self) -> bool: 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 diff --git a/tableauserverclient/server/endpoint/subscriptions_endpoint.py b/tableauserverclient/server/endpoint/subscriptions_endpoint.py index 6360ebf14..8bfd71b6f 100644 --- a/tableauserverclient/server/endpoint/subscriptions_endpoint.py +++ b/tableauserverclient/server/endpoint/subscriptions_endpoint.py @@ -47,7 +47,10 @@ def create(self, subscription_item: SubscriptionItem) -> SubscriptionItem: # 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; see SubscriptionItem.on_extract_refresh") + 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) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c2dabc330..91acbd218 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1349,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 diff --git a/test/test_subscription.py b/test/test_subscription.py index 045943aac..991d67b84 100644 --- a/test/test_subscription.py +++ b/test/test_subscription.py @@ -116,7 +116,7 @@ def test_create_rejects_none_schedule_id(server: TSC.Server) -> None: the inline-schedule form (no schedule id on the wire). """ target = TSC.Target("view-id", "view") - sub = TSC.SubscriptionItem("subject", None, "user-id", target) # type: ignore[arg-type] + sub = TSC.SubscriptionItem("subject", None, "user-id", target) with pytest.raises(ValueError, match="on_extract_refresh"): server.subscriptions.create(sub) @@ -217,7 +217,7 @@ def test_update_rejects_missing_schedule_id(server: TSC.Server) -> None: 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 # type: ignore[assignment] + sub.schedule_id = None with pytest.raises(ValueError, match="schedule_id is required"): server.subscriptions.update(sub)