From 3fefcfabdfc66819a6c8e6c1c66ea09a66c00dbe Mon Sep 17 00:00:00 2001
From: gregg <gconklin@gmail.com>
Date: Thu, 19 Oct 2023 19:00:45 +0000
Subject: [PATCH 01/27] Fix for #1301 of duplicate default permission requests

1. logging to the root logger isn't correct
2. the log line calls fetch_call() which makes a server request
3. retuns the results of fetch_call() which is never used anywhere

Removing these lines from _set_default_permissions makes it more
functionally equivalent to the above _set_permissions
---
 tableauserverclient/models/project_item.py | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py
index e7254ab5d..4918f1a14 100644
--- a/tableauserverclient/models/project_item.py
+++ b/tableauserverclient/models/project_item.py
@@ -163,9 +163,6 @@ def _set_default_permissions(self, permissions, content_type):
             attr,
             permissions,
         )
-        fetch_call = getattr(self, attr)
-        logging.getLogger().info({"type": attr, "value": fetch_call()})
-        return fetch_call()
 
     @classmethod
     def from_response(cls, resp, ns) -> List["ProjectItem"]:

From 25a59d0f8f54fb872c068b2f14cf366dd3a18e76 Mon Sep 17 00:00:00 2001
From: Fumiya Suto <fumiya.suto@gmail.com>
Date: Mon, 13 Nov 2023 20:26:42 +0900
Subject: [PATCH 02/27] Fixed type annotation for workbook.refresh

`workbook.refresh` is implemented to accept both `WorkbookItem`
and `str` as arguments, but the type annotation describes it as
receiving `str`, which can cause false warnings in static analysis.

Since the documentation states that it receives `workbook_item`,
the name of the argument is also changed from `workbook_id` to
`workbook_item`.

Issue: https://github.com/tableau/server-client-python/issues/1318
---
 tableauserverclient/server/endpoint/workbooks_endpoint.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py
index a73b0f0d5..3c8efbe3b 100644
--- a/tableauserverclient/server/endpoint/workbooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -88,8 +88,8 @@ def get_by_id(self, workbook_id: str) -> WorkbookItem:
         return WorkbookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
 
     @api(version="2.8")
-    def refresh(self, workbook_id: str) -> JobItem:
-        id_ = getattr(workbook_id, "id", workbook_id)
+    def refresh(self, workbook_item: Union[WorkbookItem, str]) -> JobItem:
+        id_ = getattr(workbook_item, "id", workbook_item)
         url = "{0}/{1}/refresh".format(self.baseurl, id_)
         empty_req = RequestFactory.Empty.empty_req()
         server_response = self.post_request(url, empty_req)

From 5b73beb145b9378cd7ef3c7a2c46a8214605a399 Mon Sep 17 00:00:00 2001
From: Brian Cantoni <bcantoni@salesforce.com>
Date: Sat, 18 Nov 2023 11:01:45 -0800
Subject: [PATCH 03/27] Remove comment with fake password that was causing
 confusion

---
 tableauserverclient/helpers/strings.py | 2 --
 1 file changed, 2 deletions(-)

diff --git a/tableauserverclient/helpers/strings.py b/tableauserverclient/helpers/strings.py
index e51a6611a..75534103b 100644
--- a/tableauserverclient/helpers/strings.py
+++ b/tableauserverclient/helpers/strings.py
@@ -9,8 +9,6 @@
 T = TypeVar("T", str, bytes)
 
 
-# usage: _redact_any_type("<xml workbook password= cooliothesecond>")
-# -> b"<xml workbook password =***************">
 def _redact_any_type(xml: T, sensitive_word: T, replacement: T, encoding=None) -> T:
     try:
         root = fromstring(xml)

From 082cec0b6a063117eee61ef43b08ecdde7d11e43 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Wed, 25 Oct 2023 20:13:58 -0500
Subject: [PATCH 04/27] Add all missing fields

---
 tableauserverclient/server/request_options.py | 40 +++++++++++++++++++
 1 file changed, 40 insertions(+)

diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py
index 796f8add3..95233f8fc 100644
--- a/tableauserverclient/server/request_options.py
+++ b/tableauserverclient/server/request_options.py
@@ -37,35 +37,75 @@ class Operator:
 
     class Field:
         Args = "args"
+        AuthenticationType = "authenticationType"
+        Caption = "caption"
+        Channel = "channel"
         CompletedAt = "completedAt"
+        ConnectedWorkbookType = "connectedWorkbookType"
+        ConnectionTo = "connectionTo"
+        ConnectionType = "connectionType"
         ContentUrl = "contentUrl"
         CreatedAt = "createdAt"
+        DatabaseName = "databaseName"
+        DatabaseUserName = "databaseUserName"
+        Description = "description"
+        DisplayTabs = "displayTabs"
         DomainName = "domainName"
         DomainNickname = "domainNickname"
+        FavoritesTotal = "favoritesTotal"
+        Fields = "fields"
+        FlowId = "flowId"
+        FriendlyName = "friendlyName"
+        HasAlert = "hasAlert"
+        HasAlerts = "hasAlerts"
+        HasEmbeddedPassword = "hasEmbeddedPassword"
+        HasExtracts = "hasExtracts"
         HitsTotal = "hitsTotal"
+        Id = "id"
+        IsCertified = "isCertified"
+        IsConnectable = "isConnectable"
+        IsDefaultPort = "isDefaultPort"
+        IsHierarchical = "isHierarchical"
         IsLocal = "isLocal"
+        IsPublished = "isPublished"
         JobType = "jobType"
         LastLogin = "lastLogin"
+        Luid = "luid"
         MinimumSiteRole = "minimumSiteRole"
         Name = "name"
         Notes = "notes"
+        NotificationType = "notificationType"
         OwnerDomain = "ownerDomain"
         OwnerEmail = "ownerEmail"
         OwnerName = "ownerName"
         ParentProjectId = "parentProjectId"
+        Priority = "priority"
         Progress = "progress"
+        ProjectId = "projectId"
         ProjectName = "projectName"
         PublishSamples = "publishSamples"
+        ServerName = "serverName"
+        ServerPort = "serverPort"
+        SheetCount = "sheetCount"
+        SheetNumber = "sheetNumber"
+        SheetType = "sheetType"
         SiteRole = "siteRole"
+        Size = "size"
         StartedAt = "startedAt"
         Status = "status"
+        SubscriptionsTotal = "subscriptionsTotal"
         Subtitle = "subtitle"
+        TableName = "tableName"
         Tags = "tags"
         Title = "title"
         TopLevelProject = "topLevelProject"
         Type = "type"
         UpdatedAt = "updatedAt"
         UserCount = "userCount"
+        UserId = "userId"
+        ViewUrlName = "viewUrlName"
+        WorkbookDescription = "workbookDescription"
+        WorkbookName = "workbookName"
 
     class Direction:
         Desc = "desc"

From 613334bed02ea2ab79a06d663f402a9af252f81f Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 21:28:26 -0500
Subject: [PATCH 05/27] Make imports absolute

---
 tableauserverclient/models/task_item.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py
index 159869b07..2199861c7 100644
--- a/tableauserverclient/models/task_item.py
+++ b/tableauserverclient/models/task_item.py
@@ -1,8 +1,8 @@
 from defusedxml.ElementTree import fromstring
 
 from tableauserverclient.datetime_helpers import parse_datetime
-from .schedule_item import ScheduleItem
-from .target import Target
+from tableauserverclient.models.schedule_item import ScheduleItem
+from tableauserverclient.models.target import Target
 
 
 class TaskItem(object):

From 20824143c79258994286d9351a7501b05ad4d0e9 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 21:41:30 -0500
Subject: [PATCH 06/27] Add types to TaskItem

---
 tableauserverclient/models/task_item.py | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py
