From 0b0d14f4924373cf50c5aba7cd23438d7270336c Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Tue, 4 Feb 2025 01:29:42 -0600
Subject: [PATCH 01/15] feat: project support all fields
---
tableauserverclient/models/location_item.py | 38 +++++
tableauserverclient/models/project_item.py | 132 ++++++++++++++----
.../server/endpoint/users_endpoint.py | 2 +-
tableauserverclient/server/request_options.py | 117 +++++++++++++++-
test/assets/project_get_all_fields.xml | 9 ++
test/test_project.py | 26 ++++
test/test_request_option.py | 2 +-
7 files changed, 295 insertions(+), 31 deletions(-)
create mode 100644 tableauserverclient/models/location_item.py
create mode 100644 test/assets/project_get_all_fields.xml
diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py
new file mode 100644
index 000000000..08c2ac996
--- /dev/null
+++ b/tableauserverclient/models/location_item.py
@@ -0,0 +1,38 @@
+from typing import Optional
+import xml.etree.ElementTree as ET
+
+
+class LocationItem:
+ class Type:
+ PersonalSpace = "PersonalSpace"
+ Project = "Project"
+
+ def __init__(self):
+ self._id: Optional[str] = None
+ self._type: Optional[str] = None
+ self._name: Optional[str] = None
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}({self.__dict__!r})"
+
+ @property
+ def id(self) -> Optional[str]:
+ return self._id
+
+ @property
+ def type(self) -> Optional[str]:
+ return self._type
+
+ @property
+ def name(self) -> Optional[str]:
+ return self._name
+
+ @classmethod
+ def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "LocationItem":
+ if ns is None:
+ ns = {}
+ location = cls()
+ location._id = xml.get("id", None)
+ location._type = xml.get("type", None)
+ location._name = xml.get("name", None)
+ return location
diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py
index 9be1196ba..a950b4d36 100644
--- a/tableauserverclient/models/project_item.py
+++ b/tableauserverclient/models/project_item.py
@@ -1,11 +1,10 @@
-import logging
import xml.etree.ElementTree as ET
-from typing import Optional
+from typing import Optional, overload
from defusedxml.ElementTree import fromstring
from tableauserverclient.models.exceptions import UnpopulatedPropertyError
-from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty
+from tableauserverclient.models.property_decorators import property_is_enum
class ProjectItem:
@@ -75,6 +74,8 @@ def __init__(
self.parent_id: Optional[str] = parent_id
self._samples: Optional[bool] = samples
self._owner_id: Optional[str] = None
+ self._top_level_project: Optional[bool] = None
+ self._writeable: Optional[bool] = None
self._permissions = None
self._default_workbook_permissions = None
@@ -87,6 +88,11 @@ def __init__(
self._default_database_permissions = None
self._default_table_permissions = None
+ self._project_count: Optional[int] = None
+ self._workbok_count: Optional[int] = None
+ self._view_count: Optional[int] = None
+ self._datasource_count: Optional[int] = None
+
@property
def content_permissions(self):
return self._content_permissions
@@ -176,25 +182,48 @@ def owner_id(self) -> Optional[str]:
def owner_id(self, value: str) -> None:
self._owner_id = value
+ @property
+ def top_level_project(self) -> Optional[bool]:
+ return self._top_level_project
+
+ @property
+ def writeable(self) -> Optional[bool]:
+ return self._writeable
+
+ @property
+ def project_count(self) -> Optional[int]:
+ return self._project_count
+
+ @property
+ def workbok_count(self) -> Optional[int]:
+ return self._workbok_count
+
+ @property
+ def view_count(self) -> Optional[int]:
+ return self._view_count
+
+ @property
+ def datasource_count(self) -> Optional[int]:
+ return self._datasource_count
+
def is_default(self):
return self.name.lower() == "default"
- def _parse_common_tags(self, project_xml, ns):
- if not isinstance(project_xml, ET.Element):
- project_xml = fromstring(project_xml).find(".//t:project", namespaces=ns)
-
- if project_xml is not None:
- (
- _,
- name,
- description,
- content_permissions,
- parent_id,
- ) = self._parse_element(project_xml)
- self._set_values(None, name, description, content_permissions, parent_id)
- return self
-
- def _set_values(self, project_id, name, description, content_permissions, parent_id, owner_id):
+ def _set_values(
+ self,
+ project_id,
+ name,
+ description,
+ content_permissions,
+ parent_id,
+ owner_id,
+ top_level_project,
+ writeable,
+ project_count,
+ workbok_count,
+ view_count,
+ datasource_count,
+ ):
if project_id is not None:
self._id = project_id
if name:
@@ -207,6 +236,18 @@ def _set_values(self, project_id, name, description, content_permissions, parent
self.parent_id = parent_id
if owner_id:
self._owner_id = owner_id
+ if project_count is not None:
+ self._project_count = project_count
+ if workbok_count is not None:
+ self._workbok_count = workbok_count
+ if view_count is not None:
+ self._view_count = view_count
+ if datasource_count is not None:
+ self._datasource_count = datasource_count
+ if top_level_project is not None:
+ self._top_level_project = top_level_project
+ if writeable is not None:
+ self._writeable = writeable
def _set_permissions(self, permissions):
self._permissions = permissions
@@ -220,31 +261,68 @@ def _set_default_permissions(self, permissions, content_type):
)
@classmethod
- def from_response(cls, resp, ns) -> list["ProjectItem"]:
+ def from_response(cls, resp: bytes, ns: Optional[dict]) -> list["ProjectItem"]:
all_project_items = list()
parsed_response = fromstring(resp)
all_project_xml = parsed_response.findall(".//t:project", namespaces=ns)
for project_xml in all_project_xml:
- project_item = cls.from_xml(project_xml)
+ project_item = cls.from_xml(project_xml, namespace=ns)
all_project_items.append(project_item)
return all_project_items
@classmethod
- def from_xml(cls, project_xml, namespace=None) -> "ProjectItem":
+ def from_xml(cls, project_xml: ET.Element, namespace: Optional[dict] = None) -> "ProjectItem":
project_item = cls()
- project_item._set_values(*cls._parse_element(project_xml))
+ project_item._set_values(*cls._parse_element(project_xml, namespace))
return project_item
@staticmethod
- def _parse_element(project_xml):
+ def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple:
id = project_xml.get("id", None)
name = project_xml.get("name", None)
description = project_xml.get("description", None)
content_permissions = project_xml.get("contentPermissions", None)
parent_id = project_xml.get("parentProjectId", None)
+ top_level_project = str_to_bool(project_xml.get("topLevelProject", None))
+ writeable = str_to_bool(project_xml.get("writeable", None))
owner_id = None
- for owner in project_xml:
- owner_id = owner.get("id", None)
+ if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None:
+ owner_id = owner_elem.get("id", None)
+
+ project_count = None
+ workbok_count = None
+ view_count = None
+ datasource_count = None
+ if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None:
+ project_count = int(count_elem.get("projectCount", 0))
+ workbok_count = int(count_elem.get("workbookCount", 0))
+ view_count = int(count_elem.get("viewCount", 0))
+ datasource_count = int(count_elem.get("dataSourceCount", 0))
+
+ return (
+ id,
+ name,
+ description,
+ content_permissions,
+ parent_id,
+ owner_id,
+ top_level_project,
+ writeable,
+ project_count,
+ workbok_count,
+ view_count,
+ datasource_count,
+ )
+
+
+@overload
+def str_to_bool(value: str) -> bool: ...
+
+
+@overload
+def str_to_bool(value: None) -> None: ...
+
- return id, name, description, content_permissions, parent_id, owner_id
+def str_to_bool(value):
+ return value.lower() == "true" if value is not None else None
diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py
index d81907ae9..6489f2a79 100644
--- a/tableauserverclient/server/endpoint/users_endpoint.py
+++ b/tableauserverclient/server/endpoint/users_endpoint.py
@@ -87,7 +87,7 @@ def get(self, req_options: Optional[RequestOptions] = None) -> tuple[list[UserIt
if req_options is None:
req_options = RequestOptions()
- req_options._all_fields = True
+ req_options.all_fields = True
url = self.baseurl
server_response = self.get_request(url, req_options)
diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py
index 504f7f3ca..bcae7818a 100644
--- a/tableauserverclient/server/request_options.py
+++ b/tableauserverclient/server/request_options.py
@@ -62,8 +62,9 @@ def __init__(self, pagenumber=1, pagesize=None):
self.pagesize = pagesize or config.PAGE_SIZE
self.sort = set()
self.filter = set()
+ self.fields = set()
# This is private until we expand all of our parsers to handle the extra fields
- self._all_fields = False
+ self.all_fields = False
def get_query_params(self) -> dict:
params = {}
@@ -75,12 +76,14 @@ def get_query_params(self) -> dict:
filter_options = (str(filter_item) for filter_item in self.filter)
ordered_filter_options = sorted(filter_options)
params["filter"] = ",".join(ordered_filter_options)
- if self._all_fields:
+ if self.all_fields:
params["fields"] = "_all_"
if self.pagenumber:
params["pageNumber"] = self.pagenumber
if self.pagesize:
params["pageSize"] = self.pagesize
+ if self.fields:
+ params["fields"] = ",".join(self.fields)
return params
def page_size(self, page_size):
@@ -181,6 +184,116 @@ class Direction:
Desc = "desc"
Asc = "asc"
+ class SelectFields:
+ class Common:
+ All = "_all_"
+ Default = "_default_"
+
+ class ContentsCounts:
+ ProjectCount = "contentsCounts.projectCount"
+ ViewCount = "contentsCounts.viewCount"
+ DatasourceCount = "contentsCounts.datasourceCount"
+ WorkbookCount = "contentsCounts.workbookCount"
+
+ class Datasource:
+ ContentUrl = "datasource.contentUrl"
+ ID = "datasource.id"
+ Name = "datasource.name"
+ Type = "datasource.type"
+ Description = "datasource.description"
+ CreatedAt = "datasource.createdAt"
+ UpdatedAt = "datasource.updatedAt"
+ EncryptExtracts = "datasource.encryptExtracts"
+ IsCertified = "datasource.isCertified"
+ UseRemoteQueryAgent = "datasource.useRemoteQueryAgent"
+ WebPageURL = "datasource.webpageUrl"
+ Size = "datasource.size"
+ Tag = "datasource.tag"
+ FavoritesTotal = "datasource.favoritesTotal"
+ DatabaseName = "datasource.databaseName"
+ ConnectedWorkbooksCount = "datasource.connectedWorkbooksCount"
+ HasAlert = "datasource.hasAlert"
+ HasExtracts = "datasource.hasExtracts"
+ IsPublished = "datasource.isPublished"
+ ServerName = "datasource.serverName"
+
+ class Favorite:
+ Label = "favorite.label"
+ ParentProjectName = "favorite.parentProjectName"
+ TargetOwnerName = "favorite.targetOwnerName"
+
+ class Group:
+ ID = "group.id"
+ Name = "group.name"
+ DomainName = "group.domainName"
+ UserCount = "group.userCount"
+ MinimumSiteRole = "group.minimumSiteRole"
+
+ class Job:
+ ID = "job.id"
+ Status = "job.status"
+ CreatedAt = "job.createdAt"
+ StartedAt = "job.startedAt"
+ EndedAt = "job.endedAt"
+ Priority = "job.priority"
+ JobType = "job.jobType"
+ Title = "job.title"
+ Subtitle = "job.subtitle"
+
+ class Owner:
+ ID = "owner.id"
+ Name = "owner.name"
+ FullName = "owner.fullName"
+ SiteRole = "owner.siteRole"
+ LastLogin = "owner.lastLogin"
+ Email = "owner.email"
+
+ class Project:
+ ID = "project.id"
+ Name = "project.name"
+ Description = "project.description"
+ CreatedAt = "project.createdAt"
+ UpdatedAt = "project.updatedAt"
+ ContentPermissions = "project.contentPermissions"
+ ParentProjectID = "project.parentProjectId"
+ TopLevelProject = "project.topLevelProject"
+ Writeable = "project.writeable"
+
+ class User:
+ ExternalAuthUserId = "user.externalAuthUserId"
+ ID = "user.id"
+ Name = "user.name"
+ SiteRole = "user.siteRole"
+ LastLogin = "user.lastLogin"
+ FullName = "user.fullName"
+ Email = "user.email"
+ AuthSetting = "user.authSetting"
+
+ class View:
+ ID = "view.id"
+ Name = "view.name"
+ ContentUrl = "view.contentUrl"
+ CreatedAt = "view.createdAt"
+ UpdatedAt = "view.updatedAt"
+ Tags = "view.tags"
+ SheetType = "view.sheetType"
+ Usage = "view.usage"
+
+ class Workbook:
+ ID = "workbook.id"
+ Description = "workbook.description"
+ Name = "workbook.name"
+ ContentUrl = "workbook.contentUrl"
+ ShowTabs = "workbook.showTabs"
+ Size = "workbook.size"
+ CreatedAt = "workbook.createdAt"
+ UpdatedAt = "workbook.updatedAt"
+ SheetCount = "workbook.sheetCount"
+ HasExtracts = "workbook.hasExtracts"
+ Tags = "workbook.tags"
+ WebpageUrl = "workbook.webpageUrl"
+ DefaultViewId = "workbook.defaultViewId"
+
"""
These options can be used by methods that are fetching data exported from a specific content item
diff --git a/test/assets/project_get_all_fields.xml b/test/assets/project_get_all_fields.xml
new file mode 100644
index 000000000..d71ebd922
--- /dev/null
+++ b/test/assets/project_get_all_fields.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/test/test_project.py b/test/test_project.py
index 56787efac..c51f2e1e6 100644
--- a/test/test_project.py
+++ b/test/test_project.py
@@ -10,6 +10,7 @@
TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
GET_XML = asset("project_get.xml")
+GET_XML_ALL_FIELDS = asset("project_get_all_fields.xml")
UPDATE_XML = asset("project_update.xml")
SET_CONTENT_PERMISSIONS_XML = asset("project_content_permission.xml")
CREATE_XML = asset("project_create.xml")
@@ -410,3 +411,28 @@ def test_delete_virtualconnection_default_permimssions(self):
m.delete(f"{base_url}/{endpoint}/Connect/Allow", status_code=204)
self.server.projects.delete_virtualconnection_default_permissions(project, rule)
+
+ def test_get_all_fields(self) -> None:
+ self.server.version = "3.23"
+ base_url = self.server.projects.baseurl
+ with open(GET_XML_ALL_FIELDS, "rb") as f:
+ response_xml = f.read().decode("utf-8")
+
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+
+ with requests_mock.mock() as m:
+ m.get(f"{base_url}?fields=_all_", text=response_xml)
+ all_projects, pagination_item = self.server.projects.get(req_options=ro)
+
+ assert pagination_item.total_available == 3
+ assert len(all_projects) == 1
+ project: TSC.ProjectItem = all_projects[0]
+ assert isinstance(project, TSC.ProjectItem)
+ assert project.id == "ee8c6e70-43b6-11e6-af4f-f7b0d8e20760"
+ assert project.name == "Samples"
+ assert project.description == "This project includes automatically uploaded samples."
+ assert project.top_level_project is True
+ assert project.content_permissions == "ManagedByOwner"
+ assert project.parent_id is None
+ assert project.writeable is True
diff --git a/test/test_request_option.py b/test/test_request_option.py
index 7405189a3..751269ea1 100644
--- a/test/test_request_option.py
+++ b/test/test_request_option.py
@@ -251,7 +251,7 @@ def test_all_fields(self) -> None:
m.get(requests_mock.ANY)
url = self.baseurl + "/views/456/data"
opts = TSC.RequestOptions()
- opts._all_fields = True
+ opts.all_fields = True
resp = self.server.users.get_request(url, request_object=opts)
self.assertTrue(re.search("fields=_all_", resp.request.query))
From e4c3ef5d59d5bb61140ec1bb954a4d4dc97c745c Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Tue, 4 Feb 2025 01:44:27 -0600
Subject: [PATCH 02/15] feat: groups all fields
---
tableauserverclient/models/group_item.py | 6 ++++++
test/assets/group_get_all_fields.xml | 14 ++++++++++++++
test/test_group.py | 23 +++++++++++++++++++++++
3 files changed, 43 insertions(+)
create mode 100644 test/assets/group_get_all_fields.xml
diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py
index 0afd5582c..6b0c3506c 100644
--- a/tableauserverclient/models/group_item.py
+++ b/tableauserverclient/models/group_item.py
@@ -65,6 +65,7 @@ def __init__(self, name=None, domain_name=None) -> None:
self._users: Optional[Callable[..., "Pager"]] = None
self.name: Optional[str] = name
self.domain_name: Optional[str] = domain_name
+ self._user_count: Optional[int] = None
def __repr__(self):
return f"{self.__class__.__name__}({self.__dict__!r})"
@@ -118,6 +119,10 @@ def users(self) -> "Pager":
def _set_users(self, users: Callable[..., "Pager"]) -> None:
self._users = users
+ @property
+ def user_count(self) -> Optional[int]:
+ return self._user_count
+
@classmethod
def from_response(cls, resp, ns) -> list["GroupItem"]:
all_group_items = list()
@@ -127,6 +132,7 @@ def from_response(cls, resp, ns) -> list["GroupItem"]:
name = group_xml.get("name", None)
group_item = cls(name)
group_item._id = group_xml.get("id", None)
+ group_item._user_count = int(count) if (count := group_xml.get("userCount", None)) else None
# Domain name is returned in a domain element for some calls
domain_elem = group_xml.find(".//t:domain", namespaces=ns)
diff --git a/test/assets/group_get_all_fields.xml b/test/assets/group_get_all_fields.xml
new file mode 100644
index 000000000..0118250e1
--- /dev/null
+++ b/test/assets/group_get_all_fields.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/test_group.py b/test/test_group.py
index 41b5992be..b3de07963 100644
--- a/test/test_group.py
+++ b/test/test_group.py
@@ -10,6 +10,7 @@
# TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml")
+GET_XML_ALL_FIELDS = TEST_ASSET_DIR / "group_get_all_fields.xml"
POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml")
POPULATE_USERS_EMPTY = os.path.join(TEST_ASSET_DIR, "group_populate_users_empty.xml")
ADD_USER = os.path.join(TEST_ASSET_DIR, "group_add_user.xml")
@@ -310,3 +311,25 @@ def test_update_ad_async(self) -> None:
self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b")
self.assertEqual(job.mode, "Asynchronous")
self.assertEqual(job.type, "GroupSync")
+
+ def test_get_all_fields(self) -> None:
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+ self.server.version = "3.21"
+ self.baseurl = self.server.groups.baseurl
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}?fields=_all_", text=GET_XML_ALL_FIELDS.read_text())
+ groups, pages = self.server.groups.get(req_options=ro)
+
+ assert pages.total_available == 3
+ assert len(groups) == 3
+ assert groups[0].id == "28c5b855-16df-482f-ad0b-428c1df58859"
+ assert groups[0].name == "All Users"
+ assert groups[0].user_count == 2
+ assert groups[0].domain_name == "local"
+ assert groups[1].id == "ace1ee2d-e7dd-4d7a-9504-a1ccaa5212ea"
+ assert groups[1].name == "group1"
+ assert groups[1].user_count == 1
+ assert groups[2].id == "baf0ed9d-c25d-4114-97ed-5232b8a732fd"
+ assert groups[2].name == "test"
+ assert groups[2].user_count == 0
From f6d1990679fddbee7b7987cad7cdb55727f1f7d8 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Tue, 4 Feb 2025 08:32:25 -0600
Subject: [PATCH 03/15] feat: views support all fields
---
tableauserverclient/__init__.py | 2 +
tableauserverclient/models/__init__.py | 2 +
tableauserverclient/models/user_item.py | 6 ++
tableauserverclient/models/view_item.py | 70 +++++++++++-
tableauserverclient/models/workbook_item.py | 38 ++++++-
test/assets/view_get_all_fields.xml | 35 ++++++
test/test_view.py | 114 ++++++++++++++++++++
7 files changed, 262 insertions(+), 5 deletions(-)
create mode 100644 test/assets/view_get_all_fields.xml
diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py
index 957a820db..ad08e8ded 100644
--- a/tableauserverclient/__init__.py
+++ b/tableauserverclient/__init__.py
@@ -25,6 +25,7 @@
LinkedTaskItem,
LinkedTaskStepItem,
LinkedTaskFlowRunItem,
+ LocationItem,
MetricItem,
MonthlyInterval,
PaginationItem,
@@ -101,6 +102,7 @@
"LinkedTaskFlowRunItem",
"LinkedTaskItem",
"LinkedTaskStepItem",
+ "LocationItem",
"MetricItem",
"MissingRequiredFieldError",
"MonthlyInterval",
diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py
index e4131b720..5ef81dcf1 100644
--- a/tableauserverclient/models/__init__.py
+++ b/tableauserverclient/models/__init__.py
@@ -28,6 +28,7 @@
LinkedTaskStepItem,
LinkedTaskFlowRunItem,
)
+from tableauserverclient.models.location_item import LocationItem
from tableauserverclient.models.metric_item import MetricItem
from tableauserverclient.models.pagination_item import PaginationItem
from tableauserverclient.models.permissions_item import PermissionsRule, Permission
@@ -75,6 +76,7 @@
"MonthlyInterval",
"HourlyInterval",
"BackgroundJobItem",
+ "LocationItem",
"MetricItem",
"PaginationItem",
"Permission",
diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py
index 365e44c1d..630e5ef3c 100644
--- a/tableauserverclient/models/user_item.py
+++ b/tableauserverclient/models/user_item.py
@@ -249,6 +249,12 @@ def from_response_as_owner(cls, resp, ns) -> list["UserItem"]:
element_name = ".//t:owner"
return cls._parse_xml(element_name, resp, ns)
+ @classmethod
+ def from_xml(cls, xml: ET.Element, ns: Optional[dict] = None) -> "UserItem":
+ item = cls()
+ item._set_values(*cls._parse_element(xml, ns))
+ return item
+
@classmethod
def _parse_xml(cls, element_name, resp, ns):
all_user_items = []
diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py
index 88cec7328..b19155f19 100644
--- a/tableauserverclient/models/view_item.py
+++ b/tableauserverclient/models/view_item.py
@@ -1,15 +1,21 @@
import copy
from datetime import datetime
from requests import Response
-from typing import Callable, Optional
+from typing import TYPE_CHECKING, Callable, Optional, overload
from collections.abc import Iterator
from defusedxml.ElementTree import fromstring
from tableauserverclient.datetime_helpers import parse_datetime
from tableauserverclient.models.exceptions import UnpopulatedPropertyError
+from tableauserverclient.models.location_item import LocationItem
from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.models.project_item import ProjectItem
from tableauserverclient.models.tag_item import TagItem
+from tableauserverclient.models.user_item import UserItem
+
+if TYPE_CHECKING:
+ from tableauserverclient.models.workbook_item import WorkbookItem
class ViewItem:
@@ -84,11 +90,18 @@ def __init__(self) -> None:
self._workbook_id: Optional[str] = None
self._permissions: Optional[Callable[[], list[PermissionsRule]]] = None
self.tags: set[str] = set()
+ self._favorites_total: Optional[int] = None
+ self._view_url_name: Optional[str] = None
self._data_acceleration_config = {
"acceleration_enabled": None,
"acceleration_status": None,
}
+ self._owner: Optional[UserItem] = None
+ self._project: Optional[ProjectItem] = None
+ self._workbook: Optional["WorkbookItem"] = None
+ self._location: Optional[LocationItem] = None
+
def __str__(self):
return "".format(
self._id, self.name, self.content_url, self.project_id
@@ -190,6 +203,14 @@ def updated_at(self) -> Optional[datetime]:
def workbook_id(self) -> Optional[str]:
return self._workbook_id
+ @property
+ def view_url_name(self) -> Optional[str]:
+ return self._view_url_name
+
+ @property
+ def favorites_total(self) -> Optional[int]:
+ return self._favorites_total
+
@property
def data_acceleration_config(self):
return self._data_acceleration_config
@@ -198,6 +219,22 @@ def data_acceleration_config(self):
def data_acceleration_config(self, value):
self._data_acceleration_config = value
+ @property
+ def project(self) -> Optional["ProjectItem"]:
+ return self._project
+
+ @property
+ def workbook(self) -> Optional["WorkbookItem"]:
+ return self._workbook
+
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
+
+ @property
+ def location(self) -> Optional[LocationItem]:
+ return self._location
+
@property
def permissions(self) -> list[PermissionsRule]:
if self._permissions is None:
@@ -228,7 +265,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem":
workbook_elem = view_xml.find(".//t:workbook", namespaces=ns)
owner_elem = view_xml.find(".//t:owner", namespaces=ns)
project_elem = view_xml.find(".//t:project", namespaces=ns)
- tags_elem = view_xml.find(".//t:tags", namespaces=ns)
+ tags_elem = view_xml.find("./t:tags", namespaces=ns)
data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns)
view_item._created_at = parse_datetime(view_xml.get("createdAt", None))
view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None))
@@ -236,22 +273,35 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem":
view_item._name = view_xml.get("name", None)
view_item._content_url = view_xml.get("contentUrl", None)
view_item._sheet_type = view_xml.get("sheetType", None)
+ view_item._favorites_total = string_to_int(view_xml.get("favoritesTotal", None))
+ view_item._view_url_name = view_xml.get("viewUrlName", None)
if usage_elem is not None:
total_view = usage_elem.get("totalViewCount", None)
if total_view:
view_item._total_views = int(total_view)
if owner_elem is not None:
+ user = UserItem.from_xml(owner_elem, ns)
+ view_item._owner = user
view_item._owner_id = owner_elem.get("id", None)
if project_elem is not None:
- view_item._project_id = project_elem.get("id", None)
+ project_item = ProjectItem.from_xml(project_elem, ns)
+ view_item._project = project_item
+ view_item._project_id = project_item.id
if workbook_id:
view_item._workbook_id = workbook_id
elif workbook_elem is not None:
- view_item._workbook_id = workbook_elem.get("id", None)
+ from tableauserverclient.models.workbook_item import WorkbookItem
+
+ workbook_item = WorkbookItem.from_xml(workbook_elem, ns)
+ view_item._workbook = workbook_item
+ view_item._workbook_id = workbook_item.id
if tags_elem is not None:
tags = TagItem.from_xml_element(tags_elem, ns)
view_item.tags = tags
view_item._initial_tags = copy.copy(tags)
+ if (location_elem := view_xml.find(".//t:location", namespaces=ns)) is not None:
+ location = LocationItem.from_xml(location_elem, ns)
+ view_item._location = location
if data_acceleration_config_elem is not None:
data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem)
view_item.data_acceleration_config = data_acceleration_config
@@ -274,3 +324,15 @@ def parse_data_acceleration_config(data_acceleration_elem):
def string_to_bool(s: str) -> bool:
return s.lower() == "true"
+
+
+@overload
+def string_to_int(s: None) -> None: ...
+
+
+@overload
+def string_to_int(s: str) -> int: ...
+
+
+def string_to_int(s):
+ return int(s) if s is not None else None
diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py
index 32ab413a4..80efb3deb 100644
--- a/tableauserverclient/models/workbook_item.py
+++ b/tableauserverclient/models/workbook_item.py
@@ -2,7 +2,7 @@
import datetime
import uuid
import xml.etree.ElementTree as ET
-from typing import Callable, Optional
+from typing import Callable, Optional, overload
from defusedxml.ElementTree import fromstring
@@ -139,6 +139,8 @@ def __init__(
self._permissions = None
self.thumbnails_user_id = thumbnails_user_id
self.thumbnails_group_id = thumbnails_group_id
+ self._sheet_count: Optional[int] = None
+ self._has_extracts: Optional[bool] = None
return None
@@ -234,6 +236,14 @@ def show_tabs(self, value: bool):
def size(self):
return self._size
+ @property
+ def sheet_count(self) -> Optional[int]:
+ return self._sheet_count
+
+ @property
+ def has_extracts(self) -> Optional[bool]:
+ return self._has_extracts
+
@property
def updated_at(self) -> Optional[datetime.datetime]:
return self._updated_at
@@ -342,6 +352,8 @@ def _parse_common_tags(self, workbook_xml, ns):
views,
data_acceleration_config,
data_freshness_policy,
+ sheet_count,
+ has_extracts,
) = self._parse_element(workbook_xml, ns)
self._set_values(
@@ -361,6 +373,8 @@ def _parse_common_tags(self, workbook_xml, ns):
views,
data_acceleration_config,
data_freshness_policy,
+ sheet_count,
+ has_extracts,
)
return self
@@ -383,6 +397,8 @@ def _set_values(
views,
data_acceleration_config,
data_freshness_policy,
+ sheet_count,
+ has_extracts,
):
if id is not None:
self._id = id
@@ -417,6 +433,10 @@ def _set_values(
self.data_acceleration_config = data_acceleration_config
if data_freshness_policy is not None:
self.data_freshness_policy = data_freshness_policy
+ if sheet_count is not None:
+ self._sheet_count = sheet_count
+ if has_extracts is not None:
+ self._has_extracts = has_extracts
@classmethod
def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]:
@@ -443,6 +463,8 @@ def _parse_element(workbook_xml, ns):
created_at = parse_datetime(workbook_xml.get("createdAt", None))
description = workbook_xml.get("description", None)
updated_at = parse_datetime(workbook_xml.get("updatedAt", None))
+ sheet_count = string_to_int(workbook_xml.get("sheetCount", None))
+ has_extracts = string_to_bool(workbook_xml.get("hasExtracts", ""))
size = workbook_xml.get("size", None)
if size:
@@ -505,6 +527,8 @@ def _parse_element(workbook_xml, ns):
views,
data_acceleration_config,
data_freshness_policy,
+ sheet_count,
+ has_extracts,
)
@@ -535,3 +559,15 @@ def parse_data_acceleration_config(data_acceleration_elem):
# Used to convert string represented boolean to a boolean type
def string_to_bool(s: str) -> bool:
return s.lower() == "true"
+
+
+@overload
+def string_to_int(s: None) -> None: ...
+
+
+@overload
+def string_to_int(s: str) -> int: ...
+
+
+def string_to_int(s):
+ return int(s) if s is not None else None
diff --git a/test/assets/view_get_all_fields.xml b/test/assets/view_get_all_fields.xml
new file mode 100644
index 000000000..236ebd726
--- /dev/null
+++ b/test/assets/view_get_all_fields.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/test_view.py b/test/test_view.py
index 3fdaf60e6..5ac69e98b 100644
--- a/test/test_view.py
+++ b/test/test_view.py
@@ -12,6 +12,7 @@
ADD_TAGS_XML = os.path.join(TEST_ASSET_DIR, "view_add_tags.xml")
GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml")
+GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "view_get_all_fields.xml")
GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml")
GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml")
GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml")
@@ -402,3 +403,116 @@ def test_pdf_errors(self) -> None:
req_option = TSC.PDFRequestOptions(viz_width=1920)
with self.assertRaises(ValueError):
req_option.get_query_params()
+
+ def test_view_get_all_fields(self) -> None:
+ self.server.version = "3.21"
+ self.baseurl = self.server.views.baseurl
+ with open(GET_XML_ALL_FIELDS) as f:
+ response_xml = f.read()
+
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}?fields=_all_", text=response_xml)
+ views, _ = self.server.views.get(req_options=ro)
+
+ assert views[0].id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534"
+ assert views[0].name == "Overview"
+ assert views[0].content_url == "Superstore/sheets/Overview"
+ assert views[0].created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[0].updated_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[0].sheet_type == "dashboard"
+ assert views[0].favorites_total == 0
+ assert views[0].view_url_name == "Overview"
+ assert isinstance(views[0].workbook, TSC.WorkbookItem)
+ assert views[0].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df"
+ assert views[0].workbook.name == "Superstore"
+ assert views[0].workbook.content_url == "Superstore"
+ assert views[0].workbook.show_tabs
+ assert views[0].workbook.size == 2
+ assert views[0].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[0].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z")
+ assert views[0].workbook.sheet_count == 9
+ assert not views[0].workbook.has_extracts
+ assert isinstance(views[0].owner, TSC.UserItem)
+ assert views[0].owner.email == "bob@example.com"
+ assert views[0].owner.fullname == "Bob"
+ assert views[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert views[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert views[0].owner.name == "bob@example.com"
+ assert views[0].owner.site_role == "SiteAdministratorCreator"
+ assert isinstance(views[0].project, TSC.ProjectItem)
+ assert views[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[0].project.name == "Samples"
+ assert views[0].project.description == "This project includes automatically uploaded samples."
+ assert views[0].total_views == 0
+ assert isinstance(views[0].location, TSC.LocationItem)
+ assert views[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[0].location.type == "Project"
+ assert views[1].id == "2a3fd19d-9129-413d-9ff7-9dfc36bf7f7e"
+ assert views[1].name == "Product"
+ assert views[1].content_url == "Superstore/sheets/Product"
+ assert views[1].created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[1].updated_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[1].sheet_type == "dashboard"
+ assert views[1].favorites_total == 0
+ assert views[1].view_url_name == "Product"
+ assert isinstance(views[1].workbook, TSC.WorkbookItem)
+ assert views[1].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df"
+ assert views[1].workbook.name == "Superstore"
+ assert views[1].workbook.content_url == "Superstore"
+ assert views[1].workbook.show_tabs
+ assert views[1].workbook.size == 2
+ assert views[1].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[1].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z")
+ assert views[1].workbook.sheet_count == 9
+ assert not views[1].workbook.has_extracts
+ assert isinstance(views[1].owner, TSC.UserItem)
+ assert views[1].owner.email == "bob@example.com"
+ assert views[1].owner.fullname == "Bob"
+ assert views[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert views[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert views[1].owner.name == "bob@example.com"
+ assert views[1].owner.site_role == "SiteAdministratorCreator"
+ assert isinstance(views[1].project, TSC.ProjectItem)
+ assert views[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[1].project.name == "Samples"
+ assert views[1].project.description == "This project includes automatically uploaded samples."
+ assert views[1].total_views == 0
+ assert isinstance(views[1].location, TSC.LocationItem)
+ assert views[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[1].location.type == "Project"
+ assert views[2].id == "459eda9a-85e4-46bf-a2f2-62936bd2e99a"
+ assert views[2].name == "Customers"
+ assert views[2].content_url == "Superstore/sheets/Customers"
+ assert views[2].created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[2].updated_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[2].sheet_type == "dashboard"
+ assert views[2].favorites_total == 0
+ assert views[2].view_url_name == "Customers"
+ assert isinstance(views[2].workbook, TSC.WorkbookItem)
+ assert views[2].workbook.id == "9df3e2d1-070e-497a-9578-8cc557ced9df"
+ assert views[2].workbook.name == "Superstore"
+ assert views[2].workbook.content_url == "Superstore"
+ assert views[2].workbook.show_tabs
+ assert views[2].workbook.size == 2
+ assert views[2].workbook.created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert views[2].workbook.updated_at == parse_datetime("2024-02-14T04:42:10Z")
+ assert views[2].workbook.sheet_count == 9
+ assert not views[2].workbook.has_extracts
+ assert isinstance(views[2].owner, TSC.UserItem)
+ assert views[2].owner.email == "bob@example.com"
+ assert views[2].owner.fullname == "Bob"
+ assert views[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert views[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert views[2].owner.name == "bob@example.com"
+ assert views[2].owner.site_role == "SiteAdministratorCreator"
+ assert isinstance(views[2].project, TSC.ProjectItem)
+ assert views[2].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[2].project.name == "Samples"
+ assert views[2].project.description == "This project includes automatically uploaded samples."
+ assert views[2].total_views == 0
+ assert isinstance(views[2].location, TSC.LocationItem)
+ assert views[2].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert views[2].location.type == "Project"
From de7ca77a4ac6ccfa5c2cdec928c73f7173eb3a19 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Tue, 4 Feb 2025 21:53:28 -0600
Subject: [PATCH 04/15] feat: support _all_ fields for UserItem
---
tableauserverclient/models/user_item.py | 44 ++++++++++++++++++++++++-
test/assets/user_get_all_fields.xml | 11 +++++++
test/test_user.py | 37 ++++++++++++++++++++-
3 files changed, 90 insertions(+), 2 deletions(-)
create mode 100644 test/assets/user_get_all_fields.xml
diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py
index 630e5ef3c..f7571e1c5 100644
--- a/tableauserverclient/models/user_item.py
+++ b/tableauserverclient/models/user_item.py
@@ -94,6 +94,9 @@ def __init__(
self.name: Optional[str] = name
self.site_role: Optional[str] = site_role
self.auth_setting: Optional[str] = auth_setting
+ self._locale: Optional[str] = None
+ self._language: Optional[str] = None
+ self._idp_configuration_id: Optional[str] = None
return None
@@ -184,6 +187,18 @@ def groups(self) -> "Pager":
raise UnpopulatedPropertyError(error)
return self._groups()
+ @property
+ def locale(self) -> Optional[str]:
+ return self._locale
+
+ @property
+ def language(self) -> Optional[str]:
+ return self._language
+
+ @property
+ def idp_configuration_id(self) -> Optional[str]:
+ return self._idp_configuration_id
+
def _set_workbooks(self, workbooks) -> None:
self._workbooks = workbooks
@@ -204,8 +219,11 @@ def _parse_common_tags(self, user_xml, ns) -> "UserItem":
email,
auth_setting,
_,
+ _,
+ _,
+ _,
) = self._parse_element(user_xml, ns)
- self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None)
+ self._set_values(None, None, site_role, None, None, fullname, email, auth_setting, None, None, None, None)
return self
def _set_values(
@@ -219,6 +237,9 @@ def _set_values(
email,
auth_setting,
domain_name,
+ locale,
+ language,
+ idp_configuration_id,
):
if id is not None:
self._id = id
@@ -238,6 +259,12 @@ def _set_values(
self._auth_setting = auth_setting
if domain_name:
self._domain_name = domain_name
+ if locale:
+ self._locale = locale
+ if language:
+ self._language = language
+ if idp_configuration_id:
+ self._idp_configuration_id = idp_configuration_id
@classmethod
def from_response(cls, resp, ns) -> list["UserItem"]:
@@ -271,6 +298,9 @@ def _parse_xml(cls, element_name, resp, ns):
email,
auth_setting,
domain_name,
+ locale,
+ language,
+ idp_configuration_id,
) = cls._parse_element(user_xml, ns)
user_item = cls(name, site_role)
user_item._set_values(
@@ -283,6 +313,9 @@ def _parse_xml(cls, element_name, resp, ns):
email,
auth_setting,
domain_name,
+ locale,
+ language,
+ idp_configuration_id,
)
all_user_items.append(user_item)
return all_user_items
@@ -301,6 +334,9 @@ def _parse_element(user_xml, ns):
fullname = user_xml.get("fullName", None)
email = user_xml.get("email", None)
auth_setting = user_xml.get("authSetting", None)
+ locale = user_xml.get("locale", None)
+ language = user_xml.get("language", None)
+ idp_configuration_id = user_xml.get("idpConfigurationId", None)
domain_name = None
domain_elem = user_xml.find(".//t:domain", namespaces=ns)
@@ -317,6 +353,9 @@ def _parse_element(user_xml, ns):
email,
auth_setting,
domain_name,
+ locale,
+ language,
+ idp_configuration_id,
)
class CSVImport:
@@ -367,6 +406,9 @@ def create_user_from_line(line: str):
values[UserItem.CSVImport.ColumnType.EMAIL],
values[UserItem.CSVImport.ColumnType.AUTH],
None,
+ None,
+ None,
+ None,
)
return user
diff --git a/test/assets/user_get_all_fields.xml b/test/assets/user_get_all_fields.xml
new file mode 100644
index 000000000..7e9a62568
--- /dev/null
+++ b/test/assets/user_get_all_fields.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/test_user.py b/test/test_user.py
index a46624845..6552fbf86 100644
--- a/test/test_user.py
+++ b/test/test_user.py
@@ -4,11 +4,12 @@
import requests_mock
import tableauserverclient as TSC
-from tableauserverclient.datetime_helpers import format_datetime
+from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
GET_XML = os.path.join(TEST_ASSET_DIR, "user_get.xml")
+GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "user_get_all_fields.xml")
GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "user_get_empty.xml")
GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "user_get_by_id.xml")
UPDATE_XML = os.path.join(TEST_ASSET_DIR, "user_update.xml")
@@ -233,3 +234,37 @@ def test_get_users_from_file(self):
users, failures = self.server.users.create_from_file(USERS)
assert users[0].name == "Cassie", users
assert failures == []
+
+ def test_get_users_all_fields(self) -> None:
+ self.server.version = "3.7"
+ baseurl = self.server.users.baseurl
+ with open(GET_XML_ALL_FIELDS) as f:
+ response_xml = f.read()
+
+ with requests_mock.mock() as m:
+ m.get(f"{baseurl}?fields=_all_", text=response_xml)
+ all_users, _ = self.server.users.get()
+
+ assert all_users[0].auth_setting == "TableauIDWithMFA"
+ assert all_users[0].email == "bob@example.com"
+ assert all_users[0].external_auth_user_id == "38c870c3ac5e84ec66e6ced9fb23681835b07e56d5660371ac1f705cc65bd610"
+ assert all_users[0].fullname == "Bob Smith"
+ assert all_users[0].id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert all_users[0].last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert all_users[0].name == "bob@example.com"
+ assert all_users[0].site_role == "SiteAdministratorCreator"
+ assert all_users[0].locale is None
+ assert all_users[0].language == "en"
+ assert all_users[0].idp_configuration_id == "22222222-2222-2222-2222-222222222222"
+ assert all_users[0].domain_name == "TABID_WITH_MFA"
+ assert all_users[1].auth_setting == "TableauIDWithMFA"
+ assert all_users[1].email == "alice@example.com"
+ assert all_users[1].external_auth_user_id == "96f66b893b22669cdfa632275d354cd1d92cea0266f3be7702151b9b8c52be29"
+ assert all_users[1].fullname == "Alice Jones"
+ assert all_users[1].id == "f6d72445-285b-48e5-8380-f90b519ce682"
+ assert all_users[1].name == "alice@example.com"
+ assert all_users[1].site_role == "ExplorerCanPublish"
+ assert all_users[1].locale is None
+ assert all_users[1].language == "en"
+ assert all_users[1].idp_configuration_id == "22222222-2222-2222-2222-222222222222"
+ assert all_users[1].domain_name == "TABID_WITH_MFA"
From f4ac213d9222044de206f4c4c96cf38af0e5450d Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Wed, 5 Feb 2025 08:38:14 -0600
Subject: [PATCH 05/15] feat: workbook support all fields
---
tableauserverclient/models/workbook_item.py | 93 +++++++++++++++++
test/assets/workbook_get_all_fields.xml | 46 +++++++++
test/test_workbook.py | 106 +++++++++++++++++++-
3 files changed, 244 insertions(+), 1 deletion(-)
create mode 100644 test/assets/workbook_get_all_fields.xml
diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py
index 80efb3deb..52f290f1e 100644
--- a/tableauserverclient/models/workbook_item.py
+++ b/tableauserverclient/models/workbook_item.py
@@ -7,6 +7,9 @@
from defusedxml.ElementTree import fromstring
from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.models.location_item import LocationItem
+from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.user_item import UserItem
from .connection_item import ConnectionItem
from .exceptions import UnpopulatedPropertyError
from .permissions_item import PermissionsRule
@@ -141,6 +144,13 @@ def __init__(
self.thumbnails_group_id = thumbnails_group_id
self._sheet_count: Optional[int] = None
self._has_extracts: Optional[bool] = None
+ self._project: Optional[ProjectItem] = None
+ self._owner: Optional[UserItem] = None
+ self._location: Optional[LocationItem] = None
+ self._encrypt_extracts: Optional[bool] = None
+ self._default_view_id: Optional[str] = None
+ self._share_description: Optional[str] = None
+ self._last_published_at: Optional[datetime.datetime] = None
return None
@@ -310,6 +320,34 @@ def thumbnails_group_id(self) -> Optional[str]:
def thumbnails_group_id(self, value: str):
self._thumbnails_group_id = value
+ @property
+ def project(self) -> Optional[ProjectItem]:
+ return self._project
+
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
+
+ @property
+ def location(self) -> Optional[LocationItem]:
+ return self._location
+
+ @property
+ def encrypt_extracts(self) -> Optional[bool]:
+ return self._encrypt_extracts
+
+ @property
+ def default_view_id(self) -> Optional[str]:
+ return self._default_view_id
+
+ @property
+ def share_description(self) -> Optional[str]:
+ return self._share_description
+
+ @property
+ def last_published_at(self) -> Optional[datetime.datetime]:
+ return self._last_published_at
+
def _set_connections(self, connections):
self._connections = connections
@@ -354,6 +392,13 @@ def _parse_common_tags(self, workbook_xml, ns):
data_freshness_policy,
sheet_count,
has_extracts,
+ project,
+ owner,
+ location,
+ encrypt_extracts,
+ default_view_id,
+ share_description,
+ last_published_at,
) = self._parse_element(workbook_xml, ns)
self._set_values(
@@ -375,6 +420,13 @@ def _parse_common_tags(self, workbook_xml, ns):
data_freshness_policy,
sheet_count,
has_extracts,
+ project,
+ owner,
+ location,
+ encrypt_extracts,
+ default_view_id,
+ share_description,
+ last_published_at,
)
return self
@@ -399,6 +451,13 @@ def _set_values(
data_freshness_policy,
sheet_count,
has_extracts,
+ project,
+ owner,
+ location,
+ encrypt_extracts,
+ default_view_id,
+ share_description,
+ last_published_at,
):
if id is not None:
self._id = id
@@ -437,6 +496,20 @@ def _set_values(
self._sheet_count = sheet_count
if has_extracts is not None:
self._has_extracts = has_extracts
+ if project:
+ self._project = project
+ if owner:
+ self._owner = owner
+ if location:
+ self._location = location
+ if encrypt_extracts is not None:
+ self._encrypt_extracts = encrypt_extracts
+ if default_view_id is not None:
+ self._default_view_id = default_view_id
+ if share_description is not None:
+ self._share_description = share_description
+ if last_published_at is not None:
+ self._last_published_at = last_published_at
@classmethod
def from_response(cls, resp: str, ns: dict[str, str]) -> list["WorkbookItem"]:
@@ -465,6 +538,10 @@ def _parse_element(workbook_xml, ns):
updated_at = parse_datetime(workbook_xml.get("updatedAt", None))
sheet_count = string_to_int(workbook_xml.get("sheetCount", None))
has_extracts = string_to_bool(workbook_xml.get("hasExtracts", ""))
+ encrypt_extracts = string_to_bool(e) if (e := workbook_xml.get("encryptExtracts", None)) is not None else None
+ default_view_id = workbook_xml.get("defaultViewId", None)
+ share_description = workbook_xml.get("shareDescription", None)
+ last_published_at = parse_datetime(workbook_xml.get("lastPublishedAt", None))
size = workbook_xml.get("size", None)
if size:
@@ -474,14 +551,18 @@ def _parse_element(workbook_xml, ns):
project_id = None
project_name = None
+ project = None
project_tag = workbook_xml.find(".//t:project", namespaces=ns)
if project_tag is not None:
+ project = ProjectItem.from_xml(project_tag, ns)
project_id = project_tag.get("id", None)
project_name = project_tag.get("name", None)
owner_id = None
+ owner = None
owner_tag = workbook_xml.find(".//t:owner", namespaces=ns)
if owner_tag is not None:
+ owner = UserItem.from_xml(owner_tag, ns)
owner_id = owner_tag.get("id", None)
tags = None
@@ -495,6 +576,11 @@ def _parse_element(workbook_xml, ns):
if views_elem is not None:
views = ViewItem.from_xml_element(views_elem, ns)
+ location = None
+ location_elem = workbook_xml.find(".//t:location", namespaces=ns)
+ if location_elem is not None:
+ location = LocationItem.from_xml(location_elem, ns)
+
data_acceleration_config = {
"acceleration_enabled": None,
"accelerate_now": None,
@@ -529,6 +615,13 @@ def _parse_element(workbook_xml, ns):
data_freshness_policy,
sheet_count,
has_extracts,
+ project,
+ owner,
+ location,
+ encrypt_extracts,
+ default_view_id,
+ share_description,
+ last_published_at,
)
diff --git a/test/assets/workbook_get_all_fields.xml b/test/assets/workbook_get_all_fields.xml
new file mode 100644
index 000000000..007b79338
--- /dev/null
+++ b/test/assets/workbook_get_all_fields.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/test_workbook.py b/test/test_workbook.py
index f3c2dd147..84afd7fcb 100644
--- a/test/test_workbook.py
+++ b/test/test_workbook.py
@@ -10,7 +10,7 @@
import pytest
import tableauserverclient as TSC
-from tableauserverclient.datetime_helpers import format_datetime
+from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
from tableauserverclient.models import UserItem, GroupItem, PermissionsRule
from tableauserverclient.server.endpoint.exceptions import InternalServerError, UnsupportedAttributeError
from tableauserverclient.server.request_factory import RequestFactory
@@ -24,6 +24,7 @@
GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml")
GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml")
GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml")
+GET_XML_ALL_FIELDS = os.path.join(TEST_ASSET_DIR, "workbook_get_all_fields.xml")
ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml")
POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml")
POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf")
@@ -978,3 +979,106 @@ def test_odata_connection(self) -> None:
assert xml_connection is not None
self.assertEqual(xml_connection.get("serverAddress"), url)
+
+ def test_get_workbook_all_fields(self) -> None:
+ self.server.version = "3.21"
+ baseurl = self.server.workbooks.baseurl
+
+ with open(GET_XML_ALL_FIELDS) as f:
+ response = f.read()
+
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+
+ with requests_mock.mock() as m:
+ m.get(f"{baseurl}?fields=_all_", text=response)
+ workbooks, _ = self.server.workbooks.get(req_options=ro)
+
+ assert workbooks[0].id == "9df3e2d1-070e-497a-9578-8cc557ced9df"
+ assert workbooks[0].name == "Superstore"
+ assert workbooks[0].content_url == "Superstore"
+ assert workbooks[0].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265605"
+ assert workbooks[0].show_tabs
+ assert workbooks[0].size == 2
+ assert workbooks[0].created_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert workbooks[0].updated_at == parse_datetime("2024-02-14T04:42:10Z")
+ assert workbooks[0].sheet_count == 9
+ assert not workbooks[0].has_extracts
+ assert not workbooks[0].encrypt_extracts
+ assert workbooks[0].default_view_id == "2bdcd787-dcc6-4a5d-bc61-2846f1ef4534"
+ assert workbooks[0].share_description == "Superstore"
+ assert workbooks[0].last_published_at == parse_datetime("2024-02-14T04:42:09Z")
+ assert isinstance(workbooks[0].project, TSC.ProjectItem)
+ assert workbooks[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert workbooks[0].project.name == "Samples"
+ assert workbooks[0].project.description == "This project includes automatically uploaded samples."
+ assert isinstance(workbooks[0].location, TSC.LocationItem)
+ assert workbooks[0].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert workbooks[0].location.type == "Project"
+ assert workbooks[0].location.name == "Samples"
+ assert isinstance(workbooks[0].owner, TSC.UserItem)
+ assert workbooks[0].owner.email == "bob@example.com"
+ assert workbooks[0].owner.fullname == "Bob Smith"
+ assert workbooks[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert workbooks[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert workbooks[0].owner.name == "bob@example.com"
+ assert workbooks[0].owner.site_role == "SiteAdministratorCreator"
+ assert workbooks[1].id == "6693cb26-9507-4174-ad3e-9de81a18c971"
+ assert workbooks[1].name == "World Indicators"
+ assert workbooks[1].content_url == "WorldIndicators"
+ assert workbooks[1].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265606"
+ assert workbooks[1].show_tabs
+ assert workbooks[1].size == 1
+ assert workbooks[1].created_at == parse_datetime("2024-02-14T04:42:11Z")
+ assert workbooks[1].updated_at == parse_datetime("2024-02-14T04:42:12Z")
+ assert workbooks[1].sheet_count == 8
+ assert not workbooks[1].has_extracts
+ assert not workbooks[1].encrypt_extracts
+ assert workbooks[1].default_view_id == "3d10dbcf-a206-47c7-91ba-ebab3ab33d7c"
+ assert workbooks[1].share_description == "World Indicators"
+ assert workbooks[1].last_published_at == parse_datetime("2024-02-14T04:42:11Z")
+ assert isinstance(workbooks[1].project, TSC.ProjectItem)
+ assert workbooks[1].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert workbooks[1].project.name == "Samples"
+ assert workbooks[1].project.description == "This project includes automatically uploaded samples."
+ assert isinstance(workbooks[1].location, TSC.LocationItem)
+ assert workbooks[1].location.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert workbooks[1].location.type == "Project"
+ assert workbooks[1].location.name == "Samples"
+ assert isinstance(workbooks[1].owner, TSC.UserItem)
+ assert workbooks[1].owner.email == "bob@example.com"
+ assert workbooks[1].owner.fullname == "Bob Smith"
+ assert workbooks[1].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert workbooks[1].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert workbooks[1].owner.name == "bob@example.com"
+ assert workbooks[1].owner.site_role == "SiteAdministratorCreator"
+ assert workbooks[2].id == "dbc0f162-909f-4edf-8392-0d12a80af955"
+ assert workbooks[2].name == "Superstore"
+ assert workbooks[2].description == "This is a superstore workbook"
+ assert workbooks[2].content_url == "Superstore_17078880698360"
+ assert workbooks[2].webpage_url == "https://10ax.online.tableau.com/#/site/exampledev/workbooks/265621"
+ assert not workbooks[2].show_tabs
+ assert workbooks[2].size == 1
+ assert workbooks[2].created_at == parse_datetime("2024-02-14T05:21:09Z")
+ assert workbooks[2].updated_at == parse_datetime("2024-07-02T02:19:59Z")
+ assert workbooks[2].sheet_count == 7
+ assert workbooks[2].has_extracts
+ assert not workbooks[2].encrypt_extracts
+ assert workbooks[2].default_view_id == "8c4b1d3e-3f31-4d2a-8b9f-492b92f27987"
+ assert workbooks[2].share_description == "Superstore"
+ assert workbooks[2].last_published_at == parse_datetime("2024-07-02T02:19:58Z")
+ assert isinstance(workbooks[2].project, TSC.ProjectItem)
+ assert workbooks[2].project.id == "9836791c-9468-40f0-b7f3-d10b9562a046"
+ assert workbooks[2].project.name == "default"
+ assert workbooks[2].project.description == "The default project that was automatically created by Tableau."
+ assert isinstance(workbooks[2].location, TSC.LocationItem)
+ assert workbooks[2].location.id == "9836791c-9468-40f0-b7f3-d10b9562a046"
+ assert workbooks[2].location.type == "Project"
+ assert workbooks[2].location.name == "default"
+ assert isinstance(workbooks[2].owner, TSC.UserItem)
+ assert workbooks[2].owner.email == "bob@example.com"
+ assert workbooks[2].owner.fullname == "Bob Smith"
+ assert workbooks[2].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert workbooks[2].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert workbooks[2].owner.name == "bob@example.com"
+ assert workbooks[2].owner.site_role == "SiteAdministratorCreator"
From ea473dc834ce7cd60c11e9187857a5f0cd3a850e Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Wed, 5 Feb 2025 22:22:26 -0600
Subject: [PATCH 06/15] feat: datasourceitem _all_ fields
---
tableauserverclient/models/datasource_item.py | 112 +++++++++++++++++-
test/assets/datasource_get_all_fields.xml | 10 ++
test/test_datasource.py | 39 +++++-
3 files changed, 159 insertions(+), 2 deletions(-)
create mode 100644 test/assets/datasource_get_all_fields.xml
diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py
index 2005edf7e..c9688ab35 100644
--- a/tableauserverclient/models/datasource_item.py
+++ b/tableauserverclient/models/datasource_item.py
@@ -1,7 +1,7 @@
import copy
import datetime
import xml.etree.ElementTree as ET
-from typing import Optional
+from typing import Optional, overload
from defusedxml.ElementTree import fromstring
@@ -9,6 +9,7 @@
from tableauserverclient.models.connection_item import ConnectionItem
from tableauserverclient.models.exceptions import UnpopulatedPropertyError
from tableauserverclient.models.permissions_item import PermissionsRule
+from tableauserverclient.models.project_item import ProjectItem
from tableauserverclient.models.property_decorators import (
property_not_nullable,
property_is_boolean,
@@ -16,6 +17,7 @@
)
from tableauserverclient.models.revision_item import RevisionItem
from tableauserverclient.models.tag_item import TagItem
+from tableauserverclient.models.user_item import UserItem
class DatasourceItem:
@@ -143,6 +145,13 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None)
self.owner_id: Optional[str] = None
self.project_id: Optional[str] = project_id
self.tags: set[str] = set()
+ self._connected_workbooks_count: Optional[int] = None
+ self._favorites_total: Optional[int] = None
+ self._has_alert: Optional[bool] = None
+ self._is_published: Optional[bool] = None
+ self._server_name: Optional[str] = None
+ self._project: Optional[ProjectItem] = None
+ self._owner: Optional[UserItem] = None
self._permissions = None
self._data_quality_warnings = None
@@ -274,6 +283,34 @@ def revisions(self) -> list[RevisionItem]:
def size(self) -> Optional[int]:
return self._size
+ @property
+ def connected_workbooks_count(self) -> Optional[int]:
+ return self._connected_workbooks_count
+
+ @property
+ def favorites_total(self) -> Optional[int]:
+ return self._favorites_total
+
+ @property
+ def has_alert(self) -> Optional[bool]:
+ return self._has_alert
+
+ @property
+ def is_published(self) -> Optional[bool]:
+ return self._is_published
+
+ @property
+ def server_name(self) -> Optional[str]:
+ return self._server_name
+
+ @property
+ def project(self) -> Optional[ProjectItem]:
+ return self._project
+
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
+
def _set_connections(self, connections) -> None:
self._connections = connections
@@ -310,6 +347,13 @@ def _parse_common_elements(self, datasource_xml, ns):
use_remote_query_agent,
webpage_url,
size,
+ connected_workbooks_count,
+ favorites_total,
+ has_alert,
+ is_published,
+ server_name,
+ project,
+ owner,
) = self._parse_element(datasource_xml, ns)
self._set_values(
ask_data_enablement,
@@ -331,6 +375,13 @@ def _parse_common_elements(self, datasource_xml, ns):
use_remote_query_agent,
webpage_url,
size,
+ connected_workbooks_count,
+ favorites_total,
+ has_alert,
+ is_published,
+ server_name,
+ project,
+ owner,
)
return self
@@ -355,6 +406,13 @@ def _set_values(
use_remote_query_agent,
webpage_url,
size,
+ connected_workbooks_count,
+ favorites_total,
+ has_alert,
+ is_published,
+ server_name,
+ project,
+ owner,
):
if ask_data_enablement is not None:
self._ask_data_enablement = ask_data_enablement
@@ -394,6 +452,20 @@ def _set_values(
self._webpage_url = webpage_url
if size is not None:
self._size = int(size)
+ if connected_workbooks_count is not None:
+ self._connected_workbooks_count = connected_workbooks_count
+ if favorites_total is not None:
+ self._favorites_total = favorites_total
+ if has_alert is not None:
+ self._has_alert = has_alert
+ if is_published is not None:
+ self._is_published = is_published
+ if server_name is not None:
+ self._server_name = server_name
+ if project is not None:
+ self._project = project
+ if owner is not None:
+ self._owner = owner
@classmethod
def from_response(cls, resp: str, ns: dict) -> list["DatasourceItem"]:
@@ -428,6 +500,11 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple:
use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None)
webpage_url = datasource_xml.get("webpageUrl", None)
size = datasource_xml.get("size", None)
+ connected_workbooks_count = nullable_str_to_int(datasource_xml.get("connectedWorkbooksCount", None))
+ favorites_total = nullable_str_to_int(datasource_xml.get("favoritesTotal", None))
+ has_alert = nullable_str_to_bool(datasource_xml.get("hasAlert", None))
+ is_published = nullable_str_to_bool(datasource_xml.get("isPublished", None))
+ server_name = datasource_xml.get("serverName", None)
tags = None
tags_elem = datasource_xml.find(".//t:tags", namespaces=ns)
@@ -438,12 +515,14 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple:
project_name = None
project_elem = datasource_xml.find(".//t:project", namespaces=ns)
if project_elem is not None:
+ project = ProjectItem.from_xml(project_elem, ns)
project_id = project_elem.get("id", None)
project_name = project_elem.get("name", None)
owner_id = None
owner_elem = datasource_xml.find(".//t:owner", namespaces=ns)
if owner_elem is not None:
+ owner = UserItem.from_xml(owner_elem, ns)
owner_id = owner_elem.get("id", None)
ask_data_enablement = None
@@ -471,4 +550,35 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple:
use_remote_query_agent,
webpage_url,
size,
+ connected_workbooks_count,
+ favorites_total,
+ has_alert,
+ is_published,
+ server_name,
+ project,
+ owner,
)
+
+
+@overload
+def nullable_str_to_int(value: None) -> None: ...
+
+
+@overload
+def nullable_str_to_int(value: str) -> int: ...
+
+
+def nullable_str_to_int(value):
+ return int(value) if value is not None else None
+
+
+@overload
+def nullable_str_to_bool(value: None) -> None: ...
+
+
+@overload
+def nullable_str_to_bool(value: str) -> bool: ...
+
+
+def nullable_str_to_bool(value):
+ return str(value).lower() == "true" if value is not None else None
diff --git a/test/assets/datasource_get_all_fields.xml b/test/assets/datasource_get_all_fields.xml
new file mode 100644
index 000000000..46c4396d3
--- /dev/null
+++ b/test/assets/datasource_get_all_fields.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/test/test_datasource.py b/test/test_datasource.py
index b7e7e2721..a604ba8b0 100644
--- a/test/test_datasource.py
+++ b/test/test_datasource.py
@@ -10,7 +10,7 @@
import tableauserverclient as TSC
from tableauserverclient import ConnectionItem
-from tableauserverclient.datetime_helpers import format_datetime
+from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
from tableauserverclient.server.endpoint.exceptions import InternalServerError
from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads
from tableauserverclient.server.request_factory import RequestFactory
@@ -20,6 +20,7 @@
GET_XML = "datasource_get.xml"
GET_EMPTY_XML = "datasource_get_empty.xml"
GET_BY_ID_XML = "datasource_get_by_id.xml"
+GET_XML_ALL_FIELDS = "datasource_get_all_fields.xml"
POPULATE_CONNECTIONS_XML = "datasource_populate_connections.xml"
POPULATE_PERMISSIONS_XML = "datasource_populate_permissions.xml"
PUBLISH_XML = "datasource_publish.xml"
@@ -733,3 +734,39 @@ def test_bad_download_response(self) -> None:
)
file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td)
self.assertTrue(os.path.exists(file_path))
+
+ def test_get_datasource_all_fields(self) -> None:
+ ro = TSC.RequestOptions()
+ ro.all_fields = True
+ with requests_mock.mock() as m:
+ m.get(f"{self.baseurl}?fields=_all_", text=read_xml_asset(GET_XML_ALL_FIELDS))
+ datasources, _ = self.server.datasources.get(req_options=ro)
+
+ assert datasources[0].connected_workbooks_count == 0
+ assert datasources[0].content_url == "SuperstoreDatasource"
+ assert datasources[0].created_at == parse_datetime("2024-02-14T04:42:13Z")
+ assert not datasources[0].encrypt_extracts
+ assert datasources[0].favorites_total == 0
+ assert not datasources[0].has_alert
+ assert not datasources[0].has_extracts
+ assert datasources[0].id == "a71cdd15-3a23-4ec1-b3ce-9956f5e00bb7"
+ assert not datasources[0].certified
+ assert datasources[0].is_published
+ assert datasources[0].name == "Superstore Datasource"
+ assert datasources[0].size == 1
+ assert datasources[0].datasource_type == "excel-direct"
+ assert datasources[0].updated_at == parse_datetime("2024-02-14T04:42:14Z")
+ assert not datasources[0].use_remote_query_agent
+ assert datasources[0].server_name == "localhost"
+ assert datasources[0].webpage_url == "https://10ax.online.tableau.com/#/site/example/datasources/3566752"
+ assert isinstance(datasources[0].project, TSC.ProjectItem)
+ assert datasources[0].project.id == "669ca36b-492e-4ccf-bca1-3614fe6a9d7a"
+ assert datasources[0].project.name == "Samples"
+ assert datasources[0].project.description == "This project includes automatically uploaded samples."
+ assert datasources[0].owner.email == "bob@example.com"
+ assert isinstance(datasources[0].owner, TSC.UserItem)
+ assert datasources[0].owner.fullname == "Bob Smith"
+ assert datasources[0].owner.id == "ee8bc9ca-77fe-4ae0-8093-cf77f0ee67a9"
+ assert datasources[0].owner.last_login == parse_datetime("2025-02-04T06:39:20Z")
+ assert datasources[0].owner.name == "bob@example.com"
+ assert datasources[0].owner.site_role == "SiteAdministratorCreator"
From cc4a649f4bb243a8a3e0091f8f69394fed22c131 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Wed, 5 Feb 2025 22:49:03 -0600
Subject: [PATCH 07/15] feat: add fields methods to QuerySet
---
.../server/endpoint/endpoint.py | 39 +++++++++++++++++++
tableauserverclient/server/query.py | 36 +++++++++++++++++
test/test_request_option.py | 10 +++++
3 files changed, 85 insertions(+)
diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index 9e1160705..21462af5f 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -14,6 +14,7 @@
TypeVar,
Union,
)
+from typing_extensions import Self
from tableauserverclient.models.pagination_item import PaginationItem
from tableauserverclient.server.request_options import RequestOptions
@@ -353,3 +354,41 @@ def paginate(self, **kwargs) -> QuerySet[T]:
@abc.abstractmethod
def get(self, request_options: Optional[RequestOptions] = None) -> tuple[list[T], PaginationItem]:
raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}")
+
+ def fields(self: Self, *fields: str) -> QuerySet:
+ """
+ Add fields to the request options. If no fields are provided, the
+ default fields will be used. If fields are provided, the default fields
+ will be used in addition to the provided fields.
+
+ Parameters
+ ----------
+ fields : str
+ The fields to include in the request options.
+
+ Returns
+ -------
+ QuerySet
+ """
+ queryset = QuerySet(self)
+ queryset.request_options.fields |= set(fields) | set(("_default_",))
+ return queryset
+
+ def only_fields(self: Self, *fields: str) -> QuerySet:
+ """
+ Add fields to the request options. If no fields are provided, the
+ default fields will be used. If fields are provided, the default fields
+ will be replaced by the provided fields.
+
+ Parameters
+ ----------
+ fields : str
+ The fields to include in the request options.
+
+ Returns
+ -------
+ QuerySet
+ """
+ queryset = QuerySet(self)
+ queryset.request_options.fields |= set(fields)
+ return queryset
diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py
index 801ad4a13..5137cee52 100644
--- a/tableauserverclient/server/query.py
+++ b/tableauserverclient/server/query.py
@@ -208,6 +208,42 @@ def paginate(self: Self, **kwargs) -> Self:
self.request_options.pagesize = kwargs["page_size"]
return self
+ def fields(self: Self, *fields: str) -> Self:
+ """
+ Add fields to the request options. If no fields are provided, the
+ default fields will be used. If fields are provided, the default fields
+ will be used in addition to the provided fields.
+
+ Parameters
+ ----------
+ fields : str
+ The fields to include in the request options.
+
+ Returns
+ -------
+ QuerySet
+ """
+ self.request_options.fields |= set(fields) | set(("_default_"))
+ return self
+
+ def only_fields(self: Self, *fields: str) -> Self:
+ """
+ Add fields to the request options. If no fields are provided, the
+ default fields will be used. If fields are provided, the default fields
+ will be replaced by the provided fields.
+
+ Parameters
+ ----------
+ fields : str
+ The fields to include in the request options.
+
+ Returns
+ -------
+ QuerySet
+ """
+ self.request_options.fields |= set(fields)
+ return self
+
@staticmethod
def _parse_shorthand_filter(key: str) -> tuple[str, str]:
tokens = key.split("__", 1)
diff --git a/test/test_request_option.py b/test/test_request_option.py
index 751269ea1..57dfdc2a0 100644
--- a/test/test_request_option.py
+++ b/test/test_request_option.py
@@ -368,3 +368,13 @@ def test_language_export(self) -> None:
resp = self.server.users.get_request(url, request_object=opts)
self.assertTrue(re.search("language=en-us", resp.request.query))
+
+ def test_queryset_fields(self) -> None:
+ loop = self.server.users.fields("id")
+ assert "id" in loop.request_options.fields
+ assert "_default_" in loop.request_options.fields
+
+ def test_queryset_only_fields(self) -> None:
+ loop = self.server.users.only_fields("id")
+ assert "id" in loop.request_options.fields
+ assert "_default_" not in loop.request_options.fields
From a79446e549d7e963152769f36217c19e812ee5de Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Thu, 6 Feb 2025 22:21:02 -0600
Subject: [PATCH 08/15] docs: Docstrings for new fields
---
tableauserverclient/models/datasource_item.py | 21 +++++++++
tableauserverclient/models/group_item.py | 5 +++
tableauserverclient/models/location_item.py | 15 +++++++
tableauserverclient/models/project_item.py | 17 ++++++++
tableauserverclient/models/user_item.py | 43 +++++++++++++++++++
tableauserverclient/models/view_item.py | 14 +++++-
tableauserverclient/models/workbook_item.py | 21 +++++++++
7 files changed, 135 insertions(+), 1 deletion(-)
diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py
index c9688ab35..174adbf88 100644
--- a/tableauserverclient/models/datasource_item.py
+++ b/tableauserverclient/models/datasource_item.py
@@ -42,6 +42,9 @@ class DatasourceItem:
specified, it will default to SiteDefault. See REST API Publish
Datasource for more information about ask_data_enablement.
+ connected_workbooks_count : Optional[int]
+ The number of workbooks connected to the datasource.
+
connections : list[ConnectionItem]
The list of data connections (ConnectionItem) for the specified data
source. You must first call the populate_connections method to access
@@ -69,6 +72,12 @@ class DatasourceItem:
A Boolean value to determine if a datasource should be encrypted or not.
See Extract and Encryption Methods for more information.
+ favorites_total : Optional[int]
+ The number of users who have marked the data source as a favorite.
+
+ has_alert : Optional[bool]
+ A Boolean value that indicates whether the data source has an alert.
+
has_extracts : Optional[bool]
A Boolean value that indicates whether the datasource has extracts.
@@ -77,13 +86,22 @@ class DatasourceItem:
specific data source or to delete a data source with the get_by_id and
delete methods.
+ is_published : Optional[bool]
+ A Boolean value that indicates whether the data source is published.
+
name : Optional[str]
The name of the data source. If not specified, the name of the published
data source file is used.
+ owner: Optional[UserItem]
+ The owner of the data source.
+
owner_id : Optional[str]
The identifier of the owner of the data source.
+ project : Optional[ProjectItem]
+ The project that the data source belongs to.
+
project_id : Optional[str]
The identifier of the project associated with the data source. You must
provide this identifier when you create an instance of a DatasourceItem.
@@ -91,6 +109,9 @@ class DatasourceItem:
project_name : Optional[str]
The name of the project associated with the data source.
+ server_name : Optional[str]
+ The name of the server where the data source is published.
+
tags : Optional[set[str]]
The tags (list of strings) that have been added to the data source.
diff --git a/tableauserverclient/models/group_item.py b/tableauserverclient/models/group_item.py
index 6b0c3506c..00f35e518 100644
--- a/tableauserverclient/models/group_item.py
+++ b/tableauserverclient/models/group_item.py
@@ -44,6 +44,11 @@ class GroupItem:
login to a site. When the mode is onSync, a license is granted for group
members each time the domain is synced.
+ Attributes
+ ----------
+ user_count: Optional[int]
+ The number of users in the group.
+
Examples
--------
>>> # Create a new group item
diff --git a/tableauserverclient/models/location_item.py b/tableauserverclient/models/location_item.py
index 08c2ac996..fa7c2ff2c 100644
--- a/tableauserverclient/models/location_item.py
+++ b/tableauserverclient/models/location_item.py
@@ -3,6 +3,21 @@
class LocationItem:
+ """
+ Details of where an item is located, such as a personal space or project.
+
+ Attributes
+ ----------
+ id : str | None
+ The ID of the location.
+
+ type : str | None
+ The type of location, such as PersonalSpace or Project.
+
+ name : str | None
+ The name of the location.
+ """
+
class Type:
PersonalSpace = "PersonalSpace"
Project = "Project"
diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py
index a950b4d36..85b9f8496 100644
--- a/tableauserverclient/models/project_item.py
+++ b/tableauserverclient/models/project_item.py
@@ -38,12 +38,29 @@ class corresponds to the project resources you can access using the Tableau
Attributes
----------
+ datasource_count : int
+ The number of data sources in the project.
+
id : str
The unique identifier for the project.
owner_id : str
The unique identifier for the UserItem owner of the project.
+ project_count : int
+ The number of projects in the project.
+
+ top_level_project : bool
+ True if the project is a top-level project.
+
+ view_count : int
+ The number of views in the project.
+
+ workbok_count : int
+ The number of workbooks in the project.
+
+ writeable : bool
+ True if the project is writeable.
"""
ERROR_MSG = "Project item must be populated with permissions first."
diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py
index f7571e1c5..4d0186c4c 100644
--- a/tableauserverclient/models/user_item.py
+++ b/tableauserverclient/models/user_item.py
@@ -37,6 +37,49 @@ class UserItem:
auth_setting: str
Required attribute for Tableau Cloud. How the user autenticates to the
server.
+
+ Attributes
+ ----------
+ domain_name: Optional[str]
+ The name of the Active Directory domain ("local" if local authentication
+ is used).
+
+ email: Optional[str]
+ The email address of the user.
+
+ external_auth_user_id: Optional[str]
+ The unique identifier for the user in the external authentication system.
+
+ id: Optional[str]
+ The unique identifier for the user.
+
+ favorites: dict[str, list]
+ The favorites of the user. Must be populated with a call to
+ `populate_favorites()`.
+
+ fullname: Optional[str]
+ The full name of the user.
+
+ groups: Pager
+ The groups the user belongs to. Must be populated with a call to
+ `populate_groups()`.
+
+ last_login: Optional[datetime]
+ The last time the user logged in.
+
+ locale: Optional[str]
+ The locale of the user.
+
+ language: Optional[str]
+ Language setting for the user.
+
+ idp_configuration_id: Optional[str]
+ The ID of the identity provider configuration.
+
+ workbooks: Pager
+ The workbooks owned by the user. Must be populated with a call to
+ `populate_workbooks()`.
+
"""
tag_name: str = "user"
diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py
index b19155f19..dc8eda9c8 100644
--- a/tableauserverclient/models/view_item.py
+++ b/tableauserverclient/models/view_item.py
@@ -40,9 +40,16 @@ class ViewItem:
The image of the view. You must first call the `views.populate_image`
method to access the image.
+ location: Optional[LocationItem], default None
+ The location of the view. The location can be a personal space or a
+ project.
+
name: Optional[str], default None
The name of the view.
+ owner: Optional[UserItem], default None
+ The owner of the view.
+
owner_id: Optional[str], default None
The ID for the owner of the view.
@@ -54,6 +61,9 @@ class ViewItem:
The preview image of the view. You must first call the
`views.populate_preview_image` method to access the preview image.
+ project: Optional[ProjectItem], default None
+ The project that contains the view.
+
project_id: Optional[str], default None
The ID for the project that contains the view.
@@ -66,9 +76,11 @@ class ViewItem:
updated_at: Optional[datetime], default None
The date and time when the view was last updated.
+ workbook: Optional[WorkbookItem], default None
+ The workbook that contains the view.
+
workbook_id: Optional[str], default None
The ID for the workbook that contains the view.
-
"""
def __init__(self) -> None:
diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py
index 52f290f1e..a3ede65d6 100644
--- a/tableauserverclient/models/workbook_item.py
+++ b/tableauserverclient/models/workbook_item.py
@@ -54,13 +54,31 @@ class as arguments. The workbook item specifies the project.
created_at : Optional[datetime.datetime]
The date and time the workbook was created.
+ default_view_id : Optional[str]
+ The identifier for the default view of the workbook.
+
description : Optional[str]
User-defined description of the workbook.
+ encrypt_extracts : Optional[bool]
+ Indicates whether extracts are encrypted.
+
+ has_extracts : Optional[bool]
+ Indicates whether the workbook has extracts.
+
id : Optional[str]
The identifier for the workbook. You need this value to query a specific
workbook or to delete a workbook with the get_by_id and delete methods.
+ last_published_at : Optional[datetime.datetime]
+ The date and time the workbook was last published.
+
+ location : Optional[LocationItem]
+ The location of the workbook, such as a personal space or project.
+
+ owner : Optional[UserItem]
+ The owner of the workbook.
+
owner_id : Optional[str]
The identifier for the owner (UserItem) of the workbook.
@@ -68,6 +86,9 @@ class as arguments. The workbook item specifies the project.
The thumbnail image for the view. You must first call the
workbooks.populate_preview_image method to access this data.
+ project: Optional[ProjectItem]
+ The project that contains the workbook.
+
project_name : Optional[str]
The name of the project that contains the workbook.
From 6fac6b0a2ed2d8296d89c8430cbc38b194b8ce04 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Thu, 6 Feb 2025 22:33:59 -0600
Subject: [PATCH 09/15] feat: add owner attribute to project
---
tableauserverclient/models/project_item.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py
index 85b9f8496..b00ce7567 100644
--- a/tableauserverclient/models/project_item.py
+++ b/tableauserverclient/models/project_item.py
@@ -5,6 +5,7 @@
from tableauserverclient.models.exceptions import UnpopulatedPropertyError
from tableauserverclient.models.property_decorators import property_is_enum
+from tableauserverclient.models.user_item import UserItem
class ProjectItem:
@@ -44,6 +45,9 @@ class corresponds to the project resources you can access using the Tableau
id : str
The unique identifier for the project.
+ owner: Optional[UserItem]
+ The UserItem owner of the project.
+
owner_id : str
The unique identifier for the UserItem owner of the project.
@@ -110,6 +114,8 @@ def __init__(
self._view_count: Optional[int] = None
self._datasource_count: Optional[int] = None
+ self._owner: Optional[UserItem] = None
+
@property
def content_permissions(self):
return self._content_permissions
@@ -223,6 +229,10 @@ def view_count(self) -> Optional[int]:
def datasource_count(self) -> Optional[int]:
return self._datasource_count
+ @property
+ def owner(self) -> Optional[UserItem]:
+ return self._owner
+
def is_default(self):
return self.name.lower() == "default"
@@ -240,6 +250,7 @@ def _set_values(
workbok_count,
view_count,
datasource_count,
+ owner,
):
if project_id is not None:
self._id = project_id
@@ -265,6 +276,8 @@ def _set_values(
self._top_level_project = top_level_project
if writeable is not None:
self._writeable = writeable
+ if owner is not None:
+ self._owner = owner
def _set_permissions(self, permissions):
self._permissions = permissions
@@ -305,6 +318,7 @@ def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple:
writeable = str_to_bool(project_xml.get("writeable", None))
owner_id = None
if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None:
+ owner = UserItem.from_xml(owner_elem, namespace)
owner_id = owner_elem.get("id", None)
project_count = None
@@ -330,6 +344,7 @@ def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple:
workbok_count,
view_count,
datasource_count,
+ owner,
)
From e70b5331ad1d03b88a87a0f67880c8331542458b Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Thu, 6 Feb 2025 22:46:30 -0600
Subject: [PATCH 10/15] fix: unassigned local variable
---
tableauserverclient/models/project_item.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py
index b00ce7567..19e9ba491 100644
--- a/tableauserverclient/models/project_item.py
+++ b/tableauserverclient/models/project_item.py
@@ -317,6 +317,7 @@ def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple:
top_level_project = str_to_bool(project_xml.get("topLevelProject", None))
writeable = str_to_bool(project_xml.get("writeable", None))
owner_id = None
+ owner = None
if (owner_elem := project_xml.find(".//t:owner", namespaces=namespace)) is not None:
owner = UserItem.from_xml(owner_elem, namespace)
owner_id = owner_elem.get("id", None)
From b93095bc6e83d61ae9f1f81a46491534d44ebf19 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Fri, 21 Mar 2025 17:07:56 -0500
Subject: [PATCH 11/15] fix: typo
---
tableauserverclient/models/project_item.py | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py
index 19e9ba491..1ab369ba7 100644
--- a/tableauserverclient/models/project_item.py
+++ b/tableauserverclient/models/project_item.py
@@ -60,7 +60,7 @@ class corresponds to the project resources you can access using the Tableau
view_count : int
The number of views in the project.
- workbok_count : int
+ workbook_count : int
The number of workbooks in the project.
writeable : bool
@@ -110,7 +110,7 @@ def __init__(
self._default_table_permissions = None
self._project_count: Optional[int] = None
- self._workbok_count: Optional[int] = None
+ self._workbook_count: Optional[int] = None
self._view_count: Optional[int] = None
self._datasource_count: Optional[int] = None
@@ -218,8 +218,8 @@ def project_count(self) -> Optional[int]:
return self._project_count
@property
- def workbok_count(self) -> Optional[int]:
- return self._workbok_count
+ def workbook_count(self) -> Optional[int]:
+ return self._workbook_count
@property
def view_count(self) -> Optional[int]:
@@ -247,7 +247,7 @@ def _set_values(
top_level_project,
writeable,
project_count,
- workbok_count,
+ workbook_count,
view_count,
datasource_count,
owner,
@@ -266,8 +266,8 @@ def _set_values(
self._owner_id = owner_id
if project_count is not None:
self._project_count = project_count
- if workbok_count is not None:
- self._workbok_count = workbok_count
+ if workbook_count is not None:
+ self._workbook_count = workbook_count
if view_count is not None:
self._view_count = view_count
if datasource_count is not None:
@@ -323,12 +323,12 @@ def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple:
owner_id = owner_elem.get("id", None)
project_count = None
- workbok_count = None
+ workbook_count = None
view_count = None
datasource_count = None
if (count_elem := project_xml.find(".//t:contentsCounts", namespaces=namespace)) is not None:
project_count = int(count_elem.get("projectCount", 0))
- workbok_count = int(count_elem.get("workbookCount", 0))
+ workbook_count = int(count_elem.get("workbookCount", 0))
view_count = int(count_elem.get("viewCount", 0))
datasource_count = int(count_elem.get("dataSourceCount", 0))
@@ -342,7 +342,7 @@ def _parse_element(project_xml: ET.Element, namespace: Optional[dict]) -> tuple:
top_level_project,
writeable,
project_count,
- workbok_count,
+ workbook_count,
view_count,
datasource_count,
owner,
From 1efd3628085be32f3c1ccc2ba6c46663f124401e Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Fri, 21 Mar 2025 17:15:31 -0500
Subject: [PATCH 12/15] fix: restore _all_fields but deprecated
---
tableauserverclient/server/request_options.py | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py
index bcae7818a..4aaa0909d 100644
--- a/tableauserverclient/server/request_options.py
+++ b/tableauserverclient/server/request_options.py
@@ -1,5 +1,6 @@
import sys
from typing import Optional
+import warnings
from typing_extensions import Self
@@ -66,6 +67,19 @@ def __init__(self, pagenumber=1, pagesize=None):
# This is private until we expand all of our parsers to handle the extra fields
self.all_fields = False
+ @property
+ def _all_fields(self) -> bool:
+ return self.all_fields
+
+ @_all_fields.setter
+ def _all_fields(self, value):
+ warnings.warn(
+ "Directly setting _all_fields is deprecated, please use the all_fields property instead.",
+ DeprecationWarning,
+ )
+ self.all_fields = value
+
+
def get_query_params(self) -> dict:
params = {}
if self.sort and len(self.sort) > 0:
From 81b0d3be93415e3fcdbde1304c3897742e5032dd Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Fri, 21 Mar 2025 17:15:46 -0500
Subject: [PATCH 13/15] chore: refactor helper functions
---
tableauserverclient/helpers/strings.py | 26 ++++++++++++++++++-
tableauserverclient/models/datasource_item.py | 26 ++-----------------
2 files changed, 27 insertions(+), 25 deletions(-)
diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py
index 75534103b..6ba4e48d9 100644
--- a/tableauserverclient/helpers/strings.py
+++ b/tableauserverclient/helpers/strings.py
@@ -1,6 +1,6 @@
from defusedxml.ElementTree import fromstring, tostring
from functools import singledispatch
-from typing import TypeVar
+from typing import TypeVar, overload
# the redact method can handle either strings or bytes, but it can't mix them.
@@ -41,3 +41,27 @@ def _(xml: str) -> str:
@redact_xml.register # type: ignore[no-redef]
def _(xml: bytes) -> bytes:
return _redact_any_type(bytearray(xml), b"password", b"..[redacted]")
+
+
+@overload
+def nullable_str_to_int(value: None) -> None: ...
+
+
+@overload
+def nullable_str_to_int(value: str) -> int: ...
+
+
+def nullable_str_to_int(value):
+ return int(value) if value is not None else None
+
+
+@overload
+def nullable_str_to_bool(value: None) -> None: ...
+
+
+@overload
+def nullable_str_to_bool(value: str) -> bool: ...
+
+
+def nullable_str_to_bool(value):
+ return str(value).lower() == "true" if value is not None else None
diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py
index 174adbf88..4d4395802 100644
--- a/tableauserverclient/models/datasource_item.py
+++ b/tableauserverclient/models/datasource_item.py
@@ -1,11 +1,12 @@
import copy
import datetime
import xml.etree.ElementTree as ET
-from typing import Optional, overload
+from typing import Optional
from defusedxml.ElementTree import fromstring
from tableauserverclient.datetime_helpers import parse_datetime
+from tableauserverclient.helpers.strings import nullable_str_to_bool, nullable_str_to_int
from tableauserverclient.models.connection_item import ConnectionItem
from tableauserverclient.models.exceptions import UnpopulatedPropertyError
from tableauserverclient.models.permissions_item import PermissionsRule
@@ -580,26 +581,3 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple:
owner,
)
-
-@overload
-def nullable_str_to_int(value: None) -> None: ...
-
-
-@overload
-def nullable_str_to_int(value: str) -> int: ...
-
-
-def nullable_str_to_int(value):
- return int(value) if value is not None else None
-
-
-@overload
-def nullable_str_to_bool(value: None) -> None: ...
-
-
-@overload
-def nullable_str_to_bool(value: str) -> bool: ...
-
-
-def nullable_str_to_bool(value):
- return str(value).lower() == "true" if value is not None else None
From 5b61424782f0272035b9621c1bb46216654f4fe4 Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Fri, 21 Mar 2025 17:18:36 -0500
Subject: [PATCH 14/15] style: black
---
tableauserverclient/models/datasource_item.py | 1 -
tableauserverclient/server/request_options.py | 1 -
2 files changed, 2 deletions(-)
diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py
index 4d4395802..de976f359 100644
--- a/tableauserverclient/models/datasource_item.py
+++ b/tableauserverclient/models/datasource_item.py
@@ -580,4 +580,3 @@ def _parse_element(datasource_xml: ET.Element, ns: dict) -> tuple:
project,
owner,
)
-
diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py
index 4aaa0909d..4a104255f 100644
--- a/tableauserverclient/server/request_options.py
+++ b/tableauserverclient/server/request_options.py
@@ -79,7 +79,6 @@ def _all_fields(self, value):
)
self.all_fields = value
-
def get_query_params(self) -> dict:
params = {}
if self.sort and len(self.sort) > 0:
From 3d1ccfad1561222ff28b6966e7a4282c3b52f3be Mon Sep 17 00:00:00 2001
From: Jordan Woods <13803242+jorwoods@users.noreply.github.com>
Date: Fri, 21 Mar 2025 17:21:27 -0500
Subject: [PATCH 15/15] fix: rebase broken test
---
test/test_view.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/test/test_view.py b/test/test_view.py
index 5ac69e98b..ee6d518de 100644
--- a/test/test_view.py
+++ b/test/test_view.py
@@ -5,7 +5,7 @@
import tableauserverclient as TSC
from tableauserverclient import UserItem, GroupItem, PermissionsRule
-from tableauserverclient.datetime_helpers import format_datetime
+from tableauserverclient.datetime_helpers import format_datetime, parse_datetime
from tableauserverclient.server.endpoint.exceptions import UnsupportedAttributeError
TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")