Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions tableauserverclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
DatabaseItem,
DataFreshnessPolicyItem,
DatasourceItem,
ExtensionsServer,
ExtensionsSiteSettings,
FavoriteItem,
FlowItem,
FlowRunItem,
Expand All @@ -36,6 +38,7 @@
ProjectItem,
Resource,
RevisionItem,
SafeExtension,
ScheduleItem,
SiteAuthConfiguration,
SiteOIDCConfiguration,
Expand Down Expand Up @@ -88,6 +91,8 @@
"DEFAULT_NAMESPACE",
"DQWItem",
"ExcelRequestOptions",
"ExtensionsServer",
"ExtensionsSiteSettings",
"FailedSignInError",
"FavoriteItem",
"FileuploadItem",
Expand Down Expand Up @@ -121,6 +126,7 @@
"RequestOptions",
"Resource",
"RevisionItem",
"SafeExtension",
"ScheduleItem",
"Server",
"ServerInfoItem",
Expand Down
4 changes: 4 additions & 0 deletions tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ExtensionsSiteSettings, SafeExtension
from tableauserverclient.models.favorites_item import FavoriteItem
from tableauserverclient.models.fileupload_item import FileuploadItem
from tableauserverclient.models.flow_item import FlowItem
Expand Down Expand Up @@ -113,4 +114,7 @@
"LinkedTaskStepItem",
"LinkedTaskFlowRunItem",
"ExtractItem",
"ExtensionsServer",
"ExtensionsSiteSettings",
"SafeExtension",
]
186 changes: 186 additions & 0 deletions tableauserverclient/models/extensions_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
from typing import overload
from typing_extensions import Self

from defusedxml.ElementTree import fromstring

from tableauserverclient.models.property_decorators import property_is_boolean


class ExtensionsServer:
def __init__(self) -> None:
self._enabled: bool | None = None
self._block_list: list[str] | None = None

@property
def enabled(self) -> bool | None:
"""Indicates whether the extensions server is enabled."""
return self._enabled

@enabled.setter
@property_is_boolean
def enabled(self, value: bool | None) -> None:
self._enabled = value

@property
def block_list(self) -> list[str] | None:
"""List of blocked extensions."""
return self._block_list

@block_list.setter
def block_list(self, value: list[str] | None) -> 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) if e.text is not None]

return obj


class SafeExtension:
def __init__(
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) -> bool | None:
return self._full_data_allowed

@full_data_allowed.setter
@property_is_boolean
def full_data_allowed(self, value: bool | None) -> None:
self._full_data_allowed = value

@property
def prompt_needed(self) -> bool | None:
return self._prompt_needed

@prompt_needed.setter
@property_is_boolean
def prompt_needed(self, value: bool | None) -> None:
self._prompt_needed = value


class ExtensionsSiteSettings:
def __init__(self) -> 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) -> bool | None:
return self._enabled

@enabled.setter
@property_is_boolean
def enabled(self, value: bool | None) -> None:
self._enabled = value

@property
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: bool | None) -> None:
self._use_default_setting = value
Comment on lines +94 to +101

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the useDefaultSetting setting is deprecated by the time new new trusted extensions settings come around - we can skip adding it I think! @bcantoni to confirm

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jacksonlauren Sounds like we might need to keep this for as long as anything <3.27 is supported though.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm remembering correctly, it is fully unhooked from any client code - essentially a noop - and has been for some time now. Even though <3.27 has it in the API, there's not a need for anyone to use it.

I'll check with Brian though, I'm not sure when exactly that happened and if we still support versions of Tableau that would utilize useDefaultSetting in any way


@property
def allow_trusted(self) -> bool | None:
return self._allow_trusted

@allow_trusted.setter
@property_is_boolean
def allow_trusted(self, value: bool | None) -> None:
self._allow_trusted = value

@property
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: bool | None) -> None:
self._include_tableau_built = value

@property
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: bool | None) -> None:
self._include_partner_built = value

@property
def include_sandboxed(self) -> bool | None:
return self._include_sandboxed

@include_sandboxed.setter
@property_is_boolean
def include_sandboxed(self, value: bool | None) -> None:
self._include_sandboxed = 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_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):
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: ...


@overload
def string_to_bool(s: None) -> None: ...


def string_to_bool(s):
return s.lower() == "true" if s is not None else None
4 changes: 2 additions & 2 deletions tableauserverclient/models/property_decorators.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import datetime
import re
from functools import wraps
from typing import Any, Optional
from typing import Any
from collections.abc import Container

from tableauserverclient.datetime_helpers import parse_datetime
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions tableauserverclient/server/endpoint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +43,7 @@
"QuerysetEndpoint",
"MissingRequiredFieldError",
"Endpoint",
"Extensions",
"Favorites",
"Fileuploads",
"FlowRuns",
Expand Down
79 changes: 79 additions & 0 deletions tableauserverclient/server/endpoint/extensions_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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


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"

@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

Returns
-------
ExtensionsServer
The server extensions settings
"""
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. Overwrites all existing settings. Any
sites omitted from the block list will be unblocked.

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)

@api(version="3.21")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the site settings, I wonder if we should just use 3.27 here? The methods were added in 3.21, but the new trusted items and such weren't added until 3.27. Thoughts?

def get(self) -> ExtensionsSiteSettings:
"""Lists the extensions settings for the site

Returns
-------
ExtensionsSiteSettings
The site extensions settings
"""
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.
Comment on lines +63 to +65

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the API directly, all of the settings are optional, and only overwrite if included (with the safelist being a full overwrite iff included), but it seems here that that is not what this comment is.

Is that an intentional change for the python wrapping? Or am I misreading the comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'll update the docstring to reflect that. I over-generalized the behavior from the safelist

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jacksonlauren How would the user purge the safe list? The behavior I wrote would not add any safeList sub elements if the user sets it as an empty list.

https://github.com/tableau/server-client-python/pull/1672/files#diff-d4293e36603783537f9002365e19cc03c973fe1e68b4192d4466bbf0bc555c95R1666-R1667

Copy link

@jacksonlauren jacksonlauren Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the API directly, you'd pass in an empty array to designate purging the list. So for this instance I think we'd want something like this, if the aim is to match the REST API:

if extensions_site_settings.safe_list is not None: 
    /* purge the list here */

    for safe in extensions_site_settings.safe_list:
        /* add in the new elements */

/* implied else => do nothing to the safe list */


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)
Loading
Loading