Skip to content

Commit 3a2cc14

Browse files
jacalataclaude
andcommitted
Tighten VirtualConnections review round: PUT body + id-stamp helper
- test_update_tags_diff_round_trip now inspects the PUT XML body and asserts the add-set is exactly the diff; also asserts the two DELETEs target the correct tag URLs. Catches regressions in the add-set that call_count alone couldn't. - Extract the "server omits id, stamp from request path" workaround into VirtualConnectionItem._stamp_id_from_request() so the docstring explaining W-23806343 lives with the class it mutates. Endpoint no longer reaches across encapsulation into result._id. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4c234b4 commit 3a2cc14

3 files changed

Lines changed: 45 additions & 8 deletions

File tree

tableauserverclient/models/virtual_connection_item.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ def __repr__(self) -> str:
4545
def _set_permissions(self, permissions):
4646
self._permissions = permissions
4747

48+
def _stamp_id_from_request(self, vconn_id: str) -> None:
49+
"""Populate ``_id`` from the request path when the server response
50+
omits it. Workaround for an internal server-side ticket: the
51+
response builder for ``GET /virtualConnections/{id}`` does not
52+
emit an ``id`` attribute on the ``<virtualConnection>`` element,
53+
so downstream calls that need ``self.id`` (add_tags, delete_tags,
54+
update_tags, add_permissions, ...) would fail without this
55+
backfill. Remove once the server-side fix ships.
56+
"""
57+
if self._id is None:
58+
self._id = vconn_id
59+
4860
@property
4961
def id(self) -> str | None:
5062
return self._id

tableauserverclient/server/endpoint/virtual_connections_endpoint.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,10 @@ def get_by_id(self, virtual_connection: str | VirtualConnectionItem) -> VirtualC
185185
server_response = self.get_request(url)
186186
result = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
187187
# The Get Virtual Connection response omits the `id` attribute on the
188-
# <virtualConnection> element (server-side response builder never calls
189-
# setId). Stamp it back from the request path so downstream calls that
190-
# need result.id (add_tags, delete_tags, update_tags) work.
191-
if result._id is None:
192-
result._id = vconn_id
188+
# <virtualConnection> element. Backfill it from the request path so
189+
# downstream calls (add_tags, delete_tags, update_tags, ...) work.
190+
# See VirtualConnectionItem._stamp_id_from_request for the ticket ref.
191+
result._stamp_id_from_request(vconn_id)
193192
return result
194193

195194
@api(version="3.23")

test/test_virtual_connection.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
from pathlib import Path
3+
from xml.etree.ElementTree import fromstring
34

45
import pytest
56
import requests_mock
@@ -282,11 +283,12 @@ def test_update_tags_diff_round_trip(server: TSC.Server) -> None:
282283
"""
283284
server.version = "3.30" # update_tags requires 3.30 (see @api decorator)
284285
vconn_id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
286+
tags_url = f"{server.virtual_connections.baseurl}/{vconn_id}/tags"
285287
add_tags_response = VIRTUAL_CONNECTION_ADD_TAGS.read_text()
286288
with requests_mock.mock() as m:
287-
m.put(f"{server.virtual_connections.baseurl}/{vconn_id}/tags", text=add_tags_response)
288-
m.delete(f"{server.virtual_connections.baseurl}/{vconn_id}/tags/b", status_code=204)
289-
m.delete(f"{server.virtual_connections.baseurl}/{vconn_id}/tags/d", status_code=204)
289+
m.put(tags_url, text=add_tags_response)
290+
m.delete(f"{tags_url}/b", status_code=204)
291+
m.delete(f"{tags_url}/d", status_code=204)
290292

291293
vconn = VirtualConnectionItem("vconn")
292294
vconn._id = vconn_id
@@ -297,6 +299,30 @@ def test_update_tags_diff_round_trip(server: TSC.Server) -> None:
297299
# add PUT + 2 deletes = 3 calls
298300
assert m.call_count == 3, m.request_history
299301

302+
# TaggingMixin.update_tags does DELETEs first, then PUT for the
303+
# add-set. Assert the two DELETEs target /tags/b and /tags/d
304+
# (set-iteration order isn't guaranteed, so compare as a set)
305+
# and the PUT is last.
306+
delete_calls = [r for r in m.request_history if r.method == "DELETE"]
307+
put_calls = [r for r in m.request_history if r.method == "PUT"]
308+
assert len(delete_calls) == 2
309+
assert len(put_calls) == 1
310+
assert {r.url for r in delete_calls} == {f"{tags_url}/b", f"{tags_url}/d"}
311+
assert m.request_history[-1].method == "PUT"
312+
assert m.request_history[-1].url == tags_url
313+
314+
# The PUT body must carry exactly the add-set {"e"}. This is the
315+
# test that catches the class of regression Copilot flagged as a
316+
# docstring lie ("PUT with {a,c,e}") -- if update_tags started
317+
# sending the full target set instead of the diff, or dropped the
318+
# add-set entirely, the labels below would change.
319+
put_body = put_calls[0].text
320+
assert put_body is not None
321+
root = fromstring(put_body)
322+
# <tsRequest><tags><tag label="..."/>...</tags></tsRequest> - no namespace
323+
labels = {tag.get("label") for tag in root.findall("./tags/tag")}
324+
assert labels == {"e"}, put_body
325+
300326

301327
def test_add_permissions(server: TSC.Server) -> None:
302328
response_xml = ADD_PERMISSIONS.read_text()

0 commit comments

Comments
 (0)