From 10613091d3da1b178ed6dab8b006d1cb62756789 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 20 Nov 2025 22:13:28 -0600 Subject: [PATCH 01/12] fix: black ci errors --- test/test_datasource.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_datasource.py b/test/test_datasource.py index 7f4cca759..56eb11ab7 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -895,7 +895,8 @@ def test_publish_description(server: TSC.Server) -> None: ds_elem = body.find(".//datasource") assert ds_elem is not None assert ds_elem.attrib["description"] == "Sample description" - + + def test_get_datasource_no_owner(server: TSC.Server) -> None: with requests_mock.mock() as m: m.get(server.datasources.baseurl, text=GET_NO_OWNER.read_text()) From 9751fdae235c51f47dbf0ef7c8ab3fc7afa14422 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 19 Oct 2025 14:45:03 -0500 Subject: [PATCH 02/12] feat: List server extension settings --- tableauserverclient/__init__.py | 2 + tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/extensions_item.py | 56 +++++++++++++++++++ .../server/endpoint/__init__.py | 2 + .../server/endpoint/extensions_endpoint.py | 24 ++++++++ tableauserverclient/server/server.py | 2 + .../extensions_server_settings_false.xml | 6 ++ .../extensions_server_settings_true.xml | 8 +++ 8 files changed, 102 insertions(+) create mode 100644 tableauserverclient/models/extensions_item.py create mode 100644 tableauserverclient/server/endpoint/extensions_endpoint.py create mode 100644 test/assets/extensions_server_settings_false.xml create mode 100644 test/assets/extensions_server_settings_true.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index b041fcdae..86ce315b0 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -13,6 +13,7 @@ DatabaseItem, DataFreshnessPolicyItem, DatasourceItem, + ExtensionsServer, FavoriteItem, FlowItem, FlowRunItem, @@ -88,6 +89,7 @@ "DEFAULT_NAMESPACE", "DQWItem", "ExcelRequestOptions", + "ExtensionsServer", "FailedSignInError", "FavoriteItem", "FileuploadItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 67f6553fd..c47a66f60 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -10,6 +10,7 @@ from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.dqw_item import DQWItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.extensions_item import ExtensionsServer from tableauserverclient.models.favorites_item import FavoriteItem from tableauserverclient.models.fileupload_item import FileuploadItem from tableauserverclient.models.flow_item import FlowItem @@ -113,4 +114,5 @@ "LinkedTaskStepItem", "LinkedTaskFlowRunItem", "ExtractItem", + "ExtensionsServer", ] diff --git a/tableauserverclient/models/extensions_item.py b/tableauserverclient/models/extensions_item.py new file mode 100644 index 000000000..31f70c6fb --- /dev/null +++ b/tableauserverclient/models/extensions_item.py @@ -0,0 +1,56 @@ +from typing import Optional, TypeVar, overload +from typing_extensions import Self + +from defusedxml.ElementTree import fromstring + +T = TypeVar("T") + + +class ExtensionsServer: + def __init__(self) -> None: + self._enabled: Optional[bool] = None + self._block_list: Optional[list[str]] = None + + @property + def enabled(self) -> Optional[bool]: + """Indicates whether the extensions server is enabled.""" + return self._enabled + + @enabled.setter + def enabled(self, value: Optional[bool]) -> None: + self._enabled = value + + @property + def block_list(self) -> Optional[list[str]]: + """List of blocked extensions.""" + return self._block_list + + @block_list.setter + def block_list(self, value: Optional[list[str]]) -> None: + self._block_list = value + + @classmethod + def from_response(cls: type[Self], response, ns) -> Self: + xml = fromstring(response) + obj = cls() + element = xml.find(".//t:extensionsServerSettings", namespaces=ns) + if element is None: + raise ValueError("Missing extensionsServerSettings element in response") + + if (enabled_element := element.find("./t:extensionsGloballyEnabled", namespaces=ns)) is not None: + obj.enabled = string_to_bool(enabled_element.text) + obj.block_list = [e.text for e in element.findall("./t:blockList", namespaces=ns)] + + return obj + + +@overload +def string_to_bool(s: str) -> bool: ... + + +@overload +def string_to_bool(s: None) -> None: ... + + +def string_to_bool(s): + return s.lower() == "true" if s is not None else None diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 3c1266f90..d944bc429 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -6,6 +6,7 @@ from tableauserverclient.server.endpoint.datasources_endpoint import Datasources from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint from tableauserverclient.server.endpoint.exceptions import ServerResponseError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.extensions_endpoint import Extensions from tableauserverclient.server.endpoint.favorites_endpoint import Favorites from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads from tableauserverclient.server.endpoint.flow_runs_endpoint import FlowRuns @@ -42,6 +43,7 @@ "QuerysetEndpoint", "MissingRequiredFieldError", "Endpoint", + "Extensions", "Favorites", "Fileuploads", "FlowRuns", diff --git a/tableauserverclient/server/endpoint/extensions_endpoint.py b/tableauserverclient/server/endpoint/extensions_endpoint.py new file mode 100644 index 000000000..2cb417eea --- /dev/null +++ b/tableauserverclient/server/endpoint/extensions_endpoint.py @@ -0,0 +1,24 @@ +from tableauserverclient.models.extensions_item import ExtensionsServer +from tableauserverclient.server.endpoint.endpoint import Endpoint +from tableauserverclient.server.endpoint.endpoint import api + + +class Extensions(Endpoint): + def __init__(self, parent_srv): + super().__init__(parent_srv) + + @property + def _server_baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/settings/extensions" + + @api(version="3.21") + def get_server_settings(self) -> ExtensionsServer: + """Lists the settings for extensions of a server + + Returns + ------- + ExtensionsServer + The server extensions settings + """ + response = self.get_request(self._server_baseurl) + return ExtensionsServer.from_response(response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 9202e3e63..b497e9086 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -39,6 +39,7 @@ Tags, VirtualConnections, OIDC, + Extensions, ) from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, @@ -185,6 +186,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.tags = Tags(self) self.virtual_connections = VirtualConnections(self) self.oidc = OIDC(self) + self.extensions = Extensions(self) self._session = self._session_factory() self._http_options = dict() # must set this before making a server call diff --git a/test/assets/extensions_server_settings_false.xml b/test/assets/extensions_server_settings_false.xml new file mode 100644 index 000000000..16fd3e85d --- /dev/null +++ b/test/assets/extensions_server_settings_false.xml @@ -0,0 +1,6 @@ + + + + false + + diff --git a/test/assets/extensions_server_settings_true.xml b/test/assets/extensions_server_settings_true.xml new file mode 100644 index 000000000..c562d4719 --- /dev/null +++ b/test/assets/extensions_server_settings_true.xml @@ -0,0 +1,8 @@ + + + + true + https://test.com + https://example.com + + From 9539ba0eed6a4f202b57f867883476eba6f591b8 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 19 Oct 2025 16:20:59 -0500 Subject: [PATCH 03/12] feat: support updating server extension settings --- .../server/endpoint/extensions_endpoint.py | 19 ++++++ tableauserverclient/server/request_factory.py | 18 ++++++ test/test_extensions.py | 59 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 test/test_extensions.py diff --git a/tableauserverclient/server/endpoint/extensions_endpoint.py b/tableauserverclient/server/endpoint/extensions_endpoint.py index 2cb417eea..c31e13d06 100644 --- a/tableauserverclient/server/endpoint/extensions_endpoint.py +++ b/tableauserverclient/server/endpoint/extensions_endpoint.py @@ -1,6 +1,7 @@ from tableauserverclient.models.extensions_item import ExtensionsServer from tableauserverclient.server.endpoint.endpoint import Endpoint from tableauserverclient.server.endpoint.endpoint import api +from tableauserverclient.server.request_factory import RequestFactory class Extensions(Endpoint): @@ -22,3 +23,21 @@ def get_server_settings(self) -> ExtensionsServer: """ response = self.get_request(self._server_baseurl) return ExtensionsServer.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.21") + def update_server_settings(self, extensions_server: ExtensionsServer) -> ExtensionsServer: + """Updates the settings for extensions of a server + + Parameters + ---------- + extensions_server : ExtensionsServer + The server extensions settings to update + + Returns + ------- + ExtensionsServer + The updated server extensions settings + """ + req = RequestFactory.Extensions.update_server_extensions(extensions_server) + response = self.put_request(self._server_baseurl, req) + return ExtensionsServer.from_response(response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 66071bbeb..f754ba426 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1629,6 +1629,23 @@ def update_req(self, xml_request: ET.Element, oidc_item: SiteOIDCConfiguration) return ET.tostring(xml_request) +class ExtensionsRequest: + @_tsrequest_wrapped + def update_server_extensions(self, xml_request: ET.Element, extensions_server: "ExtensionsServer") -> None: + extensions_element = ET.SubElement(xml_request, "extensionsServerSettings") + if not isinstance(extensions_server.enabled, bool): + raise ValueError(f"Extensions Server missing enabled: {extensions_server}") + enabled_element = ET.SubElement(extensions_element, "extensionsGloballyEnabled") + enabled_element.text = str(extensions_server.enabled).lower() + + if extensions_server.block_list is None: + return + for blocked in extensions_server.block_list: + blocked_element = ET.SubElement(extensions_element, "blockList") + blocked_element.text = blocked + return + + class RequestFactory: Auth = AuthRequest() Connection = Connection() @@ -1639,6 +1656,7 @@ class RequestFactory: Database = DatabaseRequest() DQW = DQWRequest() Empty = EmptyRequest() + Extensions = ExtensionsRequest() Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() diff --git a/test/test_extensions.py b/test/test_extensions.py new file mode 100644 index 000000000..0ca7a2138 --- /dev/null +++ b/test/test_extensions.py @@ -0,0 +1,59 @@ +from pathlib import Path + +import requests_mock +import pytest + +import tableauserverclient as TSC + + +TEST_ASSET_DIR = Path(__file__).parent / "assets" + +GET_SERVER_EXT_SETTINGS = TEST_ASSET_DIR / "extensions_server_settings_true.xml" +GET_SERVER_EXT_SETTINGS_FALSE = TEST_ASSET_DIR / "extensions_server_settings_false.xml" + + +@pytest.fixture(scope="function") +def server() -> TSC.Server: + server = TSC.Server("http://test", False) + + # Fake sign in + server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + server.version = "3.21" + + return server + + +def test_get_server_extensions_settings(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.extensions._server_baseurl, text=GET_SERVER_EXT_SETTINGS.read_text()) + ext_settings = server.extensions.get_server_settings() + + assert ext_settings.enabled is True + assert ext_settings.block_list is not None + assert set(ext_settings.block_list) == {"https://test.com", "https://example.com"} + + +def test_get_server_extensions_settings_false(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.extensions._server_baseurl, text=GET_SERVER_EXT_SETTINGS_FALSE.read_text()) + ext_settings = server.extensions.get_server_settings() + + assert ext_settings.enabled is False + assert ext_settings.block_list is not None + assert len(ext_settings.block_list) == 0 + + +def test_update_server_extensions_settings(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.extensions._server_baseurl, text=GET_SERVER_EXT_SETTINGS_FALSE.read_text()) + + ext_settings = TSC.ExtensionsServer() + ext_settings.enabled = False + ext_settings.block_list = [] + + updated_settings = server.extensions.update_server_settings(ext_settings) + + assert updated_settings.enabled is False + assert updated_settings.block_list is not None + assert len(updated_settings.block_list) == 0 From 509bcb922857eec63152168e9cae535f63e4593d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:07:18 -0500 Subject: [PATCH 04/12] feat: enable retrieving site extension settings --- tableauserverclient/__init__.py | 4 + tableauserverclient/models/__init__.py | 4 +- tableauserverclient/models/extensions_item.py | 86 ++++++++++++++++++- .../server/endpoint/extensions_endpoint.py | 18 +++- test/assets/extensions_site_settings.xml | 12 +++ test/test_extensions.py | 17 ++++ 6 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 test/assets/extensions_site_settings.xml diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 86ce315b0..cd0ec3e03 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -14,6 +14,7 @@ DataFreshnessPolicyItem, DatasourceItem, ExtensionsServer, + ExtensionsSiteSettings, FavoriteItem, FlowItem, FlowRunItem, @@ -37,6 +38,7 @@ ProjectItem, Resource, RevisionItem, + SafeExtension, ScheduleItem, SiteAuthConfiguration, SiteOIDCConfiguration, @@ -90,6 +92,7 @@ "DQWItem", "ExcelRequestOptions", "ExtensionsServer", + "ExtensionsSiteSettings", "FailedSignInError", "FavoriteItem", "FileuploadItem", @@ -123,6 +126,7 @@ "RequestOptions", "Resource", "RevisionItem", + "SafeExtension", "ScheduleItem", "Server", "ServerInfoItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index c47a66f60..aa28e0dbf 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -10,7 +10,7 @@ from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.dqw_item import DQWItem from tableauserverclient.models.exceptions import UnpopulatedPropertyError -from tableauserverclient.models.extensions_item import ExtensionsServer +from tableauserverclient.models.extensions_item import ExtensionsServer, ExtensionsSiteSettings, SafeExtension from tableauserverclient.models.favorites_item import FavoriteItem from tableauserverclient.models.fileupload_item import FileuploadItem from tableauserverclient.models.flow_item import FlowItem @@ -115,4 +115,6 @@ "LinkedTaskFlowRunItem", "ExtractItem", "ExtensionsServer", + "ExtensionsSiteSettings", + "SafeExtension", ] diff --git a/tableauserverclient/models/extensions_item.py b/tableauserverclient/models/extensions_item.py index 31f70c6fb..adb530fd1 100644 --- a/tableauserverclient/models/extensions_item.py +++ b/tableauserverclient/models/extensions_item.py @@ -1,9 +1,9 @@ -from typing import Optional, TypeVar, overload +from typing import Optional, overload from typing_extensions import Self from defusedxml.ElementTree import fromstring -T = TypeVar("T") +from tableauserverclient.models.property_decorators import property_is_boolean class ExtensionsServer: @@ -17,6 +17,7 @@ def enabled(self) -> Optional[bool]: return self._enabled @enabled.setter + @property_is_boolean def enabled(self, value: Optional[bool]) -> None: self._enabled = value @@ -44,6 +45,87 @@ def from_response(cls: type[Self], response, ns) -> Self: return obj +class SafeExtension: + def __init__( + self, url: Optional[str] = None, full_data_allowed: Optional[bool] = None, prompt_needed: Optional[bool] = None + ) -> None: + self.url = url + self._full_data_allowed = full_data_allowed + self._prompt_needed = prompt_needed + + @property + def full_data_allowed(self) -> Optional[bool]: + return self._full_data_allowed + + @full_data_allowed.setter + @property_is_boolean + def full_data_allowed(self, value: Optional[bool]) -> None: + self._full_data_allowed = value + + @property + def prompt_needed(self) -> Optional[bool]: + return self._prompt_needed + + @prompt_needed.setter + @property_is_boolean + def prompt_needed(self, value: Optional[bool]) -> None: + self._prompt_needed = value + + +class ExtensionsSiteSettings: + def __init__(self) -> None: + self._enabled: Optional[bool] = None + self._use_default_settings: Optional[bool] = None + self.safe_list: Optional[list[SafeExtension]] = None + + @property + def enabled(self) -> Optional[bool]: + return self._enabled + + @enabled.setter + @property_is_boolean + def enabled(self, value: Optional[bool]) -> None: + self._enabled = value + + @property + def use_default_settings(self) -> Optional[bool]: + return self._use_default_settings + + @use_default_settings.setter + @property_is_boolean + def use_default_settings(self, value: Optional[bool]) -> None: + self._use_default_settings = value + + @classmethod + def from_response(cls: type[Self], response, ns) -> Self: + xml = fromstring(response) + element = xml.find(".//t:extensionsSiteSettings", namespaces=ns) + obj = cls() + if element is None: + raise ValueError("Missing extensionsSiteSettings element in response") + + if (enabled_element := element.find("./t:extensionsEnabled", namespaces=ns)) is not None: + obj.enabled = string_to_bool(enabled_element.text) + if (default_settings_element := element.find("./t:useDefaultSetting", namespaces=ns)) is not None: + obj.use_default_settings = string_to_bool(default_settings_element.text) + + safe_list = [] + for safe_extension_element in element.findall("./t:safeList", namespaces=ns): + url = safe_extension_element.find("./t:url", namespaces=ns) + full_data_allowed = safe_extension_element.find("./t:fullDataAllowed", namespaces=ns) + prompt_needed = safe_extension_element.find("./t:promptNeeded", namespaces=ns) + + safe_extension = SafeExtension( + url=url.text if url is not None else None, + full_data_allowed=string_to_bool(full_data_allowed.text) if full_data_allowed is not None else None, + prompt_needed=string_to_bool(prompt_needed.text) if prompt_needed is not None else None, + ) + safe_list.append(safe_extension) + + obj.safe_list = safe_list + return obj + + @overload def string_to_bool(s: str) -> bool: ... diff --git a/tableauserverclient/server/endpoint/extensions_endpoint.py b/tableauserverclient/server/endpoint/extensions_endpoint.py index c31e13d06..24390017a 100644 --- a/tableauserverclient/server/endpoint/extensions_endpoint.py +++ b/tableauserverclient/server/endpoint/extensions_endpoint.py @@ -1,4 +1,4 @@ -from tableauserverclient.models.extensions_item import ExtensionsServer +from tableauserverclient.models.extensions_item import ExtensionsServer, ExtensionsSiteSettings from tableauserverclient.server.endpoint.endpoint import Endpoint from tableauserverclient.server.endpoint.endpoint import api from tableauserverclient.server.request_factory import RequestFactory @@ -12,6 +12,10 @@ def __init__(self, parent_srv): def _server_baseurl(self) -> str: return f"{self.parent_srv.baseurl}/settings/extensions" + @property + def baseurl(self) -> str: + return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/settings/extensions" + @api(version="3.21") def get_server_settings(self) -> ExtensionsServer: """Lists the settings for extensions of a server @@ -41,3 +45,15 @@ def update_server_settings(self, extensions_server: ExtensionsServer) -> Extensi req = RequestFactory.Extensions.update_server_extensions(extensions_server) response = self.put_request(self._server_baseurl, req) return ExtensionsServer.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.21") + def get(self) -> ExtensionsSiteSettings: + """Lists the extensions settings for the site + + Returns + ------- + list[ExtensionsSiteSettings] + The site extensions settings + """ + response = self.get_request(self.baseurl) + return ExtensionsSiteSettings.from_response(response.content, self.parent_srv.namespace) diff --git a/test/assets/extensions_site_settings.xml b/test/assets/extensions_site_settings.xml new file mode 100644 index 000000000..ca99dc84e --- /dev/null +++ b/test/assets/extensions_site_settings.xml @@ -0,0 +1,12 @@ + + + + true + false + + http://localhost:9123/Dynamic.html + true + true + + + diff --git a/test/test_extensions.py b/test/test_extensions.py index 0ca7a2138..e6c8c0faf 100644 --- a/test/test_extensions.py +++ b/test/test_extensions.py @@ -10,6 +10,7 @@ GET_SERVER_EXT_SETTINGS = TEST_ASSET_DIR / "extensions_server_settings_true.xml" GET_SERVER_EXT_SETTINGS_FALSE = TEST_ASSET_DIR / "extensions_server_settings_false.xml" +GET_SITE_SETTINGS = TEST_ASSET_DIR / "extensions_site_settings.xml" @pytest.fixture(scope="function") @@ -57,3 +58,19 @@ def test_update_server_extensions_settings(server: TSC.Server) -> None: assert updated_settings.enabled is False assert updated_settings.block_list is not None assert len(updated_settings.block_list) == 0 + + +def test_get_site_settings(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.get(server.extensions.baseurl, text=GET_SITE_SETTINGS.read_text()) + site_settings = server.extensions.get() + + assert isinstance(site_settings, TSC.ExtensionsSiteSettings) + assert site_settings.enabled is True + assert site_settings.use_default_settings is False + assert site_settings.safe_list is not None + assert len(site_settings.safe_list) == 1 + first_safe = site_settings.safe_list[0] + assert first_safe.url == "http://localhost:9123/Dynamic.html" + assert first_safe.full_data_allowed is True + assert first_safe.prompt_needed is True From c9d448d377481abdacfeff8e28d161ff09b33c19 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:31:18 -0500 Subject: [PATCH 05/12] feat: support updating site settings --- tableauserverclient/models/extensions_item.py | 14 ++--- .../models/property_decorators.py | 2 +- .../server/endpoint/extensions_endpoint.py | 22 +++++++- tableauserverclient/server/request_factory.py | 26 +++++++++ test/test_extensions.py | 56 ++++++++++++++++++- 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/tableauserverclient/models/extensions_item.py b/tableauserverclient/models/extensions_item.py index adb530fd1..176ea737a 100644 --- a/tableauserverclient/models/extensions_item.py +++ b/tableauserverclient/models/extensions_item.py @@ -75,7 +75,7 @@ def prompt_needed(self, value: Optional[bool]) -> None: class ExtensionsSiteSettings: def __init__(self) -> None: self._enabled: Optional[bool] = None - self._use_default_settings: Optional[bool] = None + self._use_default_setting: Optional[bool] = None self.safe_list: Optional[list[SafeExtension]] = None @property @@ -88,13 +88,13 @@ def enabled(self, value: Optional[bool]) -> None: self._enabled = value @property - def use_default_settings(self) -> Optional[bool]: - return self._use_default_settings + def use_default_setting(self) -> Optional[bool]: + return self._use_default_setting - @use_default_settings.setter + @use_default_setting.setter @property_is_boolean - def use_default_settings(self, value: Optional[bool]) -> None: - self._use_default_settings = value + def use_default_setting(self, value: Optional[bool]) -> None: + self._use_default_setting = value @classmethod def from_response(cls: type[Self], response, ns) -> Self: @@ -107,7 +107,7 @@ def from_response(cls: type[Self], response, ns) -> Self: if (enabled_element := element.find("./t:extensionsEnabled", namespaces=ns)) is not None: obj.enabled = string_to_bool(enabled_element.text) if (default_settings_element := element.find("./t:useDefaultSetting", namespaces=ns)) is not None: - obj.use_default_settings = string_to_bool(default_settings_element.text) + obj.use_default_setting = string_to_bool(default_settings_element.text) safe_list = [] for safe_extension_element in element.findall("./t:safeList", namespaces=ns): diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 5048b3498..0fcc97457 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,7 @@ import datetime import re from functools import wraps -from typing import Any, Optional +from typing import Any, Optional, Tuple from collections.abc import Container from tableauserverclient.datetime_helpers import parse_datetime diff --git a/tableauserverclient/server/endpoint/extensions_endpoint.py b/tableauserverclient/server/endpoint/extensions_endpoint.py index 24390017a..ccef53dee 100644 --- a/tableauserverclient/server/endpoint/extensions_endpoint.py +++ b/tableauserverclient/server/endpoint/extensions_endpoint.py @@ -30,7 +30,8 @@ def get_server_settings(self) -> ExtensionsServer: @api(version="3.21") def update_server_settings(self, extensions_server: ExtensionsServer) -> ExtensionsServer: - """Updates the settings for extensions of a server + """Updates the settings for extensions of a server. Overwrites all existing settings. Any + sites omitted from the block list will be unblocked. Parameters ---------- @@ -57,3 +58,22 @@ def get(self) -> ExtensionsSiteSettings: """ response = self.get_request(self.baseurl) return ExtensionsSiteSettings.from_response(response.content, self.parent_srv.namespace) + + @api(version="3.21") + def update(self, extensions_site_settings: ExtensionsSiteSettings) -> ExtensionsSiteSettings: + """Updates the extensions settings for the site. Overwrites all existing settings. + Any extensions omitted from the safe extensions list will be removed. + + Parameters + ---------- + extensions_site_settings : ExtensionsSiteSettings + The site extensions settings to update + + Returns + ------- + ExtensionsSiteSettings + The updated site extensions settings + """ + req = RequestFactory.Extensions.update_site_extensions(extensions_site_settings) + response = self.put_request(self.baseurl, req) + return ExtensionsSiteSettings.from_response(response.content, self.parent_srv.namespace) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index f754ba426..547d960be 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1645,6 +1645,32 @@ def update_server_extensions(self, xml_request: ET.Element, extensions_server: " blocked_element.text = blocked return + @_tsrequest_wrapped + def update_site_extensions(self, xml_request: ET.Element, extensions_site_settings: ExtensionsSiteSettings) -> None: + ext_element = ET.SubElement(xml_request, "extensionsSiteSettings") + if not isinstance(extensions_site_settings.enabled, bool): + raise ValueError(f"Extensions Site Settings missing enabled: {extensions_site_settings}") + enabled_element = ET.SubElement(ext_element, "extensionsEnabled") + enabled_element.text = str(extensions_site_settings.enabled).lower() + if not isinstance(extensions_site_settings.use_default_setting, bool): + raise ValueError( + f"Extensions Site Settings missing use_default_setting: {extensions_site_settings.use_default_setting}" + ) + default_element = ET.SubElement(ext_element, "useDefaultSetting") + default_element.text = str(extensions_site_settings.use_default_setting).lower() + + for safe in extensions_site_settings.safe_list or []: + safe_element = ET.SubElement(ext_element, "safeList") + if safe.url is not None: + url_element = ET.SubElement(safe_element, "url") + url_element.text = safe.url + if safe.full_data_allowed is not None: + full_data_element = ET.SubElement(safe_element, "fullDataAllowed") + full_data_element.text = str(safe.full_data_allowed).lower() + if safe.prompt_needed is not None: + prompt_element = ET.SubElement(safe_element, "promptNeeded") + prompt_element.text = str(safe.prompt_needed).lower() + class RequestFactory: Auth = AuthRequest() diff --git a/test/test_extensions.py b/test/test_extensions.py index e6c8c0faf..d55f479ac 100644 --- a/test/test_extensions.py +++ b/test/test_extensions.py @@ -1,5 +1,7 @@ from pathlib import Path +from xml.etree.ElementTree import Element +from defusedxml.ElementTree import fromstring import requests_mock import pytest @@ -67,10 +69,62 @@ def test_get_site_settings(server: TSC.Server) -> None: assert isinstance(site_settings, TSC.ExtensionsSiteSettings) assert site_settings.enabled is True - assert site_settings.use_default_settings is False + assert site_settings.use_default_setting is False assert site_settings.safe_list is not None assert len(site_settings.safe_list) == 1 first_safe = site_settings.safe_list[0] assert first_safe.url == "http://localhost:9123/Dynamic.html" assert first_safe.full_data_allowed is True assert first_safe.prompt_needed is True + + +def test_update_site_settings(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.extensions.baseurl, text=GET_SITE_SETTINGS.read_text()) + + site_settings = TSC.ExtensionsSiteSettings() + site_settings.enabled = True + site_settings.use_default_setting = False + safe_extension = TSC.SafeExtension( + url="http://localhost:9123/Dynamic.html", + full_data_allowed=True, + prompt_needed=True, + ) + site_settings.safe_list = [safe_extension] + + updated_settings = server.extensions.update(site_settings) + history = m.request_history + + assert isinstance(updated_settings, TSC.ExtensionsSiteSettings) + assert updated_settings.enabled is True + assert updated_settings.use_default_setting is False + assert updated_settings.safe_list is not None + assert len(updated_settings.safe_list) == 1 + first_safe = updated_settings.safe_list[0] + assert first_safe.url == "http://localhost:9123/Dynamic.html" + assert first_safe.full_data_allowed is True + assert first_safe.prompt_needed is True + + # Verify that the request body was as expected + assert len(history) == 1 + xml_payload = fromstring(history[0].body) + extensions_site_settings_elem = xml_payload.find(".//extensionsSiteSettings") + assert extensions_site_settings_elem is not None + enabled_elem = extensions_site_settings_elem.find("extensionsEnabled") + assert enabled_elem is not None + assert enabled_elem.text == "true" + use_default_elem = extensions_site_settings_elem.find("useDefaultSetting") + assert use_default_elem is not None + assert use_default_elem.text == "false" + safe_list_elements = list(extensions_site_settings_elem.findall("safeList")) + assert len(safe_list_elements) == 1 + safe_extension_elem = safe_list_elements[0] + url_elem = safe_extension_elem.find("url") + assert url_elem is not None + assert url_elem.text == "http://localhost:9123/Dynamic.html" + full_data_allowed_elem = safe_extension_elem.find("fullDataAllowed") + assert full_data_allowed_elem is not None + assert full_data_allowed_elem.text == "true" + prompt_needed_elem = safe_extension_elem.find("promptNeeded") + assert prompt_needed_elem is not None + assert prompt_needed_elem.text == "true" From 60e0213bb31af2016fb73f5b9c21aa1d27b17bed Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 20 Oct 2025 22:40:28 -0500 Subject: [PATCH 06/12] feat: add support for new extension attributes Closes #1629 New attributes have been added to extension site settings for upcoming Tableau Cloud releases. --- tableauserverclient/models/extensions_item.py | 48 +++++++++++++++++++ tableauserverclient/server/request_factory.py | 12 +++++ test/assets/extensions_site_settings.xml | 4 ++ test/test_extensions.py | 4 ++ 4 files changed, 68 insertions(+) diff --git a/tableauserverclient/models/extensions_item.py b/tableauserverclient/models/extensions_item.py index 176ea737a..5094e1af6 100644 --- a/tableauserverclient/models/extensions_item.py +++ b/tableauserverclient/models/extensions_item.py @@ -77,6 +77,10 @@ def __init__(self) -> None: self._enabled: Optional[bool] = None self._use_default_setting: Optional[bool] = None self.safe_list: Optional[list[SafeExtension]] = None + self._allow_trusted: Optional[bool] = None + self._include_tableau_built: Optional[bool] = None + self._include_partner_built: Optional[bool] = None + self._include_sandboxed: Optional[bool] = None @property def enabled(self) -> Optional[bool]: @@ -96,6 +100,42 @@ def use_default_setting(self) -> Optional[bool]: def use_default_setting(self, value: Optional[bool]) -> None: self._use_default_setting = value + @property + def allow_trusted(self) -> Optional[bool]: + return self._allow_trusted + + @allow_trusted.setter + @property_is_boolean + def allow_trusted(self, value: Optional[bool]) -> None: + self._allow_trusted = value + + @property + def include_tableau_built(self) -> Optional[bool]: + return self._include_tableau_built + + @include_tableau_built.setter + @property_is_boolean + def include_tableau_built(self, value: Optional[bool]) -> None: + self._include_tableau_built = value + + @property + def include_partner_built(self) -> Optional[bool]: + return self._include_partner_built + + @include_partner_built.setter + @property_is_boolean + def include_partner_built(self, value: Optional[bool]) -> None: + self._include_partner_built = value + + @property + def include_sandboxed(self) -> Optional[bool]: + return self._include_sandboxed + + @include_sandboxed.setter + @property_is_boolean + def include_sandboxed(self, value: Optional[bool]) -> None: + self._include_sandboxed = value + @classmethod def from_response(cls: type[Self], response, ns) -> Self: xml = fromstring(response) @@ -108,6 +148,14 @@ def from_response(cls: type[Self], response, ns) -> Self: obj.enabled = string_to_bool(enabled_element.text) if (default_settings_element := element.find("./t:useDefaultSetting", namespaces=ns)) is not None: obj.use_default_setting = string_to_bool(default_settings_element.text) + if (allow_trusted_element := element.find("./t:allowTrusted", namespaces=ns)) is not None: + obj.allow_trusted = string_to_bool(allow_trusted_element.text) + if (include_tableau_built_element := element.find("./t:includeTableauBuilt", namespaces=ns)) is not None: + obj.include_tableau_built = string_to_bool(include_tableau_built_element.text) + if (include_partner_built_element := element.find("./t:includePartnerBuilt", namespaces=ns)) is not None: + obj.include_partner_built = string_to_bool(include_partner_built_element.text) + if (include_sandboxed_element := element.find("./t:includeSandboxed", namespaces=ns)) is not None: + obj.include_sandboxed = string_to_bool(include_sandboxed_element.text) safe_list = [] for safe_extension_element in element.findall("./t:safeList", namespaces=ns): diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 547d960be..1d25a42ff 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1658,6 +1658,18 @@ def update_site_extensions(self, xml_request: ET.Element, extensions_site_settin ) default_element = ET.SubElement(ext_element, "useDefaultSetting") default_element.text = str(extensions_site_settings.use_default_setting).lower() + if extensions_site_settings.allow_trusted is not None: + allow_trusted_element = ET.SubElement(ext_element, "allowTrusted") + allow_trusted_element.text = str(extensions_site_settings.allow_trusted).lower() + if extensions_site_settings.include_sandboxed is not None: + include_sandboxed_element = ET.SubElement(ext_element, "includeSandboxed") + include_sandboxed_element.text = str(extensions_site_settings.include_sandboxed).lower() + if extensions_site_settings.include_tableau_built is not None: + include_tableau_built_element = ET.SubElement(ext_element, "includeTableauBuilt") + include_tableau_built_element.text = str(extensions_site_settings.include_tableau_built).lower() + if extensions_site_settings.include_partner_built is not None: + include_partner_built_element = ET.SubElement(ext_element, "includePartnerBuilt") + include_partner_built_element.text = str(extensions_site_settings.include_partner_built).lower() for safe in extensions_site_settings.safe_list or []: safe_element = ET.SubElement(ext_element, "safeList") diff --git a/test/assets/extensions_site_settings.xml b/test/assets/extensions_site_settings.xml index ca99dc84e..2a62d299c 100644 --- a/test/assets/extensions_site_settings.xml +++ b/test/assets/extensions_site_settings.xml @@ -3,6 +3,10 @@ true false + true + false + >false + false http://localhost:9123/Dynamic.html true diff --git a/test/test_extensions.py b/test/test_extensions.py index d55f479ac..0b5a85ec2 100644 --- a/test/test_extensions.py +++ b/test/test_extensions.py @@ -71,6 +71,10 @@ def test_get_site_settings(server: TSC.Server) -> None: assert site_settings.enabled is True assert site_settings.use_default_setting is False assert site_settings.safe_list is not None + assert site_settings.allow_trusted is True + assert site_settings.include_partner_built is False + assert site_settings.include_sandboxed is False + assert site_settings.include_tableau_built is False assert len(site_settings.safe_list) == 1 first_safe = site_settings.safe_list[0] assert first_safe.url == "http://localhost:9123/Dynamic.html" From e218de964a5ccb9d544350104e8eb5edad243c98 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 21 Oct 2025 07:56:01 -0500 Subject: [PATCH 07/12] chore: update to py3.10 syntax --- tableauserverclient/models/extensions_item.py | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/tableauserverclient/models/extensions_item.py b/tableauserverclient/models/extensions_item.py index 5094e1af6..9b6e1089c 100644 --- a/tableauserverclient/models/extensions_item.py +++ b/tableauserverclient/models/extensions_item.py @@ -1,4 +1,4 @@ -from typing import Optional, overload +from typing import overload from typing_extensions import Self from defusedxml.ElementTree import fromstring @@ -8,26 +8,26 @@ class ExtensionsServer: def __init__(self) -> None: - self._enabled: Optional[bool] = None - self._block_list: Optional[list[str]] = None + self._enabled: bool | None = None + self._block_list: list[str] | None = None @property - def enabled(self) -> Optional[bool]: + def enabled(self) -> bool | None: """Indicates whether the extensions server is enabled.""" return self._enabled @enabled.setter @property_is_boolean - def enabled(self, value: Optional[bool]) -> None: + def enabled(self, value: bool | None) -> None: self._enabled = value @property - def block_list(self) -> Optional[list[str]]: + def block_list(self) -> list[str] | None: """List of blocked extensions.""" return self._block_list @block_list.setter - def block_list(self, value: Optional[list[str]]) -> None: + def block_list(self, value: list[str] | None) -> None: self._block_list = value @classmethod @@ -47,93 +47,93 @@ def from_response(cls: type[Self], response, ns) -> Self: class SafeExtension: def __init__( - self, url: Optional[str] = None, full_data_allowed: Optional[bool] = None, prompt_needed: Optional[bool] = None + self, url: str | None = None, full_data_allowed: bool | None = None, prompt_needed: bool | None = None ) -> None: self.url = url self._full_data_allowed = full_data_allowed self._prompt_needed = prompt_needed @property - def full_data_allowed(self) -> Optional[bool]: + def full_data_allowed(self) -> bool | None: return self._full_data_allowed @full_data_allowed.setter @property_is_boolean - def full_data_allowed(self, value: Optional[bool]) -> None: + def full_data_allowed(self, value: bool | None) -> None: self._full_data_allowed = value @property - def prompt_needed(self) -> Optional[bool]: + def prompt_needed(self) -> bool | None: return self._prompt_needed @prompt_needed.setter @property_is_boolean - def prompt_needed(self, value: Optional[bool]) -> None: + def prompt_needed(self, value: bool | None) -> None: self._prompt_needed = value class ExtensionsSiteSettings: def __init__(self) -> None: - self._enabled: Optional[bool] = None - self._use_default_setting: Optional[bool] = None - self.safe_list: Optional[list[SafeExtension]] = None - self._allow_trusted: Optional[bool] = None - self._include_tableau_built: Optional[bool] = None - self._include_partner_built: Optional[bool] = None - self._include_sandboxed: Optional[bool] = None + self._enabled: bool | None = None + self._use_default_setting: bool | None = None + self.safe_list: list[SafeExtension] | None = None + self._allow_trusted: bool | None = None + self._include_tableau_built: bool | None = None + self._include_partner_built: bool | None = None + self._include_sandboxed: bool | None = None @property - def enabled(self) -> Optional[bool]: + def enabled(self) -> bool | None: return self._enabled @enabled.setter @property_is_boolean - def enabled(self, value: Optional[bool]) -> None: + def enabled(self, value: bool | None) -> None: self._enabled = value @property - def use_default_setting(self) -> Optional[bool]: + def use_default_setting(self) -> bool | None: return self._use_default_setting @use_default_setting.setter @property_is_boolean - def use_default_setting(self, value: Optional[bool]) -> None: + def use_default_setting(self, value: bool | None) -> None: self._use_default_setting = value @property - def allow_trusted(self) -> Optional[bool]: + def allow_trusted(self) -> bool | None: return self._allow_trusted @allow_trusted.setter @property_is_boolean - def allow_trusted(self, value: Optional[bool]) -> None: + def allow_trusted(self, value: bool | None) -> None: self._allow_trusted = value @property - def include_tableau_built(self) -> Optional[bool]: + def include_tableau_built(self) -> bool | None: return self._include_tableau_built @include_tableau_built.setter @property_is_boolean - def include_tableau_built(self, value: Optional[bool]) -> None: + def include_tableau_built(self, value: bool | None) -> None: self._include_tableau_built = value @property - def include_partner_built(self) -> Optional[bool]: + def include_partner_built(self) -> bool | None: return self._include_partner_built @include_partner_built.setter @property_is_boolean - def include_partner_built(self, value: Optional[bool]) -> None: + def include_partner_built(self, value: bool | None) -> None: self._include_partner_built = value @property - def include_sandboxed(self) -> Optional[bool]: + def include_sandboxed(self) -> bool | None: return self._include_sandboxed @include_sandboxed.setter @property_is_boolean - def include_sandboxed(self, value: Optional[bool]) -> None: + def include_sandboxed(self, value: bool | None) -> None: self._include_sandboxed = value @classmethod From 5a0d6eb143891f7dfd602e518c392e700d30fb40 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 29 Oct 2025 08:53:31 -0500 Subject: [PATCH 08/12] fix: copilot suggestions --- tableauserverclient/models/extensions_item.py | 2 +- tableauserverclient/models/property_decorators.py | 4 ++-- tableauserverclient/server/endpoint/extensions_endpoint.py | 2 +- test/assets/extensions_site_settings.xml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/models/extensions_item.py b/tableauserverclient/models/extensions_item.py index 9b6e1089c..87466cdea 100644 --- a/tableauserverclient/models/extensions_item.py +++ b/tableauserverclient/models/extensions_item.py @@ -40,7 +40,7 @@ def from_response(cls: type[Self], response, ns) -> Self: if (enabled_element := element.find("./t:extensionsGloballyEnabled", namespaces=ns)) is not None: obj.enabled = string_to_bool(enabled_element.text) - obj.block_list = [e.text for e in element.findall("./t:blockList", namespaces=ns)] + obj.block_list = [e.text for e in element.findall("./t:blockList", namespaces=ns) if e.text is not None] return obj diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 0fcc97457..050346594 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,7 +1,7 @@ import datetime import re from functools import wraps -from typing import Any, Optional, Tuple +from typing import Any from collections.abc import Container from tableauserverclient.datetime_helpers import parse_datetime @@ -67,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: tuple[int, int], allowed: Container[Any] | None = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. diff --git a/tableauserverclient/server/endpoint/extensions_endpoint.py b/tableauserverclient/server/endpoint/extensions_endpoint.py index ccef53dee..6fdbffd7b 100644 --- a/tableauserverclient/server/endpoint/extensions_endpoint.py +++ b/tableauserverclient/server/endpoint/extensions_endpoint.py @@ -53,7 +53,7 @@ def get(self) -> ExtensionsSiteSettings: Returns ------- - list[ExtensionsSiteSettings] + ExtensionsSiteSettings The site extensions settings """ response = self.get_request(self.baseurl) diff --git a/test/assets/extensions_site_settings.xml b/test/assets/extensions_site_settings.xml index 2a62d299c..e5f963ca9 100644 --- a/test/assets/extensions_site_settings.xml +++ b/test/assets/extensions_site_settings.xml @@ -5,7 +5,7 @@ false true false - >false + false false http://localhost:9123/Dynamic.html From 96482ee1089441f33cabffcc66603ffedd37c872 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:37:09 -0500 Subject: [PATCH 09/12] fix: remove unused import --- test/test_extensions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_extensions.py b/test/test_extensions.py index 0b5a85ec2..f9bd61e72 100644 --- a/test/test_extensions.py +++ b/test/test_extensions.py @@ -1,5 +1,4 @@ from pathlib import Path -from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring import requests_mock From 1eba20af9f42cce7885b5803f6e459d83894d131 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:38:16 -0500 Subject: [PATCH 10/12] docs: reflect actual behavior --- tableauserverclient/server/endpoint/extensions_endpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/extensions_endpoint.py b/tableauserverclient/server/endpoint/extensions_endpoint.py index 6fdbffd7b..d14855931 100644 --- a/tableauserverclient/server/endpoint/extensions_endpoint.py +++ b/tableauserverclient/server/endpoint/extensions_endpoint.py @@ -61,8 +61,8 @@ def get(self) -> ExtensionsSiteSettings: @api(version="3.21") def update(self, extensions_site_settings: ExtensionsSiteSettings) -> ExtensionsSiteSettings: - """Updates the extensions settings for the site. Overwrites all existing settings. - Any extensions omitted from the safe extensions list will be removed. + """Updates the extensions settings for the site. Any extensions omitted + from the safe extensions list will be removed. Parameters ---------- From 2db96543b427ac15e44112dbc84efc14b9ce3b9f Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:08:44 -0600 Subject: [PATCH 11/12] feat: handle safe list noop and delete appropriately Conversation with jacksonlauren indicated that if the ExtensionSiteSettings safe list is None, no safeList element should be included in the request. If the ExtensionSiteSettings.safe_list is a non-None Iterable that yields 0 items, an empty safeList element will be included in the request, having the effect of deleting the safe list on the site. --- tableauserverclient/server/request_factory.py | 7 ++- test/test_extensions.py | 63 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 1d25a42ff..f3d57f4f6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1671,8 +1671,11 @@ def update_site_extensions(self, xml_request: ET.Element, extensions_site_settin include_partner_built_element = ET.SubElement(ext_element, "includePartnerBuilt") include_partner_built_element.text = str(extensions_site_settings.include_partner_built).lower() - for safe in extensions_site_settings.safe_list or []: - safe_element = ET.SubElement(ext_element, "safeList") + if extensions_site_settings.safe_list is None: + return + + safe_element = ET.SubElement(ext_element, "safeList") + for safe in extensions_site_settings.safe_list: if safe.url is not None: url_element = ET.SubElement(safe_element, "url") url_element.text = safe.url diff --git a/test/test_extensions.py b/test/test_extensions.py index f9bd61e72..c94fd7076 100644 --- a/test/test_extensions.py +++ b/test/test_extensions.py @@ -131,3 +131,66 @@ def test_update_site_settings(server: TSC.Server) -> None: prompt_needed_elem = safe_extension_elem.find("promptNeeded") assert prompt_needed_elem is not None assert prompt_needed_elem.text == "true" + + +def test_update_safe_list_none(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.extensions.baseurl, text=GET_SITE_SETTINGS.read_text()) + + site_settings = TSC.ExtensionsSiteSettings() + site_settings.enabled = True + site_settings.use_default_setting = False + + updated_settings = server.extensions.update(site_settings) + history = m.request_history + + assert isinstance(updated_settings, TSC.ExtensionsSiteSettings) + assert updated_settings.enabled is True + assert updated_settings.use_default_setting is False + assert updated_settings.safe_list is not None + assert len(updated_settings.safe_list) == 1 + first_safe = updated_settings.safe_list[0] + assert first_safe.url == "http://localhost:9123/Dynamic.html" + assert first_safe.full_data_allowed is True + assert first_safe.prompt_needed is True + + # Verify that the request body was as expected + assert len(history) == 1 + xml_payload = fromstring(history[0].body) + extensions_site_settings_elem = xml_payload.find(".//extensionsSiteSettings") + assert extensions_site_settings_elem is not None + safe_list_element = extensions_site_settings_elem.find("safeList") + assert safe_list_element is None + + +def test_update_safe_list_empty(server: TSC.Server) -> None: + with requests_mock.mock() as m: + m.put(server.extensions.baseurl, text=GET_SITE_SETTINGS.read_text()) + + site_settings = TSC.ExtensionsSiteSettings() + site_settings.enabled = True + site_settings.use_default_setting = False + site_settings.safe_list = [] + + updated_settings = server.extensions.update(site_settings) + history = m.request_history + + assert isinstance(updated_settings, TSC.ExtensionsSiteSettings) + assert updated_settings.enabled is True + assert updated_settings.use_default_setting is False + assert updated_settings.safe_list is not None + assert len(updated_settings.safe_list) == 1 + first_safe = updated_settings.safe_list[0] + assert first_safe.url == "http://localhost:9123/Dynamic.html" + assert first_safe.full_data_allowed is True + assert first_safe.prompt_needed is True + + # Verify that the request body was as expected + assert len(history) == 1 + xml_payload = fromstring(history[0].body) + extensions_site_settings_elem = xml_payload.find(".//extensionsSiteSettings") + assert extensions_site_settings_elem is not None + safe_list_element = extensions_site_settings_elem.find("safeList") + assert safe_list_element is not None + assert len(safe_list_element) == 0 + From 8e638a3451ffed5135cf12b96b622d97d1166f4a Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:19:06 -0600 Subject: [PATCH 12/12] style: black --- test/test_extensions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_extensions.py b/test/test_extensions.py index c94fd7076..9dc001876 100644 --- a/test/test_extensions.py +++ b/test/test_extensions.py @@ -174,7 +174,7 @@ def test_update_safe_list_empty(server: TSC.Server) -> None: updated_settings = server.extensions.update(site_settings) history = m.request_history - + assert isinstance(updated_settings, TSC.ExtensionsSiteSettings) assert updated_settings.enabled is True assert updated_settings.use_default_setting is False @@ -193,4 +193,3 @@ def test_update_safe_list_empty(server: TSC.Server) -> None: safe_list_element = extensions_site_settings_elem.find("safeList") assert safe_list_element is not None assert len(safe_list_element) == 0 -