From 3fefcfabdfc66819a6c8e6c1c66ea09a66c00dbe Mon Sep 17 00:00:00 2001 From: gregg 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 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 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("") -# -> b" 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 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 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 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 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 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 @@ + + + + + + + + + + \ No newline at end of file From e65ca391fd929572ac5d91bd8de31fc7929460a1 Mon Sep 17 00:00:00 2001 From: Jordan Woods 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 ( "".format(**self.__dict__) From 600a0b7208392d177ce71072c12e2415b9b8aded Mon Sep 17 00:00:00 2001 From: Jordan Woods 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 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 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 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 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 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 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?= 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?= 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?= 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?= 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?= 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?= 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 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 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 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 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 @@ + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + \ 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 @@ + + + + + + + + + + + \ 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: