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 fc00dac1e..9a305d5ab 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 @@ -20,6 +21,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: `Virtual Connections Methods `_ + """ + def __init__(self, parent_srv: "Server") -> None: super().__init__(parent_srv) self._permissions = _PermissionsEndpoint(parent_srv, lambda: self.baseurl) @@ -30,6 +44,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 +71,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 +114,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,18 +147,96 @@ def update_connection_db_connection( @api(version="3.23") def get_by_id(self, virtual_connection: str | VirtualConnectionItem) -> VirtualConnectionItem: - vconn_id = getattr(virtual_connection, "id", virtual_connection) + """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) + """ + 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: + """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 +246,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 +274,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 +301,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 +329,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 +343,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) @@ -146,25 +407,158 @@ 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: - self._permissions.populate(item) + def populate_permissions(self, virtual_connection: VirtualConnectionItem) -> None: + """Populate the permissions for a virtual connection. + + After calling this method, iterate ``virtual_connection.permissions`` + to access the ``PermissionsRule`` objects. + + Parameters + ---------- + virtual_connection : VirtualConnectionItem + The virtual connection to populate permissions for. + + Returns + ------- + None + Permissions are populated on ``virtual_connection.permissions``. + + Examples + -------- + >>> server.virtual_connections.populate_permissions(vc) + >>> for rule in vc.permissions: + ... print(rule) + """ + self._permissions.populate(virtual_connection) @api(version="3.22") - def add_permissions(self, resource, rules): - return self._permissions.update(resource, rules) + def add_permissions( + self, virtual_connection: VirtualConnectionItem, rules: list[PermissionsRule] + ) -> list[PermissionsRule]: + """Add or update permissions on a virtual connection. + + Parameters + ---------- + virtual_connection : 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 as returned by the server. + + Examples + -------- + >>> permission = TSC.PermissionsRule( + ... TSC.UserItem.as_reference(user.id), + ... {'Connect': 'Allow'} + ... ) + >>> server.virtual_connections.add_permissions(vc, [permission]) + """ + return self._permissions.update(virtual_connection, rules) @api(version="3.22") - def delete_permission(self, item, capability_item): - return self._permissions.delete(item, capability_item) + def delete_permission(self, virtual_connection: VirtualConnectionItem, permission_rule: PermissionsRule) -> None: + """Remove a specific permission from a virtual connection. + + Parameters + ---------- + virtual_connection : VirtualConnectionItem + The virtual connection to remove the permission from. + + permission_rule : PermissionsRule + The permission rule to remove. + + Returns + ------- + None + + Examples + -------- + >>> server.virtual_connections.delete_permission(vc, permission_rule) + """ + 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]: + """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 full tag set on the virtual connection after the add, + as returned by the server. + + 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") + @api(version="3.30") def update_tags(self, virtual_connection: VirtualConnectionItem) -> None: - raise NotImplementedError("Update tags is not implemented for Virtual Connections") + """Push local tag edits to the server as add / delete calls. + + 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) + """ + 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()