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
-