diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py
index 819cfcfa6..a0771935c 100644
--- a/tableauserverclient/__init__.py
+++ b/tableauserverclient/__init__.py
@@ -39,6 +39,7 @@
PermissionsRule,
PersonalAccessTokenAuth,
ProjectItem,
+ RecycleBinItem,
Resource,
RevisionItem,
SafeExtension,
@@ -137,6 +138,7 @@
"PermissionsRule",
"PersonalAccessTokenAuth",
"ProjectItem",
+ "RecycleBinItem",
"RequestOptions",
"Resource",
"RevisionItem",
diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py
index 5eb79f8e8..3e91fa8c7 100644
--- a/tableauserverclient/models/__init__.py
+++ b/tableauserverclient/models/__init__.py
@@ -41,6 +41,7 @@
from tableauserverclient.models.pagination_item import PaginationItem
from tableauserverclient.models.permissions_item import PermissionsRule, Permission
from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.recycle_bin_item import RecycleBinItem
from tableauserverclient.models.revision_item import RevisionItem
from tableauserverclient.models.schedule_item import ScheduleItem
from tableauserverclient.models.server_info_item import ServerInfoItem
@@ -96,6 +97,7 @@
"Permission",
"PermissionsRule",
"ProjectItem",
+ "RecycleBinItem",
"RevisionItem",
"ScheduleItem",
"ServerInfoItem",
diff --git a/tableauserverclient/models/recycle_bin_item.py b/tableauserverclient/models/recycle_bin_item.py
new file mode 100644
index 000000000..e60dd886a
--- /dev/null
+++ b/tableauserverclient/models/recycle_bin_item.py
@@ -0,0 +1,99 @@
+import datetime
+import xml.etree.ElementTree as ET
+
+from defusedxml.ElementTree import fromstring
+
+from tableauserverclient.datetime_helpers import parse_datetime
+
+
+class RecycleBinItem:
+ """
+ An item of content (workbook, data source, flow or project) that has been
+ deleted and is held in the site's recycle bin.
+
+ Parameters
+ ----------
+ id_ : str | None
+ The identifier of the deleted content.
+
+ name : str | None
+ The name of the deleted content.
+
+ content_type : str | None
+ The type of the deleted content, as reported by the server: one of
+ "workbook", "datasource", "flow" or "project".
+
+ created_at : datetime.datetime | None
+ When the content was originally created.
+
+ updated_at : datetime.datetime | None
+ When the content was last updated before it was deleted.
+
+ original_path : str | None
+ The project path the content was in before it was deleted.
+
+ is_read_only : bool | None
+ Whether the content is read only.
+
+ owner_id : str | None
+ The identifier of the user who owned the content.
+
+ deleter_id : str | None
+ The identifier of the user who deleted the content.
+ """
+
+ tag_name: str = "contentItem"
+
+ def __init__(
+ self,
+ id_: str | None = None,
+ name: str | None = None,
+ content_type: str | None = None,
+ created_at: datetime.datetime | None = None,
+ updated_at: datetime.datetime | None = None,
+ original_path: str | None = None,
+ is_read_only: bool | None = None,
+ owner_id: str | None = None,
+ deleter_id: str | None = None,
+ ) -> None:
+ self._id = id_
+ self.name = name
+ self.content_type = content_type
+ self.created_at = created_at
+ self.updated_at = updated_at
+ self.original_path = original_path
+ self.is_read_only = is_read_only
+ self.owner_id = owner_id
+ self.deleter_id = deleter_id
+
+ def __repr__(self) -> str:
+ return (
+ f"<{self.__class__.__qualname__} id={self._id} name={self.name} "
+ f"content_type={self.content_type} original_path={self.original_path}>"
+ )
+
+ @property
+ def id(self) -> str | None:
+ return self._id
+
+ @classmethod
+ def from_response(cls, resp: bytes, ns: dict[str, str]) -> list["RecycleBinItem"]:
+ parsed_response = fromstring(resp)
+ return [cls.from_xml(xml, ns) for xml in parsed_response.findall(".//t:contentItem", namespaces=ns)]
+
+ @classmethod
+ def from_xml(cls, xml: ET.Element, ns: dict[str, str]) -> "RecycleBinItem":
+ is_read_only = xml.get("isReadOnly")
+ owner = xml.find("t:owner", namespaces=ns)
+ deleter = xml.find("t:deleter", namespaces=ns)
+ return cls(
+ id_=xml.get("id"),
+ name=xml.get("name"),
+ content_type=xml.get("contentType"),
+ created_at=parse_datetime(xml.get("createdAt")),
+ updated_at=parse_datetime(xml.get("updatedAt")),
+ original_path=xml.get("originalPath"),
+ is_read_only=None if is_read_only is None else is_read_only.lower() == "true",
+ owner_id=owner.get("id") if owner is not None else None,
+ deleter_id=deleter.get("id") if deleter is not None else None,
+ )
diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py
index 44020419b..154015f50 100644
--- a/tableauserverclient/server/__init__.py
+++ b/tableauserverclient/server/__init__.py
@@ -41,6 +41,7 @@
Metadata,
Metrics,
Projects,
+ RecycleBin,
Schedules,
ServerInfo,
ServerResponseError,
@@ -91,6 +92,7 @@
"Metadata",
"Metrics",
"Projects",
+ "RecycleBin",
"Schedules",
"ServerInfo",
"ServerResponseError",
diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py
index de2f7e8a1..e7bd5c0fc 100644
--- a/tableauserverclient/server/endpoint/__init__.py
+++ b/tableauserverclient/server/endpoint/__init__.py
@@ -21,6 +21,7 @@
from tableauserverclient.server.endpoint.metrics_endpoint import Metrics
from tableauserverclient.server.endpoint.oidc_endpoint import OIDC
from tableauserverclient.server.endpoint.projects_endpoint import Projects
+from tableauserverclient.server.endpoint.recycle_bin_endpoint import RecycleBin
from tableauserverclient.server.endpoint.schedules_endpoint import Schedules
from tableauserverclient.server.endpoint.server_info_endpoint import ServerInfo
from tableauserverclient.server.endpoint.sites_endpoint import Sites
@@ -60,6 +61,7 @@
"Metrics",
"OIDC",
"Projects",
+ "RecycleBin",
"Schedules",
"ServerInfo",
"ServerResponseError",
diff --git a/tableauserverclient/server/endpoint/recycle_bin_endpoint.py b/tableauserverclient/server/endpoint/recycle_bin_endpoint.py
new file mode 100644
index 000000000..5fa8b6eb2
--- /dev/null
+++ b/tableauserverclient/server/endpoint/recycle_bin_endpoint.py
@@ -0,0 +1,200 @@
+from typing import Literal, TypeAlias, TYPE_CHECKING, overload
+
+from tableauserverclient.helpers.logging import logger
+from tableauserverclient.models.job_item import JobItem
+from tableauserverclient.models.pagination_item import PaginationItem
+from tableauserverclient.models.project_item import ProjectItem
+from tableauserverclient.models.recycle_bin_item import RecycleBinItem
+from tableauserverclient.server.endpoint.endpoint import Endpoint, api
+from tableauserverclient.server.request_options import RequestOptions
+
+if TYPE_CHECKING:
+ from tableauserverclient.server.server import Server
+
+RecycleBinContentType: TypeAlias = Literal["workbooks", "datasources", "flows", "projects"]
+
+_URL_SEGMENTS = {"workbook", "datasource", "flow", "project"}
+
+
+class RecycleBin(Endpoint):
+ """
+ The recycle bin holds workbooks, data sources, flows and projects that have
+ been deleted from a site, until they are restored or permanently deleted.
+
+ Content types are given as one of "workbooks", "datasources", "flows" or
+ "projects". The singular form reported by the server on a RecycleBinItem
+ (for example "workbook") is accepted as well.
+ """
+
+ def __init__(self, parent_srv: "Server") -> None:
+ super().__init__(parent_srv)
+
+ @property
+ def baseurl(self) -> str:
+ return f"{self.parent_srv.baseurl}/sites/{self.parent_srv.site_id}/recyclebin"
+
+ @staticmethod
+ def _content_type_segment(content_type: str) -> str:
+ singular = content_type[:-1] if content_type.endswith("s") else content_type
+ if singular not in _URL_SEGMENTS:
+ raise ValueError(
+ f"Unknown recycle bin content type '{content_type}'. "
+ "Expected one of: workbooks, datasources, flows, projects."
+ )
+ return f"{singular}s"
+
+ def _item_url(self, content_type: str | None, item: "RecycleBinItem | str") -> str:
+ if isinstance(item, RecycleBinItem):
+ if not item.id:
+ raise ValueError("The recycle bin item must have an id.")
+ content_type = content_type or item.content_type
+ item_id = item.id
+ else:
+ item_id = item
+ if not content_type:
+ raise ValueError("content_type is required when the item is given as an id.")
+ return f"{self.baseurl}/{self._content_type_segment(content_type)}/{item_id}"
+
+ @api(version="3.29")
+ def get(
+ self, content_type: RecycleBinContentType, req_options: RequestOptions | None = None
+ ) -> tuple[list[RecycleBinItem], PaginationItem]:
+ """
+ List the deleted content of one type in the recycle bin. Supports
+ filtering on name, ownerId, deleterId, createdAt, updatedAt and path,
+ and sorting and paging through RequestOptions.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#list_recycle_bin_content_by_type
+
+ Parameters
+ ----------
+ content_type : "workbooks" | "datasources" | "flows" | "projects"
+ The type of content to list.
+
+ req_options : RequestOptions, optional
+ Filter, sort and paging options.
+
+ Returns
+ -------
+ tuple[list[RecycleBinItem], PaginationItem]
+ """
+ logger.info(f"Querying the recycle bin for {content_type}")
+ url = f"{self.baseurl}/{self._content_type_segment(content_type)}"
+ server_response = self.get_request(url, req_options)
+ pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
+ items = RecycleBinItem.from_response(server_response.content, self.parent_srv.namespace)
+ return items, pagination_item
+
+ @api(version="3.29")
+ def get_by_id(self, content_type: RecycleBinContentType, content_id: str) -> RecycleBinItem:
+ """
+ Get a single deleted item from the recycle bin.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#get_recycle_bin_content_item
+
+ Parameters
+ ----------
+ content_type : "workbooks" | "datasources" | "flows" | "projects"
+ The type of the content.
+
+ content_id : str
+ The identifier of the deleted content.
+
+ Returns
+ -------
+ RecycleBinItem
+ """
+ if not content_id:
+ raise ValueError("content_id is required.")
+ logger.info(f"Querying the recycle bin for {content_type} (ID: {content_id})")
+ url = self._item_url(content_type, content_id)
+ server_response = self.get_request(url)
+ return RecycleBinItem.from_response(server_response.content, self.parent_srv.namespace)[0]
+
+ @api(version="3.29")
+ def restore(
+ self,
+ item: RecycleBinItem | str,
+ project: ProjectItem | str,
+ content_type: RecycleBinContentType | None = None,
+ ) -> None:
+ """
+ Restore a deleted item from the recycle bin into a project. The caller
+ needs Publish permission on the destination project.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#restore_recycle_bin_content_item
+
+ Parameters
+ ----------
+ item : RecycleBinItem | str
+ The item to restore, or its id. When an id is given, content_type
+ is required.
+
+ project : ProjectItem | str
+ The project, or project id, to restore the content into.
+
+ content_type : "workbooks" | "datasources" | "flows" | "projects", optional
+ The type of the content. Defaults to the type of a RecycleBinItem.
+ """
+ project_id = project.id if isinstance(project, ProjectItem) else project
+ if not project_id:
+ raise ValueError("A destination project id is required to restore content.")
+ url = f"{self._item_url(content_type, item)}/restore"
+ logger.info(f"Restoring recycle bin content to project (ID: {project_id}): {url}")
+ self.post_request(url, "", parameters={"params": {"projectId": project_id}})
+
+ @api(version="3.29")
+ def delete(self, item: RecycleBinItem | str, content_type: RecycleBinContentType | None = None) -> None:
+ """
+ Permanently delete one item from the recycle bin. This cannot be undone.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#delete_recycle_bin_content_item
+
+ Parameters
+ ----------
+ item : RecycleBinItem | str
+ The item to delete, or its id. When an id is given, content_type
+ is required.
+
+ content_type : "workbooks" | "datasources" | "flows" | "projects", optional
+ The type of the content. Defaults to the type of a RecycleBinItem.
+ """
+ url = self._item_url(content_type, item)
+ logger.info(f"Permanently deleting recycle bin content: {url}")
+ self.delete_request(url)
+
+ @overload
+ def empty(self, as_job: Literal[False] = False) -> None: ...
+
+ @overload
+ def empty(self, as_job: Literal[True]) -> JobItem: ...
+
+ @api(version="3.29")
+ def empty(self, as_job: bool = False) -> JobItem | None:
+ """
+ Permanently delete everything in the recycle bin. This cannot be undone.
+
+ REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_recycle_bin.htm#delete_all_recycle_bin_contents
+
+ Parameters
+ ----------
+ as_job : bool, default False
+ Run the deletion as an asynchronous EmptyRecycleBin job and return
+ the JobItem, which can be passed to `server.jobs.wait_for_job`.
+
+ Returns
+ -------
+ JobItem | None
+ The job when as_job is True, otherwise None.
+ """
+ logger.info("Emptying the recycle bin")
+ if not as_job:
+ self.delete_request(self.baseurl)
+ return None
+ server_response = self._make_request(
+ self.parent_srv.session.delete,
+ self.baseurl,
+ auth_token=self.parent_srv.auth_token,
+ parameters={"params": {"asJob": "true"}},
+ )
+ return JobItem.from_response(server_response.content, self.parent_srv.namespace)[0]
diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py
index 14fb18c61..5ec711c5e 100644
--- a/tableauserverclient/server/request_options.py
+++ b/tableauserverclient/server/request_options.py
@@ -133,6 +133,7 @@ class Field:
CreatedAt = "createdAt"
DatabaseName = "databaseName"
DatabaseUserName = "databaseUserName"
+ DeleterId = "deleterId"
Description = "description"
DisplayTabs = "displayTabs"
DomainName = "domainName"
diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py
index 853bdea68..0a554c344 100644
--- a/tableauserverclient/server/server.py
+++ b/tableauserverclient/server/server.py
@@ -18,6 +18,7 @@
Workbooks,
Datasources,
Projects,
+ RecycleBin,
Auth,
AuthFederation,
Schedules,
@@ -235,6 +236,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None,
self.oidc = OIDC(self)
self.auth_federation = AuthFederation(self)
self.extensions = Extensions(self)
+ self.recycle_bin = RecycleBin(self)
self._http_options = dict() # must set this before making a server call
if http_options:
diff --git a/test/assets/recycle_bin_empty_job.xml b/test/assets/recycle_bin_empty_job.xml
new file mode 100644
index 000000000..17cf8304b
--- /dev/null
+++ b/test/assets/recycle_bin_empty_job.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/test/assets/recycle_bin_get.xml b/test/assets/recycle_bin_get.xml
new file mode 100644
index 000000000..e0fab5131
--- /dev/null
+++ b/test/assets/recycle_bin_get.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/assets/recycle_bin_get_by_id.xml b/test/assets/recycle_bin_get_by_id.xml
new file mode 100644
index 000000000..00bfc12af
--- /dev/null
+++ b/test/assets/recycle_bin_get_by_id.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/test/test_recycle_bin.py b/test/test_recycle_bin.py
new file mode 100644
index 000000000..959be13c1
--- /dev/null
+++ b/test/test_recycle_bin.py
@@ -0,0 +1,168 @@
+import datetime
+from pathlib import Path
+
+import pytest
+import requests_mock
+
+import tableauserverclient as TSC
+from tableauserverclient.server.exceptions import EndpointUnavailableError
+
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
+RECYCLE_BIN_GET = TEST_ASSET_DIR / "recycle_bin_get.xml"
+RECYCLE_BIN_GET_BY_ID = TEST_ASSET_DIR / "recycle_bin_get_by_id.xml"
+RECYCLE_BIN_EMPTY_JOB = TEST_ASSET_DIR / "recycle_bin_empty_job.xml"
+
+ITEM_ID = "a1b2c3d4-0000-4000-8000-000000000001"
+PROJECT_ID = "44444444-4444-4444-8444-444444444444"
+
+
+@pytest.fixture(scope="function")
+def server():
+ server = TSC.Server("http://test", False)
+
+ # Fake signin
+ server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
+ server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
+ server.version = "3.29"
+
+ return server
+
+
+def test_baseurl(server: TSC.Server) -> None:
+ assert server.recycle_bin.baseurl == f"{server.baseurl}/sites/{server.site_id}/recyclebin"
+
+
+def test_requires_api_3_29(server: TSC.Server) -> None:
+ server.version = "3.28"
+ with pytest.raises(EndpointUnavailableError):
+ server.recycle_bin.get("workbooks")
+
+
+def test_get(server: TSC.Server) -> None:
+ with requests_mock.mock() as m:
+ m.get(f"{server.recycle_bin.baseurl}/workbooks", text=RECYCLE_BIN_GET.read_text())
+ items, pagination = server.recycle_bin.get("workbooks")
+
+ assert pagination.total_available == 2
+ assert len(items) == 2
+ first = items[0]
+ assert first.id == ITEM_ID
+ assert first.name == "Sales Dashboard"
+ assert first.content_type == "workbook"
+ assert first.original_path == "Finance/Reports"
+ assert first.is_read_only is False
+ assert first.owner_id == "11111111-1111-4111-8111-111111111111"
+ assert first.deleter_id == "22222222-2222-4222-8222-222222222222"
+ assert first.created_at == datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc)
+ assert first.updated_at == datetime.datetime(2026, 2, 3, 4, 5, 6, tzinfo=datetime.timezone.utc)
+ assert items[1].is_read_only is True
+
+
+def test_get_with_request_options(server: TSC.Server) -> None:
+ options = TSC.RequestOptions(pagesize=5)
+ options.filter.add(TSC.Filter(TSC.RequestOptions.Field.DeleterId, TSC.RequestOptions.Operator.Equals, "22222222"))
+ with requests_mock.mock() as m:
+ m.get(f"{server.recycle_bin.baseurl}/flows", text=RECYCLE_BIN_GET.read_text())
+ server.recycle_bin.get("flows", options)
+
+ assert m.last_request.qs["pagesize"] == ["5"]
+ assert m.last_request.qs["filter"] == ["deleterid:eq:22222222"]
+
+
+@pytest.mark.parametrize("content_type", ["datasources", "datasource", "projects", "flows", "workbook"])
+def test_get_accepts_plural_and_singular_content_types(server: TSC.Server, content_type: str) -> None:
+ expected = content_type if content_type.endswith("s") else f"{content_type}s"
+ with requests_mock.mock() as m:
+ m.get(f"{server.recycle_bin.baseurl}/{expected}", text=RECYCLE_BIN_GET.read_text())
+ items, _ = server.recycle_bin.get(content_type) # type: ignore[arg-type]
+ assert len(items) == 2
+
+
+def test_get_rejects_unknown_content_type(server: TSC.Server) -> None:
+ with pytest.raises(ValueError):
+ server.recycle_bin.get("views") # type: ignore[arg-type]
+
+
+def test_get_by_id(server: TSC.Server) -> None:
+ with requests_mock.mock() as m:
+ m.get(f"{server.recycle_bin.baseurl}/workbooks/{ITEM_ID}", text=RECYCLE_BIN_GET_BY_ID.read_text())
+ item = server.recycle_bin.get_by_id("workbooks", ITEM_ID)
+
+ assert item.id == ITEM_ID
+ assert item.name == "Sales Dashboard"
+
+
+def test_get_by_id_requires_id(server: TSC.Server) -> None:
+ with pytest.raises(ValueError):
+ server.recycle_bin.get_by_id("workbooks", "")
+
+
+def test_restore_by_id(server: TSC.Server) -> None:
+ with requests_mock.mock() as m:
+ m.post(f"{server.recycle_bin.baseurl}/workbooks/{ITEM_ID}/restore", status_code=204)
+ server.recycle_bin.restore(ITEM_ID, PROJECT_ID, content_type="workbooks")
+
+ assert m.last_request.qs["projectid"] == [PROJECT_ID]
+
+
+def test_restore_item_uses_its_content_type_and_project_item(server: TSC.Server) -> None:
+ item = TSC.RecycleBinItem(id_=ITEM_ID, content_type="datasource")
+ project = TSC.ProjectItem("Restored")
+ project._id = PROJECT_ID
+ with requests_mock.mock() as m:
+ m.post(f"{server.recycle_bin.baseurl}/datasources/{ITEM_ID}/restore", status_code=204)
+ server.recycle_bin.restore(item, project)
+
+ assert m.last_request.qs["projectid"] == [PROJECT_ID]
+
+
+def test_restore_requires_content_type_for_id(server: TSC.Server) -> None:
+ with pytest.raises(ValueError):
+ server.recycle_bin.restore(ITEM_ID, PROJECT_ID)
+
+
+def test_restore_requires_project(server: TSC.Server) -> None:
+ with pytest.raises(ValueError):
+ server.recycle_bin.restore(ITEM_ID, TSC.ProjectItem("No id"), content_type="workbooks")
+
+
+def test_delete_by_id(server: TSC.Server) -> None:
+ with requests_mock.mock() as m:
+ m.delete(f"{server.recycle_bin.baseurl}/workbooks/{ITEM_ID}", status_code=204)
+ server.recycle_bin.delete(ITEM_ID, content_type="workbooks")
+
+ assert m.called
+
+
+def test_delete_item(server: TSC.Server) -> None:
+ item = TSC.RecycleBinItem(id_=ITEM_ID, content_type="flow")
+ with requests_mock.mock() as m:
+ m.delete(f"{server.recycle_bin.baseurl}/flows/{ITEM_ID}", status_code=204)
+ server.recycle_bin.delete(item)
+
+ assert m.called
+
+
+def test_delete_requires_content_type_for_id(server: TSC.Server) -> None:
+ with pytest.raises(ValueError):
+ server.recycle_bin.delete(ITEM_ID)
+
+
+def test_empty(server: TSC.Server) -> None:
+ with requests_mock.mock() as m:
+ m.delete(server.recycle_bin.baseurl, status_code=204)
+ result = server.recycle_bin.empty()
+
+ assert result is None
+ assert m.last_request.method == "DELETE"
+ assert "asjob" not in m.last_request.qs
+
+
+def test_empty_as_job(server: TSC.Server) -> None:
+ with requests_mock.mock() as m:
+ m.delete(server.recycle_bin.baseurl, status_code=202, text=RECYCLE_BIN_EMPTY_JOB.read_text())
+ job = server.recycle_bin.empty(as_job=True)
+
+ assert m.last_request.qs["asjob"] == ["true"]
+ assert job.id == "33333333-3333-4333-8333-333333333333"
+ assert job.type == "EmptyRecycleBin"