Skip to content

Commit 56bee23

Browse files
jacalataclaude
andcommitted
Parse tags on VirtualConnectionItem and enable diff-based update_tags
The `VirtualConnections` REST endpoints have carried tags on their response bodies since Tableau Server 2026.2 / Cloud April 2026 (server commit 2e687548e10, W-21424436, shipping in v262 with REST API 3.30+). The public REST docs still don't reflect this (filed W-23806318); `VirtualConnectionItem` previously didn't parse the <tags> element. Changes: - `VirtualConnectionItem.__init__` gains `tags: set[str]` and `_initial_tags: set[str]` matching every other taggable item. - `VirtualConnectionItem.from_xml` parses <tags><tag label="..."/></tags> via the existing `TagItem.from_xml_element` helper. `_initial_tags` is a shallow copy of `tags` (strings are immutable so copy.copy is sufficient). - `VirtualConnections.update_tags` drops the NotImplementedError override and delegates to `TaggingMixin.update_tags`. Bumped to `@api(version="3.30")` since older server responses don't carry <tags>, meaning `_initial_tags` would be empty and every locally-set tag would be treated as new -- silent no-op on removes. - `VirtualConnections.get_by_id` stamps the id back onto the returned item. The `Get Virtual Connection` server response element omits the `id` attribute (separate server-side bug filed as W-23806343); downstream calls that need result.id (add_tags, delete_tags, update_tags) would fail with 'ID not found.' Client-side workaround until the server fix ships. - Fixed pre-existing "Workbook item must be populated with permissions first" copy-paste error in the `permissions` property error message. - Test fixture `virtual_connections_get.xml` grew a matching empty `<tags/>` element to reflect the current server response shape. - `test_from_xml` and `test_virtual_connection_get_by_id` assert on the new tags/id shape. - New `test_from_xml_populated_tags` covers the tag-parse path including no-back-propagation from `tags` to `_initial_tags`. - New `test_update_tags_diff_round_trip` mocks the PUT/DELETE calls to verify the diff-based mixin end-to-end. - `test_tagging.py` server version bumped from 3.28 to 3.30 so the parametrized virtual_connections update_tags case exercises the real code path. Live-verified end-to-end against a Tableau server (build 2026-08-09, REST API 3.30): fetch a VC, mutate .tags locally, call update_tags, re-fetch, server state matches the local edit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b55f79d commit 56bee23

7 files changed

Lines changed: 155 additions & 31 deletions

File tree

‎tableauserverclient/models/virtual_connection_item.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
import datetime as dt
23
import json
34
from typing import Callable
@@ -10,6 +11,7 @@
1011
from tableauserverclient.models.connection_item import ConnectionItem
1112
from tableauserverclient.models.exceptions import UnpopulatedPropertyError
1213
from tableauserverclient.models.permissions_item import PermissionsRule
14+
from tableauserverclient.models.tag_item import TagItem
1315

1416

1517
class VirtualConnectionItem:
@@ -26,6 +28,12 @@ def __init__(self, name: str) -> None:
2628
self.owner_id: str | None = None
2729
self.content: dict[str, dict] | None = None
2830
self.certification_note: str | None = None
31+
# Tags on virtual connections are populated by the server on List and
32+
# Get responses since Tableau Server 2026.2 / Cloud April 2026
33+
# (REST API 3.30, W-21424436). Callers on earlier server versions
34+
# will see these as empty sets; add_tags / delete_tags still work.
35+
self.tags: set[str] = set()
36+
self._initial_tags: set[str] = set()
2937

3038
def __str__(self) -> str:
3139
return f"{self.__class__.__qualname__}(name={self.name})"
@@ -43,7 +51,7 @@ def id(self) -> str | None:
4351
@property
4452
def permissions(self) -> list[PermissionsRule]:
4553
if self._permissions is None:
46-
error = "Workbook item must be populated with permissions first."
54+
error = "Virtual connection item must be populated with permissions first."
4755
raise UnpopulatedPropertyError(error)
4856
return self._permissions()
4957

