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 7f4cca75..56eb11ab 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 b041fcda..86ce315b 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 67f6553f..c47a66f6 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 00000000..31f70c6f --- /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 3c1266f9..d944bc42 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 00000000..2cb417ee --- /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 9202e3e6..b497e908 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 00000000..16fd3e85 --- /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 00000000..c562d471 --- /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 2cb417ee..c31e13d0 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 66071bbe..f754ba42 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 00000000..0ca7a213 --- /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 86ce315b..cd0ec3e0 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 c47a66f6..aa28e0db 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 31f70c6f..adb530fd 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 c31e13d0..24390017 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 00000000..ca99dc84 --- /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 0ca7a213..e6c8c0fa 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 adb530fd..176ea737 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 5048b349..0fcc9745 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 24390017..ccef53de 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 f754ba42..547d960b 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 e6c8c0fa..d55f479a 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 176ea737..5094e1af 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 547d960b..1d25a42f 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 ca99dc84..2a62d299 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 d55f479a..0b5a85ec 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 5094e1af..9b6e1089 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 9b6e1089..87466cde 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 0fcc9745..05034659 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 ccef53de..6fdbffd7 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 2a62d299..e5f963ca 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 0b5a85ec..f9bd61e7 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 6fdbffd7..d1485593 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 1d25a42f..f3d57f4f 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 f9bd61e7..c94fd707 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 c94fd707..9dc00187 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 -