index 2199861c7..24d76fc19 100644
--- a/tableauserverclient/models/task_item.py
+++ b/tableauserverclient/models/task_item.py
@@ -1,3 +1,5 @@
+from typing import List
+
 from defusedxml.ElementTree import fromstring
 
 from tableauserverclient.datetime_helpers import parse_datetime
@@ -44,7 +46,7 @@ def __repr__(self):
         )
 
     @classmethod
-    def from_response(cls, xml, ns, task_type=Type.ExtractRefresh):
+    def from_response(cls, xml, ns, task_type=Type.ExtractRefresh) -> List["TaskItem"]:
         parsed_response = fromstring(xml)
         all_tasks_xml = parsed_response.findall(".//t:task/t:{}".format(task_type), namespaces=ns)
 
@@ -94,7 +96,7 @@ def _parse_element(cls, element, ns):
         )
 
     @staticmethod
-    def _translate_task_type(task_type):
+    def _translate_task_type(task_type: str) -> str:
         if task_type in TaskItem._TASK_TYPE_MAPPING:
             return TaskItem._TASK_TYPE_MAPPING[task_type]
         else:

From 0a720e92cd09ed485855064d0c1d04c24685875b Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 21:43:42 -0500
Subject: [PATCH 07/27] Make Tasks endpoint imports absolute

---
 tableauserverclient/server/endpoint/tasks_endpoint.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py
index 092597388..0d4b23027 100644
--- a/tableauserverclient/server/endpoint/tasks_endpoint.py
+++ b/tableauserverclient/server/endpoint/tasks_endpoint.py
@@ -1,7 +1,7 @@
 import logging
 
-from .endpoint import Endpoint, api
-from .exceptions import MissingRequiredFieldError
+from tableauserverclient.server.endpoint import Endpoint, api
+from tableauserverclient.server.exceptions import MissingRequiredFieldError
 from tableauserverclient.models import TaskItem, PaginationItem
 from tableauserverclient.server import RequestFactory
 

From cdbaf98f4803e48ff77c7fa7a5d72c8f9ee10623 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 22:14:01 -0500
Subject: [PATCH 08/27] Add task test asset

---
 test/assets/tasks_without_schedule.xml | 12 ++++++++++++
 1 file changed, 12 insertions(+)
 create mode 100644 test/assets/tasks_without_schedule.xml

diff --git a/test/assets/tasks_without_schedule.xml b/test/assets/tasks_without_schedule.xml
new file mode 100644
index 000000000..e669bf67f
--- /dev/null
+++ b/test/assets/tasks_without_schedule.xml
@@ -0,0 +1,12 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<tsResponse 
+    xmlns="http://tableau.com/api" 
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.6.xsd">
+    <tasks>
+        <task>
+            <extractRefresh id="f84901ac-72ad-4f9b-a87e-7a3500402ad6" priority="50" consecutiveFailedCount="0" type="RefreshExtractTask">
+                <datasource id="c7a9327e-1cda-4504-b026-ddb43b976d1d" />
+            </extractRefresh>
+        </task>
+    </tasks>
+</tsResponse>
\ No newline at end of file

From e65ca391fd929572ac5d91bd8de31fc7929460a1 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 22:15:01 -0500
Subject: [PATCH 09/27] More typing of TaskItem

---
 tableauserverclient/models/task_item.py | 21 +++++++++++----------
 1 file changed, 11 insertions(+), 10 deletions(-)

diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py
index 24d76fc19..96718f6d2 100644
--- a/tableauserverclient/models/task_item.py
+++ b/tableauserverclient/models/task_item.py
@@ -1,4 +1,5 @@
-from typing import List
+from datetime import datetime
+from typing import List, Optional
 
 from defusedxml.ElementTree import fromstring
 
@@ -21,14 +22,14 @@ class Type:
 
     def __init__(
         self,
-        id_,
-        task_type,
-        priority,
-        consecutive_failed_count=0,
-        schedule_id=None,
-        schedule_item=None,
-        last_run_at=None,
-        target=None,
+        id_: str,
+        task_type: str,
+        priority: int,
+        consecutive_failed_count: int = 0,
+        schedule_id: Optional[str] = None,
+        schedule_item: Optional[str] = None,
+        last_run_at: Optional[datetime]=None,
+        target: Optional[Target] = None,
     ):
         self.id = id_
         self.task_type = task_type
@@ -39,7 +40,7 @@ def __init__(
         self.last_run_at = last_run_at
         self.target = target
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return (
             "<Task#{id} {task_type} pri({priority}) failed({consecutive_failed_count}) schedule_id({"
             "schedule_id}) target({target})>".format(**self.__dict__)

From 600a0b7208392d177ce71072c12e2415b9b8aded Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 22:15:39 -0500
Subject: [PATCH 10/27] Permit missing tasks missing schedule

---
 tableauserverclient/models/task_item.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py
index 96718f6d2..eae5948e3 100644
--- a/tableauserverclient/models/task_item.py
+++ b/tableauserverclient/models/task_item.py
@@ -65,8 +65,7 @@ def _parse_element(cls, element, ns):
         last_run_at_element = element.find(".//t:lastRunAt", namespaces=ns)
 
         schedule_item_list = ScheduleItem.from_element(element, ns)
-        if len(schedule_item_list) >= 1:
-            schedule_item = schedule_item_list[0]
+        schedule_item = next(iter(schedule_item_list), None)
 
         # according to the Tableau Server REST API documentation,
         # there should be only one of workbook or datasource
@@ -90,7 +89,7 @@ def _parse_element(cls, element, ns):
             task_type,
             priority,
             consecutive_failed_count,
-            schedule_item.id,
+            schedule_item.id if schedule_item is not None else None,
             schedule_item,
             last_run_at,
             target,

From b44d69e484abe61b8dde66b402e1b4152f65ce8b Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 22:16:40 -0500
Subject: [PATCH 11/27] Fix import references

---
 tableauserverclient/server/endpoint/tasks_endpoint.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py
index 0d4b23027..92e0095c9 100644
--- a/tableauserverclient/server/endpoint/tasks_endpoint.py
+++ b/tableauserverclient/server/endpoint/tasks_endpoint.py
@@ -1,7 +1,7 @@
 import logging
 
-from tableauserverclient.server.endpoint import Endpoint, api
-from tableauserverclient.server.exceptions import MissingRequiredFieldError
+from tableauserverclient.server.endpoint.endpoint import Endpoint, api
+from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
 from tableauserverclient.models import TaskItem, PaginationItem
 from tableauserverclient.server import RequestFactory
 

From f4280318ec8d31bdd2cc3347ae632ceb827a5b30 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 22:17:15 -0500
Subject: [PATCH 12/27] Add test for missing schedule

---
 test/test_task.py | 13 ++++++++++++-
 1 file changed, 12 insertions(+), 1 deletion(-)

diff --git a/test/test_task.py b/test/test_task.py
index 4eb2c02e2..4e0157dfd 100644
--- a/test/test_task.py
+++ b/test/test_task.py
@@ -1,6 +1,7 @@
 import os
 import unittest
 from datetime import time
+from pathlib import Path
 
 import requests_mock
 
@@ -8,7 +9,7 @@
 from tableauserverclient.datetime_helpers import parse_datetime
 from tableauserverclient.models.task_item import TaskItem
 
-TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
+TEST_ASSET_DIR = Path(__file__).parent / "assets"
 
 GET_XML_NO_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_no_workbook_or_datasource.xml")
 GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml")
@@ -17,6 +18,7 @@
 GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml")
 GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml")
 GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml")
+GET_XML_WITHOUT_SCHEDULE = TEST_ASSET_DIR / "tasks_without_schedule.xml"
 
 
 class TaskTests(unittest.TestCase):
@@ -86,6 +88,15 @@ def test_get_task_with_schedule(self):
         self.assertEqual("workbook", task.target.type)
         self.assertEqual("b60b4efd-a6f7-4599-beb3-cb677e7abac1", task.schedule_id)
 
+    def test_get_task_without_schedule(self):
+        with requests_mock.mock() as m:
+            m.get(self.baseurl, text=GET_XML_WITHOUT_SCHEDULE.read_text())
+            all_tasks, pagination_item = self.server.tasks.get()
+
+        task = all_tasks[0]
+        self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id)
+        self.assertEqual("datasource", task.target.type)
+
     def test_delete(self):
         with requests_mock.mock() as m:
             m.delete(self.baseurl + "/c7a9327e-1cda-4504-b026-ddb43b976d1d", status_code=204)

From 95d66973d8cc8599e71431f297b8838ae556c3a9 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 22:17:37 -0500
Subject: [PATCH 13/27] Formatting

---
 tableauserverclient/models/task_item.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py
index eae5948e3..cb7eeec6f 100644
--- a/tableauserverclient/models/task_item.py
+++ b/tableauserverclient/models/task_item.py
@@ -28,7 +28,7 @@ def __init__(
         consecutive_failed_count: int = 0,
         schedule_id: Optional[str] = None,
         schedule_item: Optional[str] = None,
-        last_run_at: Optional[datetime]=None,
+        last_run_at: Optional[datetime] = None,
         target: Optional[Target] = None,
     ):
         self.id = id_

From 82ff83aca821b02091fa0035847eb179e83d607b Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 22:39:46 -0500
Subject: [PATCH 14/27] Add type annotations

---
 tableauserverclient/models/task_item.py        |  2 +-
 .../server/endpoint/tasks_endpoint.py          | 18 ++++++++++++------
 2 files changed, 13 insertions(+), 7 deletions(-)

diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py
index cb7eeec6f..0ffc3bfab 100644
--- a/tableauserverclient/models/task_item.py
+++ b/tableauserverclient/models/task_item.py
@@ -27,7 +27,7 @@ def __init__(
         priority: int,
         consecutive_failed_count: int = 0,
         schedule_id: Optional[str] = None,
-        schedule_item: Optional[str] = None,
+        schedule_item: Optional[ScheduleItem] = None,
         last_run_at: Optional[datetime] = None,
         target: Optional[Target] = None,
     ):
diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py
index 92e0095c9..383f0984e 100644
--- a/tableauserverclient/server/endpoint/tasks_endpoint.py
+++ b/tableauserverclient/server/endpoint/tasks_endpoint.py
@@ -1,4 +1,5 @@
 import logging
+from typing import List, Optional, Tuple, TYPE_CHECKING
 
 from tableauserverclient.server.endpoint.endpoint import Endpoint, api
 from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
@@ -7,13 +8,16 @@
 
 from tableauserverclient.helpers.logging import logger
 
+if TYPE_CHECKING:
+    from tableauserverclient.server.request_options import RequestOptions
+
 
 class Tasks(Endpoint):
     @property
-    def baseurl(self):
+    def baseurl(self) -> str:
         return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, self.parent_srv.site_id)
 