@@ -71,6 +79,10 @@ def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem":
7179
v_conn.project_id = p.get("id", None) if ((p := xml.find(".//t:project[@id]", ns)) is not None) else None
7280
v_conn.owner_id = o.get("id", None) if ((o := xml.find(".//t:owner[@id]", ns)) is not None) else None
7381
v_conn.content = json.loads(c.text or "{}") if ((c := xml.find(".//t:content", ns)) is not None) else None
82+
tags_elem = xml.find(".//t:tags", ns)
83+
if tags_elem is not None:
84+
v_conn.tags = TagItem.from_xml_element(tags_elem, ns)
85+
v_conn._initial_tags = copy.copy(v_conn.tags)
7486
return v_conn
7587

7688

‎tableauserverclient/server/endpoint/virtual_connections_endpoint.py‎

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,20 @@ def get_by_id(self, virtual_connection: str | VirtualConnectionItem) -> VirtualC
169169
>>> vc = server.virtual_connections.get_by_id('1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d')
170170
>>> print(vc.name, vc.content)
171171
"""
172-
vconn_id = getattr(virtual_connection, "id", virtual_connection)
172+
if isinstance(virtual_connection, VirtualConnectionItem):
173+
vconn_id = virtual_connection.id or ""
174+
else:
175+
vconn_id = virtual_connection
173176
url = f"{self.baseurl}/{vconn_id}"
174177
server_response = self.get_request(url)
175-
return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
178+
result = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0]
179+
# The Get Virtual Connection response omits the `id` attribute on the
180+
# <virtualConnection> element (server-side response builder never calls
181+
# setId). Stamp it back from the request path so downstream calls that
182+
# need result.id (add_tags, delete_tags, update_tags) work.
183+
if result._id is None:
184+
result._id = vconn_id
185+
return result
176186

177187
@api(version="3.23")
178188
def download(self, virtual_connection: str | VirtualConnectionItem) -> str:
@@ -487,7 +497,8 @@ def add_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Iterab
487497
Returns
488498
-------
489499
set[str]
490-
The set of tags added.
500+
The full tag set on the virtual connection after the add,
501+
as returned by the server.
491502
492503
Examples
493504
--------
@@ -517,23 +528,37 @@ def delete_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Ite
517528
"""
518529
return super().delete_tags(virtual_connection, tags)
519530

