From 28f333278b109e3c27b63df74534983c42d48dd3 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 10 Aug 2026 18:36:35 -0700 Subject: [PATCH 1/3] docs: numpy-style docstrings on VirtualConnections endpoint (14 methods) Adds a class-level docstring plus 15 method docstrings (14 public methods + the private _get_virtual_database_connections is unchanged). Content ported from api-ref.md's Virtual Connections section on gh-pages so once the Sphinx pipeline in #1832 is live the generated output will cover what the handwritten page does today. Virtual Connections was the largest remaining chunk in the needs_docstring bucket per the api-ref migration audit (14 of the 41 remaining public endpoint methods without docstrings, after the 15 Favorites methods in #1855). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../endpoint/virtual_connections_endpoint.py | 360 +++++++++++++++++- 1 file changed, 358 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index fc00dac1e..1955a0234 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -20,6 +20,19 @@ class VirtualConnections(QuerysetEndpoint[VirtualConnectionItem], TaggingMixin): + """Access virtual connection resources on Tableau Server. + + Using this endpoint you can list virtual connections on a site, retrieve + a specific virtual connection's JSON content definition, publish new + virtual connections, update metadata or the underlying database + connection details, manage revisions, and control permissions and tags. + + The virtual connection resources are represented by the + ``VirtualConnectionItem`` class in ``tableauserverclient.models``. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_virtual_connections.htm + """ + def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -30,6 +43,26 @@ def baseurl(self) -> str: @api(version="3.18") def get(self, req_options: RequestOptions | None = None) -> tuple[list[VirtualConnectionItem], PaginationItem]: + """Return a list of virtual connections on the site. + + REST API: `List Virtual Connections `_ + + Parameters + ---------- + req_options : RequestOptions, optional + Request options such as page size and filters. + + Returns + ------- + tuple[list[VirtualConnectionItem], PaginationItem] + A pair of the page of virtual connections and pagination info. + + Examples + -------- + >>> all_vcs, pagination = server.virtual_connections.get() + >>> for vc in all_vcs: + ... print(vc.id, vc.name) + """ server_response = self.get_request(self.baseurl, req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) virtual_connections = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace) @@ -37,6 +70,30 @@ def get(self, req_options: RequestOptions | None = None) -> tuple[list[VirtualCo @api(version="3.18") def populate_connections(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem: + """Populate the database connections for a virtual connection. + + After calling this method, iterate ``virtual_connection.connections`` + to access the underlying ``ConnectionItem`` objects. + + REST API: `List Virtual Connection Database Connections `_ + + Parameters + ---------- + virtual_connection : VirtualConnectionItem + The virtual connection to populate connections for. + + Returns + ------- + VirtualConnectionItem + The same item, with ``connections`` now available for iteration. + + Examples + -------- + >>> server.virtual_connections.populate_connections(vc) + >>> for conn in vc.connections: + ... print(conn.id, conn.server_address) + """ + def _connection_fetcher(): return Pager(partial(self._get_virtual_database_connections, virtual_connection)) @@ -56,6 +113,31 @@ def _get_virtual_database_connections( def update_connection_db_connection( self, virtual_connection: str | VirtualConnectionItem, connection: ConnectionItem ) -> ConnectionItem: + """Update the database connection details inside a virtual connection. + + REST API: `Update Virtual Connection Database Connection `_ + + Parameters + ---------- + virtual_connection : VirtualConnectionItem or str + The parent virtual connection, or its ID. + + connection : ConnectionItem + The connection object with updated fields (server address, port, + username, etc.). ``connection.id`` must be set. + + Returns + ------- + ConnectionItem + The updated connection as returned by the server. + + Examples + -------- + >>> server.virtual_connections.populate_connections(vc) + >>> conn = list(vc.connections)[0] + >>> conn.server_address = 'new-db-server.example.com' + >>> updated_conn = server.virtual_connections.update_connection_db_connection(vc, conn) + """ vconn_id = getattr(virtual_connection, "id", virtual_connection) url = f"{self.baseurl}/{vconn_id}/connections/{connection.id}/modify" xml_request = RequestFactory.VirtualConnection.update_db_connection(connection) @@ -64,6 +146,28 @@ def update_connection_db_connection( @api(version="3.23") def get_by_id(self, virtual_connection: str | VirtualConnectionItem) -> VirtualConnectionItem: + """Return the details of a specific virtual connection. + + The returned item has its ``content`` attribute populated with the + virtual connection's JSON definition. + + REST API: `Get Virtual Connection `_ + + Parameters + ---------- + virtual_connection : VirtualConnectionItem or str + The virtual connection, or its ID. + + Returns + ------- + VirtualConnectionItem + The virtual connection with ``content`` populated. + + Examples + -------- + >>> vc = server.virtual_connections.get_by_id('1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d') + >>> print(vc.name, vc.content) + """ vconn_id = getattr(virtual_connection, "id", virtual_connection) url = f"{self.baseurl}/{vconn_id}" server_response = self.get_request(url) @@ -71,11 +175,57 @@ def get_by_id(self, virtual_connection: str | VirtualConnectionItem) -> VirtualC @api(version="3.23") def download(self, virtual_connection: str | VirtualConnectionItem) -> str: + """Return the JSON definition of a virtual connection as a string. + + Convenience wrapper around ``get_by_id`` that returns just the + serialized ``content``. + + REST API: `Get Virtual Connection `_ + + Parameters + ---------- + virtual_connection : VirtualConnectionItem or str + The virtual connection, or its ID. + + Returns + ------- + str + The virtual connection JSON content, serialized. + + Examples + -------- + >>> content_json = server.virtual_connections.download(vc) + >>> with open('vc_backup.json', 'w') as f: + ... f.write(content_json) + """ v_conn = self.get_by_id(virtual_connection) return json.dumps(v_conn.content) @api(version="3.23") def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnectionItem: + """Update virtual connection metadata (name, project, owner, certification, etc.). + + This does not modify the underlying JSON content of the virtual + connection; use ``update_connection_db_connection`` for that. + + REST API: `Update Virtual Connection `_ + + Parameters + ---------- + virtual_connection : VirtualConnectionItem + The virtual connection to update. Must have a valid ``id``. + + Returns + ------- + VirtualConnectionItem + The updated virtual connection as returned by the server. + + Examples + -------- + >>> vc.is_certified = True + >>> vc.certification_note = 'Approved by data team' + >>> updated_vc = server.virtual_connections.update(vc) + """ url = f"{self.baseurl}/{virtual_connection.id}" xml_request = RequestFactory.VirtualConnection.update(virtual_connection) server_response = self.put_request(url, xml_request) @@ -85,6 +235,27 @@ def update(self, virtual_connection: VirtualConnectionItem) -> VirtualConnection def get_revisions( self, virtual_connection: VirtualConnectionItem, req_options: RequestOptions | None = None ) -> tuple[list[RevisionItem], PaginationItem]: + """Return a list of revisions for a virtual connection. + + Parameters + ---------- + virtual_connection : VirtualConnectionItem + The virtual connection whose revisions to retrieve. + + req_options : RequestOptions, optional + Request options such as page size. + + Returns + ------- + tuple[list[RevisionItem], PaginationItem] + A pair of the page of revisions and pagination info. + + Examples + -------- + >>> revisions, pagination = server.virtual_connections.get_revisions(vc) + >>> for rev in revisions: + ... print(rev.revision_number, rev.created_at) + """ server_response = self.get_request(f"{self.baseurl}/{virtual_connection.id}/revisions", req_options) pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) revisions = RevisionItem.from_response(server_response.content, self.parent_srv.namespace, virtual_connection) @@ -92,6 +263,26 @@ def get_revisions( @api(version="3.23") def download_revision(self, virtual_connection: VirtualConnectionItem, revision_number: int) -> str: + """Return the JSON definition of a specific revision as a string. + + Parameters + ---------- + virtual_connection : VirtualConnectionItem + The virtual connection whose revision to download. + + revision_number : int + The revision number to download. + + Returns + ------- + str + The virtual connection JSON content at that revision, serialized. + + Examples + -------- + >>> revisions, _ = server.virtual_connections.get_revisions(vc) + >>> json_str = server.virtual_connections.download_revision(vc, revisions[0].revision_number) + """ url = f"{self.baseurl}/{virtual_connection.id}/revisions/{revision_number}" server_response = self.get_request(url) virtual_connection = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -99,6 +290,23 @@ def download_revision(self, virtual_connection: VirtualConnectionItem, revision_ @api(version="3.23") def delete(self, virtual_connection: VirtualConnectionItem | str) -> None: + """Delete a virtual connection from the site. + + REST API: `Delete Virtual Connection `_ + + Parameters + ---------- + virtual_connection : VirtualConnectionItem or str + The virtual connection, or its ID. + + Returns + ------- + None + + Examples + -------- + >>> server.virtual_connections.delete(vc.id) + """ vconn_id = getattr(virtual_connection, "id", virtual_connection) self.delete_request(f"{self.baseurl}/{vconn_id}") @@ -110,8 +318,7 @@ def publish( mode: str = "CreateNew", publish_as_draft: bool = False, ) -> VirtualConnectionItem: - """ - Publish a virtual connection to the server. + """Publish a virtual connection to the server. For the virtual_connection object, name, project_id, and owner_id are required. @@ -125,6 +332,49 @@ def publish( If publish_as_draft is True, the virtual connection will be published as a draft, and the id of the draft will be on the response object. + + REST API: `Publish Virtual Connection `_ + + Parameters + ---------- + virtual_connection : VirtualConnectionItem + The virtual connection to publish. Must have ``name``, + ``project_id``, and ``owner_id`` set. + + virtual_connection_content : str + The virtual connection JSON definition, either as a JSON string + or as a path to a JSON file on disk. + + mode : str + ``"CreateNew"`` (default) or ``"Overwrite"``. Use ``"Overwrite"`` + to replace an existing virtual connection. + + publish_as_draft : bool + If ``True``, publish as a draft. The id on the returned item + will be the draft's id. Default is ``False``. + + Returns + ------- + VirtualConnectionItem + The published virtual connection as returned by the server. + + Raises + ------ + ValueError + If ``mode`` is not ``"CreateNew"`` or ``"Overwrite"``. + RuntimeError + If ``virtual_connection_content`` is neither valid JSON nor a + path to an existing file. + + Examples + -------- + >>> new_vc = TSC.VirtualConnectionItem('My Virtual Connection') + >>> new_vc.project_id = project.id + >>> new_vc.owner_id = user.id + >>> published = server.virtual_connections.publish( + ... new_vc, '/path/to/vc_definition.json', mode='CreateNew' + ... ) + >>> print(published.id) """ try: json.loads(virtual_connection_content) @@ -147,24 +397,130 @@ def publish( @api(version="3.22") def populate_permissions(self, item: VirtualConnectionItem) -> None: + """Populate the permissions for a virtual connection. + + After calling this method, iterate ``item.permissions`` to access the + ``PermissionsRule`` objects. + + Parameters + ---------- + item : VirtualConnectionItem + The virtual connection to populate permissions for. + + Returns + ------- + None + Permissions are populated on ``item.permissions``. + + Examples + -------- + >>> server.virtual_connections.populate_permissions(vc) + >>> for rule in vc.permissions: + ... print(rule) + """ self._permissions.populate(item) @api(version="3.22") def add_permissions(self, resource, rules): + """Add or update permissions on a virtual connection. + + Parameters + ---------- + resource : VirtualConnectionItem + The virtual connection to update permissions on. + + rules : list[PermissionsRule] + The permission rules to apply. + + Returns + ------- + list[PermissionsRule] + The updated list of permission rules. + + Examples + -------- + >>> permission = TSC.PermissionsRule( + ... TSC.UserItem.as_reference(user.id), + ... {'Connect': 'Allow'} + ... ) + >>> server.virtual_connections.add_permissions(vc, [permission]) + """ return self._permissions.update(resource, rules) @api(version="3.22") def delete_permission(self, item, capability_item): + """Remove a specific permission from a virtual connection. + + Parameters + ---------- + item : VirtualConnectionItem + The virtual connection to remove the permission from. + + capability_item : PermissionsRule + The permission rule to remove. + + Returns + ------- + None + + Examples + -------- + >>> server.virtual_connections.delete_permission(vc, permission_rule) + """ return self._permissions.delete(item, capability_item) @api(version="3.23") def add_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Iterable[str] | str) -> set[str]: + """Add one or more tags to a virtual connection. + + Parameters + ---------- + virtual_connection : VirtualConnectionItem or str + The virtual connection to tag, or its ID. + + tags : str or iterable of str + A single tag or an iterable of tag strings. + + Returns + ------- + set[str] + The set of tags added. + + Examples + -------- + >>> server.virtual_connections.add_tags(vc, ['finance', 'certified']) + """ return super().add_tags(virtual_connection, tags) @api(version="3.23") def delete_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Iterable[str] | str) -> None: + """Remove one or more tags from a virtual connection. + + Parameters + ---------- + virtual_connection : VirtualConnectionItem or str + The virtual connection to remove tags from, or its ID. + + tags : str or iterable of str + A single tag or an iterable of tag strings. + + Returns + ------- + None + + Examples + -------- + >>> server.virtual_connections.delete_tags(vc, 'finance') + """ return super().delete_tags(virtual_connection, tags) @api(version="3.23") def update_tags(self, virtual_connection: VirtualConnectionItem) -> None: + """Not implemented for virtual connections. + + Raises + ------ + NotImplementedError + Always. Use ``add_tags`` and ``delete_tags`` instead. + """ raise NotImplementedError("Update tags is not implemented for Virtual Connections") From b55f79de6877e40250a71e29527b2bbd6b2f1508 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Mon, 10 Aug 2026 18:59:38 -0700 Subject: [PATCH 2/3] Address self-review: type hints, param renames, update_tags rationale - add_permissions and delete_permission grow proper type hints (VirtualConnectionItem, list[PermissionsRule], PermissionsRule) so the docstring claims and the signatures agree. - Rename docstring/signature params to `virtual_connection` throughout the permissions methods; `item`, `resource`, `capability_item` were inconsistent with the rest of the class and with each other. - Rename `delete_permission`'s second param from `capability_item` to `permission_rule` (which is what it actually is). - Class docstring's REST API line now uses the same RST link style as every method's REST API line. - update_tags docstring documents WHY it's not implemented: the REST API's virtual-connection response schema doesn't include tags, so there's no way to populate _initial_tags on the item and no diff basis for the mixin's update_tags. Tags exist server-side and are manipulated via the add-tags / delete-tag endpoints that add_tags / delete_tags already wrap. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../endpoint/virtual_connections_endpoint.py | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index 1955a0234..5a5cd1661 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -6,6 +6,7 @@ from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.models.permissions_item import PermissionsRule from tableauserverclient.models.revision_item import RevisionItem from tableauserverclient.models.virtual_connection_item import VirtualConnectionItem from tableauserverclient.server.request_factory import RequestFactory @@ -30,7 +31,7 @@ class VirtualConnections(QuerysetEndpoint[VirtualConnectionItem], TaggingMixin): The virtual connection resources are represented by the ``VirtualConnectionItem`` class in ``tableauserverclient.models``. - REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_virtual_connections.htm + REST API: `Virtual Connections Methods `_ """ def __init__(self, parent_srv: "Server") -> None: @@ -396,21 +397,21 @@ def publish( return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] @api(version="3.22") - def populate_permissions(self, item: VirtualConnectionItem) -> None: + def populate_permissions(self, virtual_connection: VirtualConnectionItem) -> None: """Populate the permissions for a virtual connection. - After calling this method, iterate ``item.permissions`` to access the - ``PermissionsRule`` objects. + After calling this method, iterate ``virtual_connection.permissions`` + to access the ``PermissionsRule`` objects. Parameters ---------- - item : VirtualConnectionItem + virtual_connection : VirtualConnectionItem The virtual connection to populate permissions for. Returns ------- None - Permissions are populated on ``item.permissions``. + Permissions are populated on ``virtual_connection.permissions``. Examples -------- @@ -418,15 +419,17 @@ def populate_permissions(self, item: VirtualConnectionItem) -> None: >>> for rule in vc.permissions: ... print(rule) """ - self._permissions.populate(item) + self._permissions.populate(virtual_connection) @api(version="3.22") - def add_permissions(self, resource, rules): + def add_permissions( + self, virtual_connection: VirtualConnectionItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: """Add or update permissions on a virtual connection. Parameters ---------- - resource : VirtualConnectionItem + virtual_connection : VirtualConnectionItem The virtual connection to update permissions on. rules : list[PermissionsRule] @@ -435,7 +438,7 @@ def add_permissions(self, resource, rules): Returns ------- list[PermissionsRule] - The updated list of permission rules. + The updated list of permission rules as returned by the server. Examples -------- @@ -445,18 +448,18 @@ def add_permissions(self, resource, rules): ... ) >>> server.virtual_connections.add_permissions(vc, [permission]) """ - return self._permissions.update(resource, rules) + return self._permissions.update(virtual_connection, rules) @api(version="3.22") - def delete_permission(self, item, capability_item): + def delete_permission(self, virtual_connection: VirtualConnectionItem, permission_rule: PermissionsRule) -> None: """Remove a specific permission from a virtual connection. Parameters ---------- - item : VirtualConnectionItem + virtual_connection : VirtualConnectionItem The virtual connection to remove the permission from. - capability_item : PermissionsRule + permission_rule : PermissionsRule The permission rule to remove. Returns @@ -467,7 +470,7 @@ def delete_permission(self, item, capability_item): -------- >>> server.virtual_connections.delete_permission(vc, permission_rule) """ - return self._permissions.delete(item, capability_item) + return self._permissions.delete(virtual_connection, permission_rule) @api(version="3.23") def add_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Iterable[str] | str) -> set[str]: @@ -518,9 +521,19 @@ def delete_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Ite def update_tags(self, virtual_connection: VirtualConnectionItem) -> None: """Not implemented for virtual connections. + `TaggingMixin.update_tags` computes an add/remove diff from the + item's ``tags`` and ``_initial_tags`` attributes. The REST API's + `List Virtual Connections` and `Get Virtual Connection` responses + do not include tags in the schema, so there's no way to populate + those attributes on the item, and no diff basis for `update_tags`. + Tags on virtual connections exist server-side and are manipulated + via the dedicated `Add Tags to Virtual Connection` and + `Delete Tag from Virtual Connection` endpoints, which the + ``add_tags`` and ``delete_tags`` methods on this endpoint wrap. + Raises ------ NotImplementedError - Always. Use ``add_tags`` and ``delete_tags`` instead. + Always. Use ``add_tags`` and ``delete_tags`` directly. """ raise NotImplementedError("Update tags is not implemented for Virtual Connections") From 56bee23f5f11af1ae3fd996e1dc7bc6109b260a9 Mon Sep 17 00:00:00 2001 From: Jac Fitzgerald Date: Tue, 11 Aug 2026 10:06:02 -0700 Subject: [PATCH 3/3] 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 element. Changes: - `VirtualConnectionItem.__init__` gains `tags: set[str]` and `_initial_tags: set[str]` matching every other taggable item. - `VirtualConnectionItem.from_xml` parses 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 , 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 `` 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) --- .../models/virtual_connection_item.py | 14 +++- .../endpoint/virtual_connections_endpoint.py | 65 +++++++++++++------ test/assets/virtual_connection_add_tags.xml | 8 +++ test/assets/virtual_connections_get.xml | 18 ++--- .../virtual_connections_get_with_tags.xml | 19 ++++++ test/test_tagging.py | 2 +- test/test_virtual_connection.py | 60 ++++++++++++++++- 7 files changed, 155 insertions(+), 31 deletions(-) create mode 100644 test/assets/virtual_connection_add_tags.xml create mode 100644 test/assets/virtual_connections_get_with_tags.xml diff --git a/tableauserverclient/models/virtual_connection_item.py b/tableauserverclient/models/virtual_connection_item.py index 7ec2efe36..2177bbae6 100644 --- a/tableauserverclient/models/virtual_connection_item.py +++ b/tableauserverclient/models/virtual_connection_item.py @@ -1,3 +1,4 @@ +import copy import datetime as dt import json from typing import Callable @@ -10,6 +11,7 @@ from tableauserverclient.models.connection_item import ConnectionItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.tag_item import TagItem class VirtualConnectionItem: @@ -26,6 +28,12 @@ def __init__(self, name: str) -> None: self.owner_id: str | None = None self.content: dict[str, dict] | None = None self.certification_note: str | None = None + # Tags on virtual connections are populated by the server on List and + # Get responses since Tableau Server 2026.2 / Cloud April 2026 + # (REST API 3.30, W-21424436). Callers on earlier server versions + # will see these as empty sets; add_tags / delete_tags still work. + self.tags: set[str] = set() + self._initial_tags: set[str] = set() def __str__(self) -> str: return f"{self.__class__.__qualname__}(name={self.name})" @@ -43,7 +51,7 @@ def id(self) -> str | None: @property def permissions(self) -> list[PermissionsRule]: if self._permissions is None: - error = "Workbook item must be populated with permissions first." + error = "Virtual connection item must be populated with permissions first." raise UnpopulatedPropertyError(error) return self._permissions() @@ -71,6 +79,10 @@ def from_xml(cls, xml: Element, ns: dict[str, str]) -> "VirtualConnectionItem": v_conn.project_id = p.get("id", None) if ((p := xml.find(".//t:project[@id]", ns)) is not None) else None v_conn.owner_id = o.get("id", None) if ((o := xml.find(".//t:owner[@id]", ns)) is not None) else None v_conn.content = json.loads(c.text or "{}") if ((c := xml.find(".//t:content", ns)) is not None) else None + tags_elem = xml.find(".//t:tags", ns) + if tags_elem is not None: + v_conn.tags = TagItem.from_xml_element(tags_elem, ns) + v_conn._initial_tags = copy.copy(v_conn.tags) return v_conn diff --git a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py index 5a5cd1661..9a305d5ab 100644 --- a/tableauserverclient/server/endpoint/virtual_connections_endpoint.py +++ b/tableauserverclient/server/endpoint/virtual_connections_endpoint.py @@ -169,10 +169,20 @@ def get_by_id(self, virtual_connection: str | VirtualConnectionItem) -> VirtualC >>> vc = server.virtual_connections.get_by_id('1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d') >>> print(vc.name, vc.content) """ - vconn_id = getattr(virtual_connection, "id", virtual_connection) + if isinstance(virtual_connection, VirtualConnectionItem): + vconn_id = virtual_connection.id or "" + else: + vconn_id = virtual_connection url = f"{self.baseurl}/{vconn_id}" server_response = self.get_request(url) - return VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + result = VirtualConnectionItem.from_response(server_response.content, self.parent_srv.namespace)[0] + # The Get Virtual Connection response omits the `id` attribute on the + # element (server-side response builder never calls + # setId). Stamp it back from the request path so downstream calls that + # need result.id (add_tags, delete_tags, update_tags) work. + if result._id is None: + result._id = vconn_id + return result @api(version="3.23") def download(self, virtual_connection: str | VirtualConnectionItem) -> str: @@ -487,7 +497,8 @@ def add_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Iterab Returns ------- set[str] - The set of tags added. + The full tag set on the virtual connection after the add, + as returned by the server. Examples -------- @@ -517,23 +528,37 @@ def delete_tags(self, virtual_connection: VirtualConnectionItem | str, tags: Ite """ return super().delete_tags(virtual_connection, tags) - @api(version="3.23") + @api(version="3.30") def update_tags(self, virtual_connection: VirtualConnectionItem) -> None: - """Not implemented for virtual connections. - - `TaggingMixin.update_tags` computes an add/remove diff from the - item's ``tags`` and ``_initial_tags`` attributes. The REST API's - `List Virtual Connections` and `Get Virtual Connection` responses - do not include tags in the schema, so there's no way to populate - those attributes on the item, and no diff basis for `update_tags`. - Tags on virtual connections exist server-side and are manipulated - via the dedicated `Add Tags to Virtual Connection` and - `Delete Tag from Virtual Connection` endpoints, which the - ``add_tags`` and ``delete_tags`` methods on this endpoint wrap. + """Push local tag edits to the server as add / delete calls. - Raises - ------ - NotImplementedError - Always. Use ``add_tags`` and ``delete_tags`` directly. + Computes the diff between ``virtual_connection.tags`` (mutated + locally) and ``virtual_connection._initial_tags`` (captured at + parse time), then issues `Add Tags to Virtual Connection` and + `Delete Tag from Virtual Connection` calls to bring the server + state in line. + + Requires Tableau Server 2026.2 / Cloud April 2026 or later (REST + API 3.30+): earlier server versions do not populate tags on the + response, so ``_initial_tags`` is empty and every tag on the item + would be treated as new. + + Parameters + ---------- + virtual_connection : VirtualConnectionItem + The virtual connection whose tags to synchronize. Must have + been fetched via `get` / `get_by_id` (which populates + ``_initial_tags``) then edited via ``virtual_connection.tags``. + + Returns + ------- + None + + Examples + -------- + >>> vc = server.virtual_connections.get_by_id(vc_id) + >>> vc.tags.add('finance') + >>> vc.tags.discard('stale') + >>> server.virtual_connections.update_tags(vc) """ - raise NotImplementedError("Update tags is not implemented for Virtual Connections") + return super().update_tags(virtual_connection) diff --git a/test/assets/virtual_connection_add_tags.xml b/test/assets/virtual_connection_add_tags.xml new file mode 100644 index 000000000..f3761e086 --- /dev/null +++ b/test/assets/virtual_connection_add_tags.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/assets/virtual_connections_get.xml b/test/assets/virtual_connections_get.xml index f1f410e4c..7efedfd96 100644 --- a/test/assets/virtual_connections_get.xml +++ b/test/assets/virtual_connections_get.xml @@ -2,13 +2,15 @@ - + + + diff --git a/test/assets/virtual_connections_get_with_tags.xml b/test/assets/virtual_connections_get_with_tags.xml new file mode 100644 index 000000000..7feac6576 --- /dev/null +++ b/test/assets/virtual_connections_get_with_tags.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/test/test_tagging.py b/test/test_tagging.py index 87f444fce..0b40968a5 100644 --- a/test/test_tagging.py +++ b/test/test_tagging.py @@ -17,7 +17,7 @@ def get_server() -> TSC.Server: # Fake sign in server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - server.version = "3.28" + server.version = "3.30" # min for virtual_connections.update_tags (v262 / 2026.2) return server diff --git a/test/test_virtual_connection.py b/test/test_virtual_connection.py index 210f605c8..19cf0a83a 100644 --- a/test/test_virtual_connection.py +++ b/test/test_virtual_connection.py @@ -10,7 +10,9 @@ ASSET_DIR = Path(__file__).parent / "assets" +VIRTUAL_CONNECTION_ADD_TAGS = ASSET_DIR / "virtual_connection_add_tags.xml" VIRTUAL_CONNECTION_GET_XML = ASSET_DIR / "virtual_connections_get.xml" +VIRTUAL_CONNECTION_GET_WITH_TAGS_XML = ASSET_DIR / "virtual_connections_get_with_tags.xml" VIRTUAL_CONNECTION_POPULATE_CONNECTIONS = ASSET_DIR / "virtual_connection_populate_connections.xml" VIRTUAL_CONNECTION_POPULATE_CONNECTIONS2 = ASSET_DIR / "virtual_connection_populate_connections2.xml" VC_DB_CONN_UPDATE = ASSET_DIR / "virtual_connection_database_connection_update.xml" @@ -46,6 +48,33 @@ def test_from_xml(server: TSC.Server) -> None: assert virtual_connection.name == "vconn" assert virtual_connection.updated_at == parse_datetime("2024-06-18T09:00:00Z") assert virtual_connection.webpage_url == "https://test/#/site/site-name/virtualconnections/3" + # Response element carries since Tableau Server 2026.2 (API 3.30); + # empty when the VC has no tags. + assert virtual_connection.tags == set() + assert virtual_connection._initial_tags == set() + + +def test_from_xml_populated_tags(server: TSC.Server) -> None: + """When the response's element carries children, + those values populate `tags` and `_initial_tags` so the diff-based + update_tags mixin can compute changes. + """ + items = VirtualConnectionItem.from_response(VIRTUAL_CONNECTION_GET_WITH_TAGS_XML.read_bytes(), server.namespace) + + assert len(items) == 2 + + with_tags = items[0] + assert with_tags.name == "vconn_with_tags" + assert with_tags.tags == {"finance", "certified"} + assert with_tags._initial_tags == {"finance", "certified"} + # _initial_tags is a copy: mutating tags after parse doesn't leak back + with_tags.tags.add("later-added") + assert with_tags._initial_tags == {"finance", "certified"} + + without_tags = items[1] + assert without_tags.name == "vconn_no_tags" + assert without_tags.tags == set() + assert without_tags._initial_tags == set() def test_virtual_connection_get(server: TSC.Server) -> None: @@ -113,7 +142,10 @@ def test_virtual_connection_get_by_id(server: TSC.Server) -> None: assert vconn.content assert vconn.created_at is None - assert vconn.id is None + # The server's Get Virtual Connection response omits the `id` attribute on + # the element, but get_by_id stamps it back from the + # request path so downstream tag/permission calls can find it. + assert vconn.id == "8fd7cc02-bb55-4d15-b8b1-9650239efe79" assert "policyCollection" in vconn.content assert "revision" in vconn.content @@ -240,6 +272,32 @@ def test_virtual_connection_publish_draft_overwrite(server: TSC.Server) -> None: assert "revision" in vconn.content +def test_update_tags_diff_round_trip(server: TSC.Server) -> None: + """Diff-based update_tags computes add-set / remove-set from the item's + tags and _initial_tags and issues the right PUT / DELETE calls. + + Simulates: server returned {a,b,c,d} at fetch time; caller mutated to + {a,c,e}. Expected calls: PUT /tags with {a,c,e} (add path adds e), DELETE + /tags/b, DELETE /tags/d. + """ + server.version = "3.30" # update_tags requires 3.30 (see @api decorator) + vconn_id = "8fd7cc02-bb55-4d15-b8b1-9650239efe79" + add_tags_response = VIRTUAL_CONNECTION_ADD_TAGS.read_text() + with requests_mock.mock() as m: + m.put(f"{server.virtual_connections.baseurl}/{vconn_id}/tags", text=add_tags_response) + m.delete(f"{server.virtual_connections.baseurl}/{vconn_id}/tags/b", status_code=204) + m.delete(f"{server.virtual_connections.baseurl}/{vconn_id}/tags/d", status_code=204) + + vconn = VirtualConnectionItem("vconn") + vconn._id = vconn_id + vconn._initial_tags = {"a", "b", "c", "d"} + vconn.tags = {"a", "c", "e"} + server.virtual_connections.update_tags(vconn) + + # add PUT + 2 deletes = 3 calls + assert m.call_count == 3, m.request_history + + def test_add_permissions(server: TSC.Server) -> None: response_xml = ADD_PERMISSIONS.read_text()