Skip to content

Commit 81cbdc2

Browse files
jacalataclaude
andcommitted
Address review: Cloud round-trip + hardening on refreshExtractTriggered
- update() now falls back to subscription.schedule.id when schedule_id is None. Fixes Cloud fetch-then-update (schedule_id is always None on Cloud inline-schedule responses per #1875), which the property docstring recommended but the guard rejected. - request_factory create_req: replace assert with if-raise so python -O and direct-import callers get an actionable error instead of an unhelpful ElementTree TypeError. - Add regression tests: (a) manually-constructed SubscriptionItem through update() emits the intended refreshExtractTriggered value, and (b) fetch-parse-mutate-update round-trip preserves the flag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4207f7f commit 81cbdc2

3 files changed

Lines changed: 187 additions & 8 deletions

File tree

tableauserverclient/server/endpoint/subscriptions_endpoint.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,23 @@ def update(self, subscription_item: SubscriptionItem) -> SubscriptionItem:
7171
if not subscription_item.id:
7272
error = "Subscription item missing ID. Subscription must be retrieved from server first."
7373
raise MissingRequiredFieldError(error)
74-
if not subscription_item.schedule_id:
75-
# A subscription round-tripped from an inline-schedule response
76-
# (Cloud/TOL) has schedule_id=None. Updating it in that state
77-
# sends <schedule/> with no id and hits the same wire-layer error
78-
# that create() guards against. See tableau/server-client-python#1658.
79-
raise ValueError("schedule_id is required to update a subscription")
74+
# Cloud subscriptions parsed from a GET response arrive with
75+
# schedule_id=None because the schedule is inlined without an id
76+
# attribute (see SubscriptionItem._parse_element). Fall back to the
77+
# parsed schedule object's id so fetch-then-update -- the safe pattern
78+
# the refresh_extract_triggered docstring recommends -- works on Cloud.
79+
schedule_id = subscription_item.schedule_id
80+
if schedule_id is None and subscription_item.schedule is not None:
81+
schedule_id = subscription_item.schedule.id
82+
if schedule_id is not None:
83+
subscription_item.schedule_id = schedule_id
84+
if not schedule_id:
85+
raise ValueError(
86+
"schedule_id is required to update a subscription. On Tableau "
87+
"Cloud, subscriptions parsed from a GET response may not carry "
88+
"a schedule id; if you constructed this SubscriptionItem "
89+
"manually, set schedule_id explicitly."
90+
)
8091
url = f"{self.baseurl}/{subscription_item.id}"
8192
update_req = RequestFactory.Subscription.update_req(subscription_item)
8293
server_response = self.put_request(url, update_req)

tableauserverclient/server/request_factory.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1351,8 +1351,17 @@ def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionIt
13511351

13521352
# Schedule element. schedule_id can be None on items parsed from
13531353
# inline-schedule responses; subscriptions.create() guards against
1354-
# that before we get here, so the value is non-None at this point.
1355-
assert subscription_item.schedule_id is not None
1354+
# that before we get here. Re-check explicitly with a raise (not
1355+
# assert) so callers importing RequestFactory directly get an
1356+
# actionable error rather than the TypeError ElementTree raises when
1357+
# attrib["id"] is set to None, and so python -O doesn't strip the
1358+
# check entirely.
1359+
if subscription_item.schedule_id is None:
1360+
raise ValueError(
1361+
"schedule_id is required to build a subscription create "
1362+
"request; for on-extract-refresh subscriptions use "
1363+
"SubscriptionItem.on_extract_refresh(...)"
1364+
)
13561365
schedule_element = ET.SubElement(subscription_element, "schedule")
13571366
schedule_element.attrib["id"] = subscription_item.schedule_id
13581367

test/test_subscription.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from datetime import time
12
from pathlib import Path
23

34
import pytest
@@ -275,6 +276,164 @@ def test_parse_response_with_inline_schedule_no_id(server: TSC.Server) -> None:
275276
assert subs[0].schedule is not None
276277

277278

279+
def test_update_manually_built_subscription_emits_flag_false(server: TSC.Server) -> None:
280+
"""Manual-build update() footgun coverage. A fresh SubscriptionItem
281+
constructed locally, with _id assigned to point at an existing
282+
subscription, must round-trip refresh_extract_triggered=False on the wire
283+
when the caller has not touched the flag. This is the exact case the
284+
property docstring warns callers away from -- pin the behavior so we
285+
notice if the emit changes.
286+
"""
287+
from tableauserverclient.server.request_factory import RequestFactory
288+
289+
response_xml = (
290+
'<tsResponse xmlns="http://tableau.com/api">'
291+
' <subscription id="existing-sub-id" subject="Updated subject" attachImage="true" attachPdf="false"'
292+
' suspended="false" refreshExtractTriggered="false">'
293+
' <content id="view-id" type="View" sendIfViewEmpty="true" />'
294+
' <schedule id="sched-id" name="Weekly" />'
295+
' <user id="user-id" />'
296+
" </subscription>"
297+
"</tsResponse>"
298+
)
299+
target = TSC.Target("view-id", "view")
300+
sub = TSC.SubscriptionItem("Updated subject", "sched-id", "user-id", target)
301+
sub._id = "existing-sub-id" # type: ignore[assignment]
302+
303+
with requests_mock.mock() as m:
304+
m.put(server.subscriptions.baseurl + "/existing-sub-id", text=response_xml)
305+
server.subscriptions.update(sub)
306+
body = m.last_request.text or ""
307+
308+
assert 'refreshExtractTriggered="false"' in body
309+
# Sanity: also confirm update_req produces the same thing without a server
310+
body_direct = RequestFactory.Subscription.update_req(sub).decode("utf-8")
311+
assert 'refreshExtractTriggered="false"' in body_direct
312+
313+
314+
def test_update_manually_built_extract_refresh_subscription_emits_flag_true(server: TSC.Server) -> None:
315+
"""Manual-build update() coverage for the on_extract_refresh() variant. A
316+
fresh SubscriptionItem constructed via on_extract_refresh(), with _id
317+
assigned, must emit refreshExtractTriggered='true' when sent through
318+
update().
319+
"""
320+
response_xml = (
321+
'<tsResponse xmlns="http://tableau.com/api">'
322+
' <subscription id="existing-sub-id" subject="On refresh" attachImage="true" attachPdf="false"'
323+
' suspended="false" refreshExtractTriggered="true">'
324+
' <content id="view-id" type="View" sendIfViewEmpty="true" />'
325+
' <schedule id="refresh-sched-id" name="Nightly refresh" />'
326+
' <user id="user-id" />'
327+
" </subscription>"
328+
"</tsResponse>"
329+
)
330+
target = TSC.Target("view-id", "view")
331+
sub = TSC.SubscriptionItem.on_extract_refresh(
332+
subject="On refresh",
333+
extract_refresh_schedule_id="refresh-sched-id",
334+
user_id="user-id",
335+
target=target,
336+
)
337+
sub._id = "existing-sub-id" # type: ignore[assignment]
338+
339+
with requests_mock.mock() as m:
340+
m.put(server.subscriptions.baseurl + "/existing-sub-id", text=response_xml)
341+
server.subscriptions.update(sub)
342+
body = m.last_request.text or ""
343+
344+
assert 'refreshExtractTriggered="true"' in body
345+
346+
347+
def test_update_round_trip_preserves_refresh_extract_triggered(server: TSC.Server) -> None:
348+
"""Fetch-then-mutate-then-update round-trip preserves the flag. Parse a
349+
subscription XML with refreshExtractTriggered='true', mutate an unrelated
350+
field (subject), send through update(), and confirm the emitted body
351+
still carries refreshExtractTriggered='true'. This is the safe pattern
352+
the property docstring recommends and it deserves an explicit test.
353+
"""
354+
parsed = TSC.SubscriptionItem.from_response(
355+
(
356+
b'<tsResponse xmlns="http://tableau.com/api">'
357+
b" <subscriptions>"
358+
b' <subscription id="existing-sub-id" subject="Original subject"'
359+
b' attachImage="true" attachPdf="false" suspended="false"'
360+
b' refreshExtractTriggered="true">'
361+
b' <content id="view-id" type="View" sendIfViewEmpty="false" />'
362+
b' <schedule id="refresh-sched-id" name="Nightly refresh" />'
363+
b' <user id="user-id" />'
364+
b" </subscription>"
365+
b" </subscriptions>"
366+
b"</tsResponse>"
367+
),
368+
{"t": "http://tableau.com/api"},
369+
)
370+
assert len(parsed) == 1
371+
sub = parsed[0]
372+
assert sub.refresh_extract_triggered is True
373+
sub.subject = "Mutated subject"
374+
375+
response_xml = (
376+
'<tsResponse xmlns="http://tableau.com/api">'
377+
' <subscription id="existing-sub-id" subject="Mutated subject" attachImage="true" attachPdf="false"'
378+
' suspended="false" refreshExtractTriggered="true">'
379+
' <content id="view-id" type="View" sendIfViewEmpty="false" />'
380+
' <schedule id="refresh-sched-id" name="Nightly refresh" />'
381+
' <user id="user-id" />'
382+
" </subscription>"
383+
"</tsResponse>"
384+
)
385+
with requests_mock.mock() as m:
386+
m.put(server.subscriptions.baseurl + "/existing-sub-id", text=response_xml)
387+
server.subscriptions.update(sub)
388+
body = m.last_request.text or ""
389+
390+
assert 'refreshExtractTriggered="true"' in body
391+
assert 'subject="Mutated subject"' in body
392+
393+
394+
def test_update_falls_back_to_schedule_object_id_on_cloud(server: TSC.Server) -> None:
395+
"""Cloud fetch-then-update guard. When a SubscriptionItem arrives with
396+
schedule_id=None but a parsed schedule object whose id is populated
397+
(a shape a future _parse_element could produce, or a shape a caller can
398+
build directly), update() must fall back to schedule.id instead of
399+
rejecting the item. This unblocks the fetch-then-update pattern the
400+
refresh_extract_triggered property docstring recommends on Cloud.
401+
"""
402+
target = TSC.Target("view-id", "view")
403+
sub = TSC.SubscriptionItem("Cloud sub", None, "user-id", target)
404+
sub._id = "existing-sub-id" # type: ignore[assignment]
405+
# Build a schedule object with an id but leave schedule_id None -- the
406+
# Cloud inline-schedule case the endpoint fallback exists to handle.
407+
schedule = TSC.ScheduleItem(
408+
"Nightly refresh",
409+
50,
410+
TSC.ScheduleItem.Type.Extract,
411+
"Parallel",
412+
TSC.DailyInterval(time(2, 0)),
413+
)
414+
schedule._id = "inline-sched-id"
415+
sub.schedule = schedule # type: ignore[assignment]
416+
assert sub.schedule_id is None
417+
assert sub.schedule.id == "inline-sched-id" # type: ignore[attr-defined]
418+
419+
response_xml = (
420+
'<tsResponse xmlns="http://tableau.com/api">'
421+
' <subscription id="existing-sub-id" subject="Cloud sub" attachImage="true" attachPdf="false"'
422+
' suspended="false" refreshExtractTriggered="false">'
423+
' <content id="view-id" type="View" sendIfViewEmpty="true" />'
424+
' <schedule id="inline-sched-id" name="Nightly refresh" />'
425+
' <user id="user-id" />'
426+
" </subscription>"
427+
"</tsResponse>"
428+
)
429+
with requests_mock.mock() as m:
430+
m.put(server.subscriptions.baseurl + "/existing-sub-id", text=response_xml)
431+
server.subscriptions.update(sub)
432+
body = m.last_request.text or ""
433+
434+
assert 'id="inline-sched-id"' in body
435+
436+
278437
def test_parse_response_missing_refresh_extract_triggered_defaults_false(server: TSC.Server) -> None:
279438
"""Backward compatibility: a subscription XML element without the attribute
280439
parses back to refresh_extract_triggered=False.

0 commit comments

Comments
 (0)