520-
@api(version="3.23")
531+
@api(version="3.30")
521532
def update_tags(self, virtual_connection: VirtualConnectionItem) -> None:
522-
"""Not implemented for virtual connections.
523-
524-
`TaggingMixin.update_tags` computes an add/remove diff from the
525-
item's ``tags`` and ``_initial_tags`` attributes. The REST API's
526-
`List Virtual Connections` and `Get Virtual Connection` responses
527-
do not include tags in the schema, so there's no way to populate
528-
those attributes on the item, and no diff basis for `update_tags`.
529-
Tags on virtual connections exist server-side and are manipulated
530-
via the dedicated `Add Tags to Virtual Connection` and
531-
`Delete Tag from Virtual Connection` endpoints, which the
532-
``add_tags`` and ``delete_tags`` methods on this endpoint wrap.
533+
"""Push local tag edits to the server as add / delete calls.
533534
534-
Raises
535-
------
536-
NotImplementedError
537-
Always. Use ``add_tags`` and ``delete_tags`` directly.
535+
Computes the diff between ``virtual_connection.tags`` (mutated
536+
locally) and ``virtual_connection._initial_tags`` (captured at
537+
parse time), then issues `Add Tags to Virtual Connection` and
538+
`Delete Tag from Virtual Connection` calls to bring the server
539+
state in line.
540+
541+
Requires Tableau Server 2026.2 / Cloud April 2026 or later (REST
542+
API 3.30+): earlier server versions do not populate tags on the
543+
response, so ``_initial_tags`` is empty and every tag on the item
544+
would be treated as new.
545+
546+
Parameters
547+
----------
548+
virtual_connection : VirtualConnectionItem
549+
The virtual connection whose tags to synchronize. Must have
550+
been fetched via `get` / `get_by_id` (which populates
551+
``_initial_tags``) then edited via ``virtual_connection.tags``.
552+
553+
Returns
554+
-------
555+
None
556+
557+
Examples
558+
--------
559+
>>> vc = server.virtual_connections.get_by_id(vc_id)
560+
>>> vc.tags.add('finance')
561+
>>> vc.tags.discard('stale')
562+
>>> server.virtual_connections.update_tags(vc)
538563
"""
539-
raise NotImplementedError("Update tags is not implemented for Virtual Connections")
564+
return super().update_tags(virtual_connection)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
3+
<tags>
4+
<tag label="a" />
5+
<tag label="c" />
6+
<tag label="e" />
7+
</tags>
8+
</tsResponse>

‎test/assets/virtual_connections_get.xml‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
33
<pagination pageNumber="1" pageSize="1" totalAvailable="1" />
44
<virtualConnections>
5-
<virtualConnection
6-
createdAt="2024-05-30T09:00:00Z"
7-
hasExtracts="false"
8-
id="8fd7cc02-bb55-4d15-b8b1-9650239efe79"
9-
isCertified="true"
10-
name="vconn"
11-
updatedAt="2024-06-18T09:00:00Z"
12-
webpageUrl="https://test/#/site/site-name/virtualconnections/3"/>
5+
<virtualConnection
6+
createdAt="2024-05-30T09:00:00Z"
7+
hasExtracts="false"
8+
id="8fd7cc02-bb55-4d15-b8b1-9650239efe79"
9+
isCertified="true"
10+
name="vconn"
11+
updatedAt="2024-06-18T09:00:00Z"
12+
webpageUrl="https://test/#/site/site-name/virtualconnections/3">
13+
<tags/>
14+
</virtualConnection>
1315
</virtualConnections>
1416
</tsResponse>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
3+
<pagination pageNumber="1" pageSize="2" totalAvailable="2" />
4+
<virtualConnections>
5+
<virtualConnection createdAt="2024-05-30T09:00:00Z" hasExtracts="false" id="8fd7cc02-bb55-4d15-b8b1-9650239efe79" isCertified="true" name="vconn_with_tags" updatedAt="2024-06-18T09:00:00Z" webpageUrl="https://test/#/site/site-name/virtualconnections/3">
6+
<project id="f5d5ba60-8ca5-11ef-b6bc-bb13b785b995" name="Default"/>
7+
<owner id="7fdad1f1-d521-46ff-a601-51f88e3296f0" name="jmoens"/>
8+
<tags>
9+
<tag label="finance"/>
10+
<tag label="certified"/>
11+
</tags>
12+
</virtualConnection>
13+
<virtualConnection createdAt="2024-03-04T21:51:02Z" hasExtracts="false" id="875228b6-a3dc-4035-bbc0-4a68ab231ca8" isCertified="false" name="vconn_no_tags" updatedAt="2024-03-04T21:52:01Z" webpageUrl="https://test/#/site/site-name/virtualconnections/4">
14+
<project id="f5d5ba60-8ca5-11ef-b6bc-bb13b785b995" name="Default"/>
15+
<owner id="e8954ef5-7132-476a-93af-89178639fa98" name="fcao"/>
16+
<tags/>
17+
</virtualConnection>
18+
</virtualConnections>
19+
</tsResponse>

‎test/test_tagging.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def get_server() -> TSC.Server:
1717
# Fake sign in
1818
server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
1919
server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
20-
server.version = "3.28"
20+
server.version = "3.30" # min for virtual_connections.update_tags (v262 / 2026.2)
2121
return server
2222

2323

‎test/test_virtual_connection.py‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
ASSET_DIR = Path(__file__).parent / "assets"
1212

13+
VIRTUAL_CONNECTION_ADD_TAGS = ASSET_DIR / "virtual_connection_add_tags.xml"
1314
VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml"
15+
VIRTUAL_CONNECTION_GET_WITH_TAGS_XML = ASSET_DIR / "virtual_connections_get_with_tags.xml"
1416
VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml"
1517
VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2 = ASSET_DIR / "virtual_connection_populate_connections2.xml"
1618
VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml"
@@ -46,6 +48,33 @@ def test_from_xml(server: TSC.Server) -> None:
4648
assert virtual_connection.name == "vconn"
4749
assert virtual_connection.updated_at == parse_datetime("2024-06-18T09:00:00Z")
4850
assert virtual_connection.webpage_url == "https://test/#/site/site-name/virtualconnections/3"
51+
# Response element carries <tags> since Tableau Server 2026.2 (API 3.30);
52+
# empty when the VC has no tags.
53+
assert virtual_connection.tags == set()
54+
assert virtual_connection._initial_tags == set()
55+
56+
57+
def test_from_xml_populated_tags(server: TSC.Server) -> None:
58+
"""When the response's <tags> element carries <tag label="..."/> children,
59+
those values populate `tags` and `_initial_tags` so the diff-based
60+
update_tags mixin can compute changes.
61+
"""
62+
items = VirtualConnectionItem.from_response(VIRTUAL_CONNECTION_GET_WITH_TAGS_XML.read_bytes(), server.namespace)
63+
64+
assert len(items) == 2
65+
66+
with_tags = items[0]
67+
assert with_tags.name == "vconn_with_tags"
68+
assert with_tags.tags == {"finance", "certified"}
69+
assert with_tags._initial_tags == {"finance", "certified"}
70+
# _initial_tags is a copy: mutating tags after parse doesn't leak back
71+
with_tags.tags.add("later-added")
72+
assert with_tags._initial_tags == {"finance", "certified"}
73+
74+
without_tags = items[1]
75+
assert without_tags.name == "vconn_no_tags"
76+
assert without_tags.tags == set()
77+
assert without_tags._initial_tags == set()
4978

5079

5180
def test_virtual_connection_get(server: TSC.Server) -> None:
@@ -113,7 +142,10 @@ def test_virtual_connection_get_by_id(server: TSC.Server) -> None:
113142

114143
assert vconn.content
115144
assert vconn.created_at is None
116-
assert vconn.id is None
145+
# The server's Get Virtual Connection response omits the `id` attribute on
146+
# the <virtualConnection> element, but get_by_id stamps it back from the
147+
# request path so downstream tag/permission calls can find it.
148+
assert vconn.id == "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
117149
assert "policyCollection" in vconn.content
118150
assert "revision" in vconn.content
119151

@@ -240,6 +272,32 @@ def test_virtual_connection_publish_draft_overwrite(server: TSC.Server) -> None:
240272
assert "revision" in vconn.content
241273

242274

275+
def test_update_tags_diff_round_trip(server: TSC.Server) -> None:
276+
"""Diff-based update_tags computes add-set / remove-set from the item's
277+
tags and _initial_tags and issues the right PUT / DELETE calls.
278+
279+
Simulates: server returned {a,b,c,d} at fetch time; caller mutated to
280+
{a,c,e}. Expected calls: PUT /tags with {a,c,e} (add path adds e), DELETE
281+
/tags/b, DELETE /tags/d.
282+
"""
283+
server.version = "3.30" # update_tags requires 3.30 (see @api decorator)
284+
vconn_id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79"
285+
add_tags_response = VIRTUAL_CONNECTION_ADD_TAGS.read_text()
286+
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)
290+
291+
vconn = VirtualConnectionItem("vconn")
292+
vconn._id = vconn_id
293+
vconn._initial_tags = {"a", "b", "c", "d"}
294+
vconn.tags = {"a", "c", "e"}
295+
server.virtual_connections.update_tags(vconn)
296+
297+
# add PUT + 2 deletes = 3 calls
298+
assert m.call_count == 3, m.request_history
299+
300+
243301
def test_add_permissions(server: TSC.Server) -> None:
244302
response_xml = ADD_PERMISSIONS.read_text()
245303

0 commit comments

Comments
 (0)