From 79c1cd5ea9761958f64b5203b1a55df964242694 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 21 Aug 2025 17:54:07 -0500 Subject: [PATCH 1/3] feat: support collections in favorites The API schema shows collections can be returned with favorites. This change adds support for a `CollectionItem`, as well as making the bundled type returned by favorites more specific. --- tableauserverclient/__init__.py | 3 +- tableauserverclient/models/__init__.py | 2 + tableauserverclient/models/collection_item.py | 51 +++++++++++++++++++ tableauserverclient/models/favorites_item.py | 29 ++++++++--- tableauserverclient/models/user_item.py | 5 +- test/assets/favorites_get.xml | 14 ++++- test/test_favorites.py | 12 +++++ 7 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 tableauserverclient/models/collection_item.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index c15e1a6eb..b041fcdae 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -2,6 +2,7 @@ from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE from tableauserverclient.models import ( BackgroundJobItem, + CollectionItem, ColumnItem, ConnectionCredentials, ConnectionItem, @@ -73,7 +74,7 @@ __all__ = [ "BackgroundJobItem", - "BackgroundJobItem", + "CollectionItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 5ad7ec1c4..67f6553fd 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,3 +1,4 @@ +from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.column_item import ColumnItem from tableauserverclient.models.connection_credentials import ConnectionCredentials from tableauserverclient.models.connection_item import ConnectionItem @@ -53,6 +54,7 @@ from tableauserverclient.models.extract_item import ExtractItem __all__ = [ + "CollectionItem", "ColumnItem", "ConnectionCredentials", "ConnectionItem", diff --git a/tableauserverclient/models/collection_item.py b/tableauserverclient/models/collection_item.py new file mode 100644 index 000000000..822b83376 --- /dev/null +++ b/tableauserverclient/models/collection_item.py @@ -0,0 +1,51 @@ +from datetime import datetime +from typing import Optional, Self +from xml.etree.ElementTree import Element + +from defusedxml.ElementTree import fromstring + +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.user_item import UserItem + + +class CollectionItem: + def __init__(self) -> None: + self.id: Optional[str] = None + self.name: Optional[str] = None + self.description: Optional[str] = None + self.created_at: Optional[datetime] = None + self.updated_at: Optional[datetime] = None + self.owner: Optional[UserItem] = None + self.total_item_count: Optional[int] = None + self.permissioned_item_count: Optional[int] = None + self.visibility: Optional[str] = None # Assuming visibility is a string, adjust as necessary + + @classmethod + def from_response(cls, response: bytes, ns) -> list[Self]: + parsed_response = fromstring(response) + + collection_elements = parsed_response.findall(".//t:collection", namespaces=ns) + if not collection_elements: + raise ValueError("No collection element found in the response") + + collections = [cls.from_xml(c, ns) for c in collection_elements] + return collections + + @classmethod + def from_xml(cls, xml: Element, ns) -> Self: + collection_item = cls() + collection_item.id = xml.get("id") + collection_item.name = xml.get("name") + collection_item.description = xml.get("description") + collection_item.created_at = parse_datetime(xml.get("createdAt")) + collection_item.updated_at = parse_datetime(xml.get("updatedAt")) + owner_element = xml.find(".//t:owner", namespaces=ns) + if owner_element is not None: + collection_item.owner = UserItem.from_xml(owner_element, ns) + else: + collection_item.owner = None + collection_item.total_item_count = int(xml.get("totalItemCount", 0)) + collection_item.permissioned_item_count = int(xml.get("permissionedItemCount", 0)) + collection_item.visibility = xml.get("visibility") + + return collection_item diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 4fea280f7..1189efc31 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,9 +1,8 @@ import logging -from typing import Union +from typing import TypedDict, Union from defusedxml.ElementTree import fromstring - -from tableauserverclient.models.tableau_types import TableauItem +from tableauserverclient.models.collection_item import CollectionItem from tableauserverclient.models.datasource_item import DatasourceItem from tableauserverclient.models.flow_item import FlowItem from tableauserverclient.models.project_item import ProjectItem @@ -13,16 +12,22 @@ from tableauserverclient.helpers.logging import logger -FavoriteType = dict[ - str, - list[TableauItem], -] + +class FavoriteType(TypedDict): + collections: list[CollectionItem] + datasources: list[DatasourceItem] + flows: list[FlowItem] + projects: list[ProjectItem] + metrics: list[MetricItem] + views: list[ViewItem] + workbooks: list[WorkbookItem] class FavoriteItem: @classmethod def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: favorites: FavoriteType = { + "collections": [], "datasources": [], "flows": [], "projects": [], @@ -32,6 +37,7 @@ def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: } parsed_response = fromstring(xml) + collections_xml = parsed_response.findall(".//t:favorite/t:collection", namespace) datasources_xml = parsed_response.findall(".//t:favorite/t:datasource", namespace) flows_xml = parsed_response.findall(".//t:favorite/t:flow", namespace) metrics_xml = parsed_response.findall(".//t:favorite/t:metric", namespace) @@ -40,13 +46,14 @@ def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: workbooks_xml = parsed_response.findall(".//t:favorite/t:workbook", namespace) logger.debug( - "ds: {}, flows: {}, metrics: {}, projects: {}, views: {}, wbs: {}".format( + "ds: {}, flows: {}, metrics: {}, projects: {}, views: {}, wbs: {}, collections: {}".format( len(datasources_xml), len(flows_xml), len(metrics_xml), len(projects_xml), len(views_xml), len(workbooks_xml), + len(collections_xml), ) ) for datasource in datasources_xml: @@ -85,5 +92,11 @@ def from_response(cls, xml: Union[str, bytes], namespace: dict) -> FavoriteType: logger.debug(fav_workbook) favorites["workbooks"].append(fav_workbook) + for collection in collections_xml: + fav_collection = CollectionItem.from_xml(collection, namespace) + if fav_collection: + logger.debug(fav_collection) + favorites["collections"].append(fav_collection) + logger.debug(favorites) return favorites diff --git a/tableauserverclient/models/user_item.py b/tableauserverclient/models/user_item.py index c995b4e07..8b2dd3dd6 100644 --- a/tableauserverclient/models/user_item.py +++ b/tableauserverclient/models/user_item.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from tableauserverclient.server import Pager + from tableauserverclient.models.favorites_item import FavoriteType class UserItem: @@ -131,7 +132,7 @@ def __init__( self._id: Optional[str] = None self._last_login: Optional[datetime] = None self._workbooks = None - self._favorites: Optional[dict[str, list]] = None + self._favorites: Optional["FavoriteType"] = None self._groups = None self.email: Optional[str] = None self.fullname: Optional[str] = None @@ -218,7 +219,7 @@ def workbooks(self) -> "Pager": return self._workbooks() @property - def favorites(self) -> dict[str, list]: + def favorites(self) -> "FavoriteType": if self._favorites is None: error = "User item must be populated with favorites first." raise UnpopulatedPropertyError(error) diff --git a/test/assets/favorites_get.xml b/test/assets/favorites_get.xml index 3d2e2ee6a..8fd780b1d 100644 --- a/test/assets/favorites_get.xml +++ b/test/assets/favorites_get.xml @@ -43,5 +43,17 @@ + + + + + - \ No newline at end of file + diff --git a/test/test_favorites.py b/test/test_favorites.py index 87332d70f..e51fe5178 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -1,3 +1,4 @@ +import datetime as dt import unittest import requests_mock @@ -48,6 +49,17 @@ def test_get(self) -> None: self.assertEqual(datasource.id, "e76a1461-3b1d-4588-bf1b-17551a879ad9") self.assertEqual(project.id, "1d0304cd-3796-429f-b815-7258370b9b74") + collection = self.user.favorites["collections"][0] + + assert collection.id == "8c57cb8a-d65f-4a32-813e-5a3f86e8f94e" + assert collection.name == "sample collection" + assert collection.description == "description for sample collection" + assert collection.total_item_count == 3 + assert collection.permissioned_item_count == 2 + assert collection.visibility == "Private" + assert collection.created_at == dt.datetime.fromisoformat("2016-08-11T21:22:40Z") + assert collection.updated_at == dt.datetime.fromisoformat("2016-08-11T21:34:17Z") + def test_add_favorite_workbook(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_WORKBOOK_XML) workbook = TSC.WorkbookItem("") From bf118dc25a7ce05cc0fbc20c6f06d40f54b57dd5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:26:53 -0500 Subject: [PATCH 2/3] fix: change Self import to make compat with < 3.11 --- tableauserverclient/models/collection_item.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/models/collection_item.py b/tableauserverclient/models/collection_item.py index 822b83376..4fdb61023 100644 --- a/tableauserverclient/models/collection_item.py +++ b/tableauserverclient/models/collection_item.py @@ -1,8 +1,9 @@ from datetime import datetime -from typing import Optional, Self +from typing import Optional from xml.etree.ElementTree import Element from defusedxml.ElementTree import fromstring +from typing_extensions import Self from tableauserverclient.datetime_helpers import parse_datetime from tableauserverclient.models.user_item import UserItem From 82aeaa2163019407a4edcb63ba76761f14914d8e Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 21 Aug 2025 20:52:09 -0500 Subject: [PATCH 3/3] fix: use parse_datetime --- test/test_favorites.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/test_favorites.py b/test/test_favorites.py index e51fe5178..e0b701953 100644 --- a/test/test_favorites.py +++ b/test/test_favorites.py @@ -1,9 +1,9 @@ -import datetime as dt import unittest import requests_mock import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime from ._utils import read_xml_asset GET_FAVORITES_XML = "favorites_get.xml" @@ -57,8 +57,8 @@ def test_get(self) -> None: assert collection.total_item_count == 3 assert collection.permissioned_item_count == 2 assert collection.visibility == "Private" - assert collection.created_at == dt.datetime.fromisoformat("2016-08-11T21:22:40Z") - assert collection.updated_at == dt.datetime.fromisoformat("2016-08-11T21:34:17Z") + assert collection.created_at == parse_datetime("2016-08-11T21:22:40Z") + assert collection.updated_at == parse_datetime("2016-08-11T21:34:17Z") def test_add_favorite_workbook(self) -> None: response_xml = read_xml_asset(ADD_FAVORITE_WORKBOOK_XML)