-    def __normalize_task_type(self, task_type):
+    def __normalize_task_type(self, task_type: str) -> str:
         """
         The word for extract refresh used in API URL is "extractRefreshes".
         It is different than the tag "extractRefresh" used in the request body.
@@ -24,7 +28,9 @@ def __normalize_task_type(self, task_type):
             return task_type
 
     @api(version="2.6")
-    def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh):
+    def get(
+        self, req_options: Optional["RequestOptions"] = None, task_type: str = TaskItem.Type.ExtractRefresh
+    ) -> Tuple[List[TaskItem], PaginationItem]:
         if task_type == TaskItem.Type.DataAcceleration:
             self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks")
 
@@ -38,7 +44,7 @@ def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh):
         return all_tasks, pagination_item
 
     @api(version="2.6")
-    def get_by_id(self, task_id):
+    def get_by_id(self, task_id: str) -> TaskItem:
         if not task_id:
             error = "No Task ID provided"
             raise ValueError(error)
@@ -63,7 +69,7 @@ def create(self, extract_item: TaskItem) -> TaskItem:
         return server_response.content
 
     @api(version="2.6")
-    def run(self, task_item):
+    def run(self, task_item: TaskItem) -> bytes:
         if not task_item.id:
             error = "Task item missing ID."
             raise MissingRequiredFieldError(error)
@@ -79,7 +85,7 @@ def run(self, task_item):
 
     # Delete 1 task by id
     @api(version="3.6")
-    def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh):
+    def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) -> None:
         if task_type == TaskItem.Type.DataAcceleration:
             self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks")
 

From 36a5547617d2f7d53ae4eaf44f7e5116b29a7181 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 14 Oct 2023 22:40:16 -0500
Subject: [PATCH 15/27] Permit creation of tasks without schedules

---
 tableauserverclient/server/request_factory.py | 18 +++++++++++-------
 1 file changed, 11 insertions(+), 7 deletions(-)

diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py
index 7fb9bf9ed..6316527ec 100644
--- a/tableauserverclient/server/request_factory.py
+++ b/tableauserverclient/server/request_factory.py
@@ -1032,6 +1032,16 @@ def run_req(self, xml_request, task_item):
     def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes:
         extract_element = ET.SubElement(xml_request, "extractRefresh")
 
+        # Main attributes
+        extract_element.attrib["type"] = extract_item.task_type
+
+        if extract_item.target is not None:
+            target_element = ET.SubElement(extract_element, extract_item.target.type)
+            target_element.attrib["id"] = extract_item.target.id
+
+        if extract_item.schedule_item is None:
+            return ET.tostring(xml_request)
+
         # Schedule attributes
         schedule_element = ET.SubElement(xml_request, "schedule")
 
@@ -1043,17 +1053,11 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem")
             frequency_element.attrib["end"] = str(interval_item.end_time)
         if hasattr(interval_item, "interval") and interval_item.interval:
             intervals_element = ET.SubElement(frequency_element, "intervals")
-            for interval in interval_item._interval_type_pairs():
+            for interval in interval_item._interval_type_pairs():  # type: ignore
                 expression, value = interval
                 single_interval_element = ET.SubElement(intervals_element, "interval")
                 single_interval_element.attrib[expression] = value
 
-        # Main attributes
-        extract_element.attrib["type"] = extract_item.task_type
-
-        target_element = ET.SubElement(extract_element, extract_item.target.type)
-        target_element.attrib["id"] = extract_item.target.id
-
         return ET.tostring(xml_request)
 
 

From 11656c4955508f44bcdb13a496989e185ab7e5ae Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sun, 15 Oct 2023 20:09:19 -0500
Subject: [PATCH 16/27] Fix logging format

---
 tableauserverclient/server/endpoint/tasks_endpoint.py | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py
index 383f0984e..a727a515f 100644
--- a/tableauserverclient/server/endpoint/tasks_endpoint.py
+++ b/tableauserverclient/server/endpoint/tasks_endpoint.py
@@ -34,7 +34,7 @@ def get(
         if task_type == TaskItem.Type.DataAcceleration:
             self.parent_srv.assert_at_least_version("3.8", "Data Acceleration Tasks")
 
-        logger.info("Querying all {} tasks for the site".format(task_type))
+        logger.info("Querying all %s tasks for the site", task_type)
 
         url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type))
         server_response = self.get_request(url, req_options)
@@ -48,7 +48,7 @@ def get_by_id(self, task_id: str) -> TaskItem:
         if not task_id:
             error = "No Task ID provided"
             raise ValueError(error)
-        logger.info("Querying a single task by id ({})".format(task_id))
+        logger.info("Querying a single task by id %s", task_id)
         url = "{}/{}/{}".format(
             self.baseurl,
             self.__normalize_task_type(TaskItem.Type.ExtractRefresh),
@@ -62,7 +62,7 @@ def create(self, extract_item: TaskItem) -> TaskItem:
         if not extract_item:
             error = "No extract refresh provided"
             raise ValueError(error)
-        logger.info("Creating an extract refresh ({})".format(extract_item))
+        logger.info("Creating an extract refresh %s", extract_item)
         url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh))
         create_req = RequestFactory.Task.create_extract_req(extract_item)
         server_response = self.post_request(url, create_req)
@@ -94,4 +94,4 @@ def delete(self, task_id: str, task_type: str = TaskItem.Type.ExtractRefresh) ->
             raise ValueError(error)
         url = "{0}/{1}/{2}".format(self.baseurl, self.__normalize_task_type(task_type), task_id)
         self.delete_request(url)
-        logger.info("Deleted single task (ID: {0})".format(task_id))
+        logger.info("Deleted single task (ID: %s)", task_id)

From 246b44974a328a6088003ba5f2242392efb76e39 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kr=C3=BCger?= <KruegerK@rki.de>
Date: Thu, 19 Oct 2023 15:42:12 +0200
Subject: [PATCH 17/27] issue-1299 set empty async response to None

---
 tableauserverclient/config.py                 |  2 +-
 .../server/endpoint/endpoint.py               | 19 +++++++++----------
 2 files changed, 10 insertions(+), 11 deletions(-)

diff --git a/tableauserverclient/config.py b/tableauserverclient/config.py
index 67a77f479..1a4a7dc37 100644
--- a/tableauserverclient/config.py
+++ b/tableauserverclient/config.py
@@ -7,7 +7,7 @@
 # For when a datasource is over 64MB, break it into 5MB(standard chunk size) chunks
 CHUNK_SIZE_MB = 5 * 10  # 5MB felt too slow, upped it to 50
 
-DELAY_SLEEP_SECONDS = 10
+DELAY_SLEEP_SECONDS = 0.1
 
 # The maximum size of a file that can be published in a single request is 64MB
 FILESIZE_LIMIT_MB = 64
diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index c11a3fb27..5d84d8e7f 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -76,7 +76,7 @@ def set_user_agent(parameters):
         # return explicitly for testing only
         return parameters
 
-    def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]:
+    def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Response", Exception]]:
         self.async_response = None
         response = None
         logger.debug("[{}] Begin blocking request to {}".format(datetime.timestamp(), url))
@@ -96,32 +96,31 @@ def _blocking_request(self, method, url, parameters={}) -> Optional["Response"]:
 
     def send_request_while_show_progress_threaded(
         self, method, url, parameters={}, request_timeout=0
-    ) -> Optional["Response"]:
+    ) -> Optional[Union["Response", Exception]]:
         try:
             request_thread = Thread(target=self._blocking_request, args=(method, url, parameters))
-            request_thread.async_response = -1  # type:ignore # this is an invented attribute for thread comms
             request_thread.start()
         except Exception as e:
             logger.debug("Error starting server request on separate thread: {}".format(e))
             return None
-        seconds = 0
+        seconds = 0.05
         minutes = 0
-        sleep(1)
-        if self.async_response != -1:
+        sleep(seconds)
+        if self.async_response is not None:
             # a quick return for any immediate responses
             return self.async_response
-        while self.async_response == -1 and (request_timeout == 0 or seconds < request_timeout):
+        while (self.async_response is None) and (request_timeout == 0 or seconds < request_timeout):
             self.log_wait_time_then_sleep(minutes, seconds, url)
             seconds = seconds + DELAY_SLEEP_SECONDS
             if seconds >= 60:
-                seconds = 0
-                minutes = minutes + 1
+                seconds -= 60
+                minutes += 1
         return self.async_response
 
     def log_wait_time_then_sleep(self, minutes, seconds, url):
         logger.debug("{} Waiting....".format(datetime.timestamp()))
         if seconds >= 60:  # detailed log message ~every minute
-            if minutes % 5 == 0:
+            if minutes % 1 == 0:
                 logger.info(
                     "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)
                 )

From 88d46142cc47fca2655c34a1fe856d391f40b8a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kr=C3=BCger?= <KruegerK@rki.de>
Date: Thu, 19 Oct 2023 15:44:48 +0200
Subject: [PATCH 18/27] issue-1299 remove unused import

---
 tableauserverclient/server/endpoint/endpoint.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index 5d84d8e7f..aa22acfb1 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -2,7 +2,6 @@
 from time import sleep
 from tableauserverclient import datetime_helpers as datetime
 
-import requests
 from packaging.version import Version
 from functools import wraps
 from xml.etree.ElementTree import ParseError

From 3ff3131d95c535f1c1e37615d880c2dec0ab433e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kr=C3=BCger?= <KruegerK@rki.de>
Date: Thu, 19 Oct 2023 16:10:49 +0200
Subject: [PATCH 19/27] issue-1299 fix timeout missed when longer than 60s

---
 .../server/endpoint/endpoint.py               | 31 ++++++++++---------
 1 file changed, 16 insertions(+), 15 deletions(-)

diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index aa22acfb1..8e02933ca 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -94,7 +94,7 @@ def _blocking_request(self, method, url, parameters={}) -> Optional[Union["Respo
         return self.async_response
 
     def send_request_while_show_progress_threaded(
-        self, method, url, parameters={}, request_timeout=0
+        self, method, url, parameters={}, request_timeout=None
     ) -> Optional[Union["Response", Exception]]:
         try:
             request_thread = Thread(target=self._blocking_request, args=(method, url, parameters))
@@ -104,28 +104,29 @@ def send_request_while_show_progress_threaded(
             return None
         seconds = 0.05
         minutes = 0
+        last_log_minute = 0
         sleep(seconds)
         if self.async_response is not None:
             # a quick return for any immediate responses
             return self.async_response
-        while (self.async_response is None) and (request_timeout == 0 or seconds < request_timeout):
-            self.log_wait_time_then_sleep(minutes, seconds, url)
+        timed_out: bool = (request_timeout is not None and seconds > request_timeout)
+        while (self.async_response is None) and not timed_out:
+            sleep(DELAY_SLEEP_SECONDS)
             seconds = seconds + DELAY_SLEEP_SECONDS
-            if seconds >= 60:
-                seconds -= 60
-                minutes += 1
+            minutes = int(seconds/60)
+            last_log_minute = self.log_wait_time(minutes, last_log_minute, url)
         return self.async_response
 
-    def log_wait_time_then_sleep(self, minutes, seconds, url):
+    def log_wait_time(self, minutes, last_log_minute, url) -> int:
         logger.debug("{} Waiting....".format(datetime.timestamp()))
-        if seconds >= 60:  # detailed log message ~every minute
-            if minutes % 1 == 0:
-                logger.info(
-                    "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)
-                )
-            else:
-                logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url))
-        sleep(DELAY_SLEEP_SECONDS)
+        if minutes > last_log_minute:  # detailed log message ~every minute
+           logger.info(
+               "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)
+           )
+           last_log_minute = minutes
+        else:
+            logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url))
+        return last_log_minute
 
     def _make_request(
         self,

From f7d60f94ec7ee3171e649169c1c4a9f4b4cb729f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kr=C3=BCger?= <KruegerK@rki.de>
Date: Fri, 20 Oct 2023 15:48:00 +0200
Subject: [PATCH 20/27] issue-1299 paint it black

---
 tableauserverclient/server/endpoint/endpoint.py | 10 ++++------
 1 file changed, 4 insertions(+), 6 deletions(-)

diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index 8e02933ca..5dbf3c9b8 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -109,21 +109,19 @@ def send_request_while_show_progress_threaded(
         if self.async_response is not None:
             # a quick return for any immediate responses
             return self.async_response
-        timed_out: bool = (request_timeout is not None and seconds > request_timeout)
+        timed_out: bool = request_timeout is not None and seconds > request_timeout
         while (self.async_response is None) and not timed_out:
             sleep(DELAY_SLEEP_SECONDS)
             seconds = seconds + DELAY_SLEEP_SECONDS
-            minutes = int(seconds/60)
+            minutes = int(seconds / 60)
             last_log_minute = self.log_wait_time(minutes, last_log_minute, url)
         return self.async_response
 
     def log_wait_time(self, minutes, last_log_minute, url) -> int:
         logger.debug("{} Waiting....".format(datetime.timestamp()))
         if minutes > last_log_minute:  # detailed log message ~every minute
-           logger.info(
-               "[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url)
-           )
-           last_log_minute = minutes
+            logger.info("[{}] Waiting ({} minutes so far) for request to {}".format(datetime.timestamp(), minutes, url))
+            last_log_minute = minutes
         else:
             logger.debug("[{}] Waiting for request to {}".format(datetime.timestamp(), url))
         return last_log_minute

From 538324e8bab057394305f61e6e57dd2f474de0d3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kr=C3=BCger?= <KruegerK@rki.de>
Date: Wed, 25 Oct 2023 15:49:03 +0200
Subject: [PATCH 21/27] issue-1299 raise exception when returned from blocking
 request

---
 tableauserverclient/server/endpoint/endpoint.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index 5dbf3c9b8..c97091d98 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -148,7 +148,7 @@ def _make_request(
         # a request can, for stuff like publishing, spin for ages waiting for a response.
         # we need some user-facing activity so they know it's not dead.
         request_timeout = self.parent_srv.http_options.get("timeout") or 0
-        server_response: Optional["Response"] = self.send_request_while_show_progress_threaded(
+        server_response: Optional[Union["Response",Exception]] = self.send_request_while_show_progress_threaded(
             method, url, parameters, request_timeout
         )
         logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response))
@@ -160,6 +160,8 @@ def _make_request(
         if server_response is None:
             logger.debug("[{}] Request failed".format(datetime.timestamp()))
             raise RuntimeError
+        if isinstance(server_response, Exception):
+            raise server_response
         self._check_status(server_response, url)
 
         loggable_response = self.log_response_safely(server_response)

From 5653a3eabf4beaf0512521745afbdb6f5314cf00 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kr=C3=BCger?= <KruegerK@rki.de>
Date: Wed, 15 Nov 2023 18:49:56 +0100
Subject: [PATCH 22/27] issue-1299 black line length 120

---
 tableauserverclient/server/endpoint/endpoint.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py
index c97091d98..77a771288 100644
--- a/tableauserverclient/server/endpoint/endpoint.py
+++ b/tableauserverclient/server/endpoint/endpoint.py
@@ -148,7 +148,7 @@ def _make_request(
         # a request can, for stuff like publishing, spin for ages waiting for a response.
         # we need some user-facing activity so they know it's not dead.
         request_timeout = self.parent_srv.http_options.get("timeout") or 0
-        server_response: Optional[Union["Response",Exception]] = self.send_request_while_show_progress_threaded(
+        server_response: Optional[Union["Response", Exception]] = self.send_request_while_show_progress_threaded(
             method, url, parameters, request_timeout
         )
         logger.debug("[{}] Async request returned: received {}".format(datetime.timestamp(), server_response))

From 1f9088f7637b46214fd98c2db43249d38c7d66c4 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Fri, 1 Dec 2023 19:43:20 -0600
Subject: [PATCH 23/27] fix: correct type hint on download_revision
 revision_number

---
 tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py
index 3c8efbe3b..dbcc1ec53 100644
--- a/tableauserverclient/server/endpoint/workbooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -455,7 +455,7 @@ def _get_workbook_revisions(
     def download_revision(
         self,
         workbook_id: str,
-        revision_number: str,
+        revision_number: Optional[str],
         filepath: Optional[PathOrFileW] = None,
         include_extract: bool = True,
         no_extract: Optional[bool] = None,

From f42948a1bee9e7f122764ecc2c9cf1c9d6877ea1 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 9 Dec 2023 22:01:37 -0600
Subject: [PATCH 24/27] fix: handle filename* in download response

---
 .gitignore                                    |  2 ++
 tableauserverclient/helpers/headers.py        | 19 +++++++++++++++++++
 .../server/endpoint/datasources_endpoint.py   |  3 +++
 .../server/endpoint/flows_endpoint.py         |  3 +++
 .../server/endpoint/workbooks_endpoint.py     |  3 +++
 test/test_datasource.py                       | 14 ++++++++++++++
 test/test_flow.py                             | 15 +++++++++++++++
 test/test_workbook.py                         | 14 ++++++++++++++
 8 files changed, 73 insertions(+)
 create mode 100644 tableauserverclient/helpers/headers.py

diff --git a/.gitignore b/.gitignore
index f0226c065..e9bd2b49f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,7 @@ var/
 *.egg-info/
 .installed.cfg
 *.egg
+pip-wheel-metadata/
 
 # PyInstaller
 #  Usually these files are written by a python script from a template
@@ -89,6 +90,7 @@ env.py
 # virtualenv
 venv/
 ENV/
+.venv/
 
 # Spyder project settings
 .spyderproject
diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py
new file mode 100644
index 000000000..18b4eacd6
--- /dev/null
+++ b/tableauserverclient/helpers/headers.py
@@ -0,0 +1,19 @@
+from copy import deepcopy
+from typing import Any, Generic, Mapping, Optional, TypeVar, Union
+from urllib.parse import unquote_plus
+
+T = TypeVar("T", )
+
+def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]:
+    if "filename*" not in params:
+        return params
+    
+    params = deepcopy(params)
+    filename = params["filename*"]
+    prefix = "UTF-8''"
+    if filename.startswith(prefix):
+        filename = filename[len(prefix):]
+
+    params["filename"] = unquote_plus(filename)
+    del params["filename*"]
+    return params
\ No newline at end of file
diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py
index c60f8f919..66ad9f710 100644
--- a/tableauserverclient/server/endpoint/datasources_endpoint.py
+++ b/tableauserverclient/server/endpoint/datasources_endpoint.py
@@ -8,6 +8,8 @@
 from pathlib import Path
 from typing import List, Mapping, Optional, Sequence, Tuple, TYPE_CHECKING, Union
 
+from tableauserverclient.helpers.headers import fix_filename
+
 if TYPE_CHECKING:
     from tableauserverclient.server import Server
     from tableauserverclient.models import PermissionsRule
@@ -441,6 +443,7 @@ def download_revision(
                     filepath.write(chunk)
                 return_path = filepath
             else:
+                params = fix_filename(params)
                 filename = to_filename(os.path.basename(params["filename"]))
                 download_path = make_download_path(filepath, filename)
                 with open(download_path, "wb") as f:
diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py
index ba8a152d7..21c16b1cc 100644
--- a/tableauserverclient/server/endpoint/flows_endpoint.py
+++ b/tableauserverclient/server/endpoint/flows_endpoint.py
@@ -7,6 +7,8 @@
 from pathlib import Path
 from typing import Iterable, List, Optional, TYPE_CHECKING, Tuple, Union
 
+from tableauserverclient.helpers.headers import fix_filename
+
 from .dqw_endpoint import _DataQualityWarningEndpoint
 from .endpoint import QuerysetEndpoint, api
 from .exceptions import InternalServerError, MissingRequiredFieldError
@@ -124,6 +126,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path
                     filepath.write(chunk)
                 return_path = filepath
             else:
+                params = fix_filename(params)
                 filename = to_filename(os.path.basename(params["filename"]))
                 download_path = make_download_path(filepath, filename)
                 with open(download_path, "wb") as f:
diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py
index dbcc1ec53..506fe02c2 100644
--- a/tableauserverclient/server/endpoint/workbooks_endpoint.py
+++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -6,6 +6,8 @@
 from contextlib import closing
 from pathlib import Path
 
+from tableauserverclient.helpers.headers import fix_filename
+
 from .endpoint import QuerysetEndpoint, api, parameter_added_in
 from .exceptions import InternalServerError, MissingRequiredFieldError
 from .permissions_endpoint import _PermissionsEndpoint
@@ -487,6 +489,7 @@ def download_revision(
                     filepath.write(chunk)
                 return_path = filepath
             else:
+                params = fix_filename(params)
                 filename = to_filename(os.path.basename(params["filename"]))
                 download_path = make_download_path(filepath, filename)
                 with open(download_path, "wb") as f:
diff --git a/test/test_datasource.py b/test/test_datasource.py
index e299e5291..c79bf45fd 100644
--- a/test/test_datasource.py
+++ b/test/test_datasource.py
@@ -696,3 +696,17 @@ def test_download_revision(self) -> None:
             )
             file_path = self.server.datasources.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td)
             self.assertTrue(os.path.exists(file_path))
+
+    def test_bad_download_response(self) -> None:
+        with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td:
+            m.get(
+                self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
+                headers={
+                    "Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"'''
+                }
+            )
+            file_path = self.server.datasources.download(
+                "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb",
+                td
+            )
+            self.assertTrue(os.path.exists(file_path))
\ No newline at end of file
diff --git a/test/test_flow.py b/test/test_flow.py
index d10641809..d7fa2dbc3 100644
--- a/test/test_flow.py
+++ b/test/test_flow.py
@@ -1,5 +1,6 @@
 import os
 import requests_mock
+import tempfile
 import unittest
 
 from io import BytesIO
@@ -203,3 +204,17 @@ def test_refresh(self):
             self.assertEqual(refresh_job.flow_run.id, "e0c3067f-2333-4eee-8028-e0a56ca496f6")
             self.assertEqual(refresh_job.flow_run.flow_id, "92967d2d-c7e2-46d0-8847-4802df58f484")
             self.assertEqual(format_datetime(refresh_job.flow_run.started_at), "2018-05-22T13:00:29Z")
+
+    def test_bad_download_response(self) -> None:
+        with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td:
+            m.get(
+                self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
+                headers={
+                    "Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''
+                }
+            )
+            file_path = self.server.flows.download(
+                "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb",
+                td
+            )
+            self.assertTrue(os.path.exists(file_path))
diff --git a/test/test_workbook.py b/test/test_workbook.py
index 5114ce1b8..9804b2c02 100644
--- a/test/test_workbook.py
+++ b/test/test_workbook.py
@@ -932,3 +932,17 @@ def test_download_revision(self) -> None:
             )
             file_path = self.server.workbooks.download_revision("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", "3", td)
             self.assertTrue(os.path.exists(file_path))
+
+    def test_bad_download_response(self) -> None:
+        with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td:
+            m.get(
+                self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
+                headers={
+                    "Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"'''
+                }
+            )
+            file_path = self.server.workbooks.download(
+                "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb",
+                td
+            )
+            self.assertTrue(os.path.exists(file_path))

From 76559d4c0456034a610818fd3bace51067e7ba07 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 9 Dec 2023 22:06:05 -0600
Subject: [PATCH 25/27] style: black formatting

---
 tableauserverclient/helpers/headers.py | 11 +++++++----
 test/test_datasource.py                |  9 +++------
 test/test_flow.py                      |  9 ++-------
 test/test_workbook.py                  |  9 ++-------
 4 files changed, 14 insertions(+), 24 deletions(-)

diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py
index 18b4eacd6..57be21b23 100644
--- a/tableauserverclient/helpers/headers.py
+++ b/tableauserverclient/helpers/headers.py
@@ -2,18 +2,21 @@
 from typing import Any, Generic, Mapping, Optional, TypeVar, Union
 from urllib.parse import unquote_plus
 
-T = TypeVar("T", )
+T = TypeVar(
+    "T",
+)
+
 
 def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]:
     if "filename*" not in params:
         return params
-    
+
     params = deepcopy(params)
     filename = params["filename*"]
     prefix = "UTF-8''"
     if filename.startswith(prefix):
-        filename = filename[len(prefix):]
+        filename = filename[len(prefix) :]
 
     params["filename"] = unquote_plus(filename)
     del params["filename*"]
-    return params
\ No newline at end of file
+    return params
diff --git a/test/test_datasource.py b/test/test_datasource.py
index c79bf45fd..f258fdc52 100644
--- a/test/test_datasource.py
+++ b/test/test_datasource.py
@@ -703,10 +703,7 @@ def test_bad_download_response(self) -> None:
                 self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
                 headers={
                     "Content-Disposition": '''name="tableau_datasource"; filename*=UTF-8''"Sample datasource.tds"'''
-                }
-            )
-            file_path = self.server.datasources.download(
-                "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb",
-                td
+                },
             )
-            self.assertTrue(os.path.exists(file_path))
\ No newline at end of file
+            file_path = self.server.datasources.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td)
+            self.assertTrue(os.path.exists(file_path))
diff --git a/test/test_flow.py b/test/test_flow.py
index d7fa2dbc3..a90b18171 100644
--- a/test/test_flow.py
+++ b/test/test_flow.py
@@ -209,12 +209,7 @@ def test_bad_download_response(self) -> None:
         with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td:
             m.get(
                 self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
-                headers={
-                    "Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''
-                }
-            )
-            file_path = self.server.flows.download(
-                "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb",
-                td
+                headers={"Content-Disposition": '''name="tableau_flow"; filename*=UTF-8''"Sample flow.tfl"'''},
             )
+            file_path = self.server.flows.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td)
             self.assertTrue(os.path.exists(file_path))
diff --git a/test/test_workbook.py b/test/test_workbook.py
index 9804b2c02..212d55a37 100644
--- a/test/test_workbook.py
+++ b/test/test_workbook.py
@@ -937,12 +937,7 @@ def test_bad_download_response(self) -> None:
         with requests_mock.mock() as m, tempfile.TemporaryDirectory() as td:
             m.get(
                 self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/content",
-                headers={
-                    "Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"'''
-                }
-            )
-            file_path = self.server.workbooks.download(
-                "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb",
-                td
+                headers={"Content-Disposition": '''name="tableau_workbook"; filename*=UTF-8''"Sample workbook.twb"'''},
             )
+            file_path = self.server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td)
             self.assertTrue(os.path.exists(file_path))

From 19a9f51ab7ab65a1819bfabf28f885d2fe0df7e2 Mon Sep 17 00:00:00 2001
From: Jordan Woods <lenluin@gmail.com>
Date: Sat, 9 Dec 2023 22:12:15 -0600
Subject: [PATCH 26/27] fix: strip typing from fix_filename

---
 tableauserverclient/helpers/headers.py | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)

diff --git a/tableauserverclient/helpers/headers.py b/tableauserverclient/helpers/headers.py
index 57be21b23..2ed4a814d 100644
--- a/tableauserverclient/helpers/headers.py
+++ b/tableauserverclient/helpers/headers.py
@@ -1,13 +1,8 @@
 from copy import deepcopy
-from typing import Any, Generic, Mapping, Optional, TypeVar, Union
 from urllib.parse import unquote_plus
 
-T = TypeVar(
-    "T",
-)
 
-
-def fix_filename(params: Mapping[str, T]) -> Mapping[str, T]:
+def fix_filename(params):
     if "filename*" not in params:
         return params
 

From f17a75d14cc0d516a01ec2676326d42f29a1866c Mon Sep 17 00:00:00 2001
From: a-torres-2 <142839181+a-torres-2@users.noreply.github.com>
Date: Wed, 13 Dec 2023 10:49:52 -0800
Subject: [PATCH 27/27] add support for multiple intervals for hourly, daily,
 and monthly schedules

---
 tableauserverclient/models/interval_item.py | 124 +++++++++++++++-----
 tableauserverclient/models/schedule_item.py |  36 ++++--
 test/assets/schedule_get_daily_id.xml       |  11 ++
 test/assets/schedule_get_hourly_id.xml      |  11 ++
 test/assets/schedule_get_monthly_id.xml     |  11 ++
 test/test_schedule.py                       |  52 +++++++-
 6 files changed, 205 insertions(+), 40 deletions(-)
 create mode 100644 test/assets/schedule_get_daily_id.xml
 create mode 100644 test/assets/schedule_get_hourly_id.xml
 create mode 100644 test/assets/schedule_get_monthly_id.xml

diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py
index 25b6d09d7..44c24a6f6 100644
--- a/tableauserverclient/models/interval_item.py
+++ b/tableauserverclient/models/interval_item.py
@@ -29,7 +29,12 @@ class HourlyInterval(object):
     def __init__(self, start_time, end_time, interval_value):
         self.start_time = start_time
         self.end_time = end_time
-        self.interval = interval_value
+
+        # interval should be a tuple, if it is not, assign as a tuple with single value
+        if isinstance(interval_value, tuple):
+            self.interval = interval_value
+        else:
+            self.interval = (interval_value,)
 
     @property
     def _frequency(self):
@@ -60,25 +65,44 @@ def interval(self):
         return self._interval
 
     @interval.setter
-    def interval(self, interval):
+    def interval(self, intervals):
         VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12}
-        if float(interval) not in VALID_INTERVALS:
-            error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS))
-            raise ValueError(error)
+        for interval in intervals:
+            # if an hourly interval is a string, then it is a weekDay interval
+            if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval):
+                error = "Invalid weekDay interval {}".format(interval)
+                raise ValueError(error)
+
+            # if an hourly interval is a number, it is an hours or minutes interval
+            if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS:
+                error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS))
+                raise ValueError(error)
 
-        self._interval = interval
+        self._interval = intervals
 
     def _interval_type_pairs(self):
-        # We use fractional hours for the two minute-based intervals.
-        # Need to convert to minutes from hours here
-        if self.interval in {0.25, 0.5}:
-            calculated_interval = int(self.interval * 60)
-            interval_type = IntervalItem.Occurrence.Minutes
-        else:
-            calculated_interval = self.interval
-            interval_type = IntervalItem.Occurrence.Hours
+        interval_type_pairs = []
+        for interval in self.interval:
+            # We use fractional hours for the two minute-based intervals.
+            # Need to convert to minutes from hours here
+            if interval in {0.25, 0.5}:
+                calculated_interval = int(interval * 60)
+                interval_type = IntervalItem.Occurrence.Minutes
+
+                interval_type_pairs.append((interval_type, str(calculated_interval)))
+            else:
+                # if the interval is a non-numeric string, it will always be a weekDay
+                if isinstance(interval, str) and not interval.isnumeric():
+                    interval_type = IntervalItem.Occurrence.WeekDay
+
+                    interval_type_pairs.append((interval_type, str(interval)))
+                # otherwise the interval is hours
+                else:
+                    interval_type = IntervalItem.Occurrence.Hours
 
-        return [(interval_type, str(calculated_interval))]
+                    interval_type_pairs.append((interval_type, str(interval)))
+
+        return interval_type_pairs
 
 
 class DailyInterval(object):
@@ -105,8 +129,45 @@ def interval(self):
         return self._interval
 
     @interval.setter
-    def interval(self, interval):
-        self._interval = interval
+    def interval(self, intervals):
+        VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12}
+
+        for interval in intervals:
+            # if an hourly interval is a string, then it is a weekDay interval
+            if isinstance(interval, str) and not interval.isnumeric() and not hasattr(IntervalItem.Day, interval):
+                error = "Invalid weekDay interval {}".format(interval)
+                raise ValueError(error)
+
+            # if an hourly interval is a number, it is an hours or minutes interval
+            if isinstance(interval, (int, float)) and float(interval) not in VALID_INTERVALS:
+                error = "Invalid interval {} not in {}".format(interval, str(VALID_INTERVALS))
+                raise ValueError(error)
+
+        self._interval = intervals
+
+    def _interval_type_pairs(self):
+        interval_type_pairs = []
+        for interval in self.interval:
+            # We use fractional hours for the two minute-based intervals.
+            # Need to convert to minutes from hours here
+            if interval in {0.25, 0.5}:
+                calculated_interval = int(interval * 60)
+                interval_type = IntervalItem.Occurrence.Minutes
+
+                interval_type_pairs.append((interval_type, str(calculated_interval)))
+            else:
+                # if the interval is a non-numeric string, it will always be a weekDay
+                if isinstance(interval, str) and not interval.isnumeric():
+                    interval_type = IntervalItem.Occurrence.WeekDay
+
+                    interval_type_pairs.append((interval_type, str(interval)))
+                # otherwise the interval is hours
+                else:
+                    interval_type = IntervalItem.Occurrence.Hours
+
+                    interval_type_pairs.append((interval_type, str(interval)))
+
+        return interval_type_pairs
 
 
 class WeeklyInterval(object):
@@ -146,7 +207,12 @@ def _interval_type_pairs(self):
 class MonthlyInterval(object):
     def __init__(self, start_time, interval_value):
         self.start_time = start_time
-        self.interval = str(interval_value)
+
+        # interval should be a tuple, if it is not, assign as a tuple with single value
+        if isinstance(interval_value, tuple):
+            self.interval = interval_value
+        else:
+            self.interval = (interval_value,)
 
     @property
     def _frequency(self):
@@ -167,24 +233,24 @@ def interval(self):
         return self._interval
 
     @interval.setter
-    def interval(self, interval_value):
-        error = "Invalid interval value for a monthly frequency: {}.".format(interval_value)
-
+    def interval(self, interval_values):
         # This is weird because the value could be a str or an int
         # The only valid str is 'LastDay' so we check that first. If that's not it
         # try to convert it to an int, if that fails because it's an incorrect string
         # like 'badstring' we catch and re-raise. Otherwise we convert to int and check
         # that it's in range 1-31
+        for interval_value in interval_values:
+            error = "Invalid interval value for a monthly frequency: {}.".format(interval_value)
 
-        if interval_value != "LastDay":
-            try:
-                if not (1 <= int(interval_value) <= 31):
-                    raise ValueError(error)
-            except ValueError:
-                if interval_value != "LastDay":
-                    raise ValueError(error)
+            if interval_value != "LastDay":
+                try:
+                    if not (1 <= int(interval_value) <= 31):
+                        raise ValueError(error)
+                except ValueError:
+                    if interval_value != "LastDay":
+                        raise ValueError(error)
 
-        self._interval = str(interval_value)
+        self._interval = interval_values
 
     def _interval_type_pairs(self):
         return [(IntervalItem.Occurrence.MonthDay, self.interval)]
diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py
index edfd0fe70..23796ff46 100644
--- a/tableauserverclient/models/schedule_item.py
+++ b/tableauserverclient/models/schedule_item.py
@@ -251,25 +251,43 @@ def _parse_interval_item(parsed_response, frequency, ns):
             interval.extend(interval_elem.attrib.items())
 
         if frequency == IntervalItem.Frequency.Daily:
-            return DailyInterval(start_time)
+            converted_intervals = []
+
+            for i in interval:
+                # We use fractional hours for the two minute-based intervals.
+                # Need to convert to hours from minutes here
+                if i[0] == IntervalItem.Occurrence.Minutes:
+                    converted_intervals.append(float(i[1]) / 60)
+                elif i[0] == IntervalItem.Occurrence.Hours:
+                    converted_intervals.append(float(i[1]))
+                else:
+                    converted_intervals.append(i[1])
+
+            return DailyInterval(start_time, *converted_intervals)
 
         if frequency == IntervalItem.Frequency.Hourly:
-            interval_occurrence, interval_value = interval.pop()
+            converted_intervals = []
 
-            # We use fractional hours for the two minute-based intervals.
-            # Need to convert to hours from minutes here
-            if interval_occurrence == IntervalItem.Occurrence.Minutes:
-                interval_value = float(interval_value) / 60
+            for i in interval:
+                # We use fractional hours for the two minute-based intervals.
+                # Need to convert to hours from minutes here
+                if i[0] == IntervalItem.Occurrence.Minutes:
+                    converted_intervals.append(float(i[1]) / 60)
+                elif i[0] == IntervalItem.Occurrence.Hours:
+                    converted_intervals.append(i[1])
+                else:
+                    converted_intervals.append(i[1])
 
-            return HourlyInterval(start_time, end_time, interval_value)
+            return HourlyInterval(start_time, end_time, tuple(converted_intervals))
 
         if frequency == IntervalItem.Frequency.Weekly:
             interval_values = [i[1] for i in interval]
             return WeeklyInterval(start_time, *interval_values)
 
         if frequency == IntervalItem.Frequency.Monthly:
-            interval_occurrence, interval_value = interval.pop()
-            return MonthlyInterval(start_time, interval_value)
+            interval_values = [i[1] for i in interval]
+
+            return MonthlyInterval(start_time, tuple(interval_values))
 
     @staticmethod
     def _parse_element(schedule_xml, ns):
diff --git a/test/assets/schedule_get_daily_id.xml b/test/assets/schedule_get_daily_id.xml
new file mode 100644
index 000000000..99467a391
--- /dev/null
+++ b/test/assets/schedule_get_daily_id.xml
@@ -0,0 +1,11 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
+  <schedule id="c9cff7f9-309c-4361-99ff-d4ba8c9f5467" name="Daily schedule" state="Active" priority="50" createdAt="2016-07-06T20:19:00Z" updatedAt="2016-09-13T11:00:32Z" type="Extract" frequency="Daily" nextRunAt="2016-09-14T11:00:00Z">
+    <frequencyDetails start="14:00:00" end="01:00:00">
+      <intervals>
+        <interval weekDay="Monday"/>
+        <interval hours="2"/>
+      </intervals>
+    </frequencyDetails>
+  </schedule>
+</tsResponse>
\ No newline at end of file
diff --git a/test/assets/schedule_get_hourly_id.xml b/test/assets/schedule_get_hourly_id.xml
new file mode 100644
index 000000000..27c374ccf
--- /dev/null
+++ b/test/assets/schedule_get_hourly_id.xml
@@ -0,0 +1,11 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
+  <schedule id="c9cff7f9-309c-4361-99ff-d4ba8c9f5467" name="Hourly schedule" state="Active" priority="50" createdAt="2016-07-06T20:19:00Z" updatedAt="2016-09-13T11:00:32Z" type="Extract" frequency="Hourly" nextRunAt="2016-09-14T11:00:00Z">
+    <frequencyDetails start="14:00:00" end="01:00:00">
+      <intervals>
+        <interval weekDay="Monday"/>
+        <interval minutes="30"/>
+      </intervals>
+    </frequencyDetails>
+  </schedule>
+</tsResponse>
\ No newline at end of file
diff --git a/test/assets/schedule_get_monthly_id.xml b/test/assets/schedule_get_monthly_id.xml
new file mode 100644
index 000000000..3fc32cc57
--- /dev/null
+++ b/test/assets/schedule_get_monthly_id.xml
@@ -0,0 +1,11 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
+  <schedule id="c9cff7f9-309c-4361-99ff-d4ba8c9f5467" name="Monthly multiple days" state="Active" priority="50" createdAt="2016-07-06T20:19:00Z" updatedAt="2016-09-13T11:00:32Z" type="Extract" frequency="Monthly" nextRunAt="2016-09-14T11:00:00Z">
+    <frequencyDetails start="14:00:00">
+      <intervals>
+        <interval monthDay="1"/>
+        <interval monthDay="2"/>
+      </intervals>
+    </frequencyDetails>
+  </schedule>
+</tsResponse>
\ No newline at end of file
diff --git a/test/test_schedule.py b/test/test_schedule.py
index 807467918..76c8720b9 100644
--- a/test/test_schedule.py
+++ b/test/test_schedule.py
@@ -11,6 +11,9 @@
 
 GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml")
 GET_BY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_by_id.xml")
+GET_HOURLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_hourly_id.xml")
+GET_DAILY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_daily_id.xml")
+GET_MONTHLY_ID_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_monthly_id.xml")
 GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml")
 CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml")
 CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml")
@@ -100,6 +103,51 @@ def test_get_by_id(self) -> None:
             self.assertEqual("Weekday early mornings", schedule.name)
             self.assertEqual("Active", schedule.state)
 
+    def test_get_hourly_by_id(self) -> None:
+        self.server.version = "3.8"
+        with open(GET_HOURLY_ID_XML, "rb") as f:
+            response_xml = f.read().decode("utf-8")
+        with requests_mock.mock() as m:
+            schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467"
+            baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id)
+            m.get(baseurl, text=response_xml)
+            schedule = self.server.schedules.get_by_id(schedule_id)
+            self.assertIsNotNone(schedule)
+            self.assertEqual(schedule_id, schedule.id)
+            self.assertEqual("Hourly schedule", schedule.name)
+            self.assertEqual("Active", schedule.state)
+            self.assertEqual(("Monday", 0.5), schedule.interval_item.interval)
+
+    def test_get_daily_by_id(self) -> None:
+        self.server.version = "3.8"
+        with open(GET_DAILY_ID_XML, "rb") as f:
+            response_xml = f.read().decode("utf-8")
+        with requests_mock.mock() as m:
+            schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467"
+            baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id)
+            m.get(baseurl, text=response_xml)
+            schedule = self.server.schedules.get_by_id(schedule_id)
+            self.assertIsNotNone(schedule)
+            self.assertEqual(schedule_id, schedule.id)
+            self.assertEqual("Daily schedule", schedule.name)
+            self.assertEqual("Active", schedule.state)
+            self.assertEqual(("Monday", 2.0), schedule.interval_item.interval)
+
+    def test_get_monthly_by_id(self) -> None:
+        self.server.version = "3.8"
+        with open(GET_MONTHLY_ID_XML, "rb") as f:
+            response_xml = f.read().decode("utf-8")
+        with requests_mock.mock() as m:
+            schedule_id = "c9cff7f9-309c-4361-99ff-d4ba8c9f5467"
+            baseurl = "{}/schedules/{}".format(self.server.baseurl, schedule_id)
+            m.get(baseurl, text=response_xml)
+            schedule = self.server.schedules.get_by_id(schedule_id)
+            self.assertIsNotNone(schedule)
+            self.assertEqual(schedule_id, schedule.id)
+            self.assertEqual("Monthly multiple days", schedule.name)
+            self.assertEqual("Active", schedule.state)
+            self.assertEqual(("1", "2"), schedule.interval_item.interval)
+
     def test_delete(self) -> None:
         with requests_mock.mock() as m:
             m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204)
@@ -131,7 +179,7 @@ def test_create_hourly(self) -> None:
         self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order)
         self.assertEqual(time(2, 30), new_schedule.interval_item.start_time)
         self.assertEqual(time(23), new_schedule.interval_item.end_time)  # type: ignore[union-attr]
-        self.assertEqual("8", new_schedule.interval_item.interval)  # type: ignore[union-attr]
+        self.assertEqual(("8",), new_schedule.interval_item.interval)  # type: ignore[union-attr]
 
     def test_create_daily(self) -> None:
         with open(CREATE_DAILY_XML, "rb") as f:
@@ -216,7 +264,7 @@ def test_create_monthly(self) -> None:
         self.assertEqual("2016-10-12T14:00:00Z", format_datetime(new_schedule.next_run_at))
         self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order)
         self.assertEqual(time(7), new_schedule.interval_item.start_time)
-        self.assertEqual("12", new_schedule.interval_item.interval)  # type: ignore[union-attr]
+        self.assertEqual(("12",), new_schedule.interval_item.interval)  # type: ignore[union-attr]
 
     def test_update(self) -> None:
         with open(UPDATE_XML, "rb") as f: