From 1d3a642fea206108d540ea2fa9a87510af26f363 Mon Sep 17 00:00:00 2001 From: markm Date: Fri, 19 Jan 2024 12:27:31 -0600 Subject: [PATCH 01/73] Changes to alter cgi dependency to email.Messages --- .../server/endpoint/datasources_endpoint.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 66ad9f710..be3733d67 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import json import io @@ -437,14 +437,16 @@ def download_revision( url += "?includeExtract=False" with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m['Content-Disposition'] = server_response.headers["Content-Disposition"] + params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB From 2cc51710d62ceebddd89ee1f34fcb66948f7af1c Mon Sep 17 00:00:00 2001 From: markm Date: Sat, 20 Jan 2024 01:33:52 -0600 Subject: [PATCH 02/73] Changes to alter cgi dependency to email.Messages --- tableauserverclient/server/endpoint/flows_endpoint.py | 8 +++++--- tableauserverclient/server/endpoint/workbooks_endpoint.py | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 21c16b1cc..a9b937ea5 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import io import logging @@ -120,14 +120,16 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path url = "{0}/{1}/content".format(self.baseurl, flow_id) with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m['Content-Disposition'] = server_response.headers["Content-Disposition"] + params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 506fe02c2..73f69a145 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -1,4 +1,4 @@ -import cgi +from email.message import Message import copy import io import logging @@ -483,14 +483,16 @@ def download_revision( url += "?includeExtract=False" with closing(self.get_request(url, parameters={"stream": True})) as server_response: - _, params = cgi.parse_header(server_response.headers["Content-Disposition"]) + m = Message() + m['Content-Disposition'] = server_response.headers["Content-Disposition"] + params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) return_path = filepath else: params = fix_filename(params) - filename = to_filename(os.path.basename(params["filename"])) + filename = to_filename(os.path.basename(params)) download_path = make_download_path(filepath, filename) with open(download_path, "wb") as f: for chunk in server_response.iter_content(1024): # 1KB From 999b3019a50f8860168b276e86b30cc925080c89 Mon Sep 17 00:00:00 2001 From: markm Date: Sat, 20 Jan 2024 01:34:44 -0600 Subject: [PATCH 03/73] Changes to alter cgi dependency to email.Messages --- tableauserverclient/server/endpoint/datasources_endpoint.py | 2 +- tableauserverclient/server/endpoint/flows_endpoint.py | 2 +- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index be3733d67..7a797cf4c 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -438,7 +438,7 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() - m['Content-Disposition'] = server_response.headers["Content-Disposition"] + m["Content-Disposition"] = server_response.headers["Content-Disposition"] params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index a9b937ea5..5132ee454 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -121,7 +121,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() - m['Content-Disposition'] = server_response.headers["Content-Disposition"] + m["Content-Disposition"] = server_response.headers["Content-Disposition"] params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 73f69a145..58fa4fe98 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -484,7 +484,7 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() - m['Content-Disposition'] = server_response.headers["Content-Disposition"] + m["Content-Disposition"] = server_response.headers["Content-Disposition"] params = m.get_filename() if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB From 68b915774737083ecad5f365f1946a4ef778050f Mon Sep 17 00:00:00 2001 From: markm Date: Sat, 20 Jan 2024 01:44:20 -0600 Subject: [PATCH 04/73] Changes to alter cgi dependency to email.Messages --- tableauserverclient/server/endpoint/datasources_endpoint.py | 2 +- tableauserverclient/server/endpoint/flows_endpoint.py | 2 +- tableauserverclient/server/endpoint/workbooks_endpoint.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 7a797cf4c..28226d280 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -439,7 +439,7 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename() + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 5132ee454..77b01c478 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -122,7 +122,7 @@ def download(self, flow_id: str, filepath: Optional[PathOrFileW] = None) -> Path with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename() + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 58fa4fe98..393a028c8 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -485,7 +485,7 @@ def download_revision( with closing(self.get_request(url, parameters={"stream": True})) as server_response: m = Message() m["Content-Disposition"] = server_response.headers["Content-Disposition"] - params = m.get_filename() + params = m.get_filename(failobj="") if isinstance(filepath, io_types_w): for chunk in server_response.iter_content(1024): # 1KB filepath.write(chunk) From 5611859114abb76b2ef921330980d73b6d2c9b7d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 18 Jan 2024 22:16:14 -0600 Subject: [PATCH 05/73] feat: allow viz height and width parameters --- .../models/property_decorators.py | 8 +++-- tableauserverclient/server/request_options.py | 33 ++++++++++++++++++- test/test_view.py | 29 ++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 7c801a4b5..6ffcf6f85 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,6 +1,8 @@ +from collections.abc import Container import datetime import re from functools import wraps +from typing import Any, Optional from tableauserverclient.datetime_helpers import parse_datetime @@ -65,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range, allowed=None): +def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. @@ -89,8 +91,10 @@ def wrapper(self, value): raise ValueError(error) min, max = range + if value in allowed: + return func(self, value) - if (value < min or value > max) and (value not in allowed): + if value < min or value > max: raise ValueError(error) return func(self, value) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 95233f8fc..f2bd3c939 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,3 +1,5 @@ +import sys + from tableauserverclient.models.property_decorators import property_is_int import logging @@ -261,11 +263,13 @@ class Orientation: Portrait = "portrait" Landscape = "landscape" - def __init__(self, page_type=None, orientation=None, maxage=-1): + def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None): super(PDFRequestOptions, self).__init__() self.page_type = page_type self.orientation = orientation self.max_age = maxage + self.viz_height = viz_height + self.viz_width = viz_width @property def max_age(self): @@ -276,6 +280,24 @@ def max_age(self): def max_age(self, value): self._max_age = value + @property + def viz_height(self): + return self._viz_height + + @viz_height.setter + @property_is_int(range=(0, sys.maxsize), allowed=(None,)) + def viz_height(self, value): + self._viz_height = value + + @property + def viz_width(self): + return self._viz_width + + @viz_width.setter + @property_is_int(range=(0, sys.maxsize), allowed=(None,)) + def viz_width(self, value): + self._viz_width = value + def get_query_params(self): params = {} if self.page_type: @@ -287,6 +309,15 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + if (self.viz_height is None) ^ (self.viz_width is None): + raise ValueError("viz_height and viz_width must be specified together") + + if self.viz_height is not None: + params["vizHeight"] = self.viz_height + + if self.viz_width is not None: + params["vizWidth"] = self.viz_width + self._append_view_filters(params) return params diff --git a/test/test_view.py b/test/test_view.py index 1459150bb..720a0ce64 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -315,3 +315,32 @@ def test_filter_excel(self) -> None: excel_file = b"".join(single_view.excel) self.assertEqual(response, excel_file) + + def test_pdf_height(self) -> None: + self.server.version = "3.8" + self.baseurl = self.server.views.baseurl + with open(POPULATE_PDF, "rb") as f: + response = f.read() + with requests_mock.mock() as m: + m.get( + self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920", + content=response, + ) + single_view = TSC.ViewItem() + single_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5" + + req_option = TSC.PDFRequestOptions( + viz_height=1080, + viz_width=1920, + ) + + self.server.views.populate_pdf(single_view, req_option) + self.assertEqual(response, single_view.pdf) + + def test_pdf_errors(self) -> None: + req_option = TSC.PDFRequestOptions(viz_height=1080) + with self.assertRaises(ValueError): + req_option.get_query_params() + req_option = TSC.PDFRequestOptions(viz_width=1920) + with self.assertRaises(ValueError): + req_option.get_query_params() From 8ad3c03b89a3851a780dc57bd7e5a4f2970c608c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jan 2024 21:04:14 -0600 Subject: [PATCH 06/73] fix: use python3.8 syntax --- tableauserverclient/models/property_decorators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 6ffcf6f85..ea781cd51 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -2,7 +2,7 @@ import datetime import re from functools import wraps -from typing import Any, Optional +from typing import Any, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime @@ -67,7 +67,7 @@ def wrapper(self, value): return wrapper -def property_is_int(range: tuple[int, int], allowed: Optional[Container[Any]] = None): +def property_is_int(range: Tuple[int, int], allowed: Optional[Container[Any]] = None): """Takes a range of ints and a list of exemptions to check against when setting a property on a model. The range is a tuple of (min, max) and the allowed list (empty by default) allows values outside that range. From 7e44b5ec47b777cd43e2725be2019892d6e4d31a Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 23 Jan 2024 21:06:43 -0600 Subject: [PATCH 07/73] fix: python3.8 syntax --- tableauserverclient/models/property_decorators.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index ea781cd51..58c33699b 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -1,8 +1,7 @@ -from collections.abc import Container import datetime import re from functools import wraps -from typing import Any, Optional, Tuple +from typing import Any, Container, Optional, Tuple from tableauserverclient.datetime_helpers import parse_datetime From ffd0b8fd8452ec8fcaf78a03a838d8670256ba02 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 24 Jan 2024 07:30:24 -0600 Subject: [PATCH 08/73] docs: comment PDF viz dimensions XOR --- tableauserverclient/server/request_options.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index f2bd3c939..8304b8f68 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -309,6 +309,7 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + # XOR. Either both are None or both are not None. if (self.viz_height is None) ^ (self.viz_width is None): raise ValueError("viz_height and viz_width must be specified together") From 9ddbad56b8f9fff464f25f8262d97d01e67a8563 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Thu, 1 Feb 2024 15:58:16 -0800 Subject: [PATCH 09/73] Add support for System schedule type I'm not fully clear on where these might come from, but this change should let TSC work in such cases. Fixes #1349 --- tableauserverclient/models/schedule_item.py | 1 + test/assets/schedule_get.xml | 1 + test/test_schedule.py | 10 ++++++++++ 3 files changed, 12 insertions(+) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index db187a5f9..e416643ba 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -26,6 +26,7 @@ class Type: Subscription = "Subscription" DataAcceleration = "DataAcceleration" ActiveDirectorySync = "ActiveDirectorySync" + System = "System" class ExecutionOrder: Parallel = "Parallel" diff --git a/test/assets/schedule_get.xml b/test/assets/schedule_get.xml index 66e4d6e51..db5e1a05e 100644 --- a/test/assets/schedule_get.xml +++ b/test/assets/schedule_get.xml @@ -5,5 +5,6 @@ + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 76c8720b9..3bbf5709b 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -50,6 +50,7 @@ def test_get(self) -> None: extract = all_schedules[0] subscription = all_schedules[1] flow = all_schedules[2] + system = all_schedules[3] self.assertEqual(2, pagination_item.total_available) self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", extract.id) @@ -79,6 +80,15 @@ def test_get(self) -> None: self.assertEqual("Flow", flow.schedule_type) self.assertEqual("2019-03-01T09:00:00Z", format_datetime(flow.next_run_at)) + self.assertEqual("3cfa4713-ce7c-4fa7-aa2e-f752bfc8dd04", system.id) + self.assertEqual("First of the month 2:00AM", system.name) + self.assertEqual("Active", system.state) + self.assertEqual(30, system.priority) + self.assertEqual("2019-02-19T18:52:19Z", format_datetime(system.created_at)) + self.assertEqual("2019-02-19T18:55:51Z", format_datetime(system.updated_at)) + self.assertEqual("System", system.schedule_type) + self.assertEqual("2019-03-01T09:00:00Z", format_datetime(system.next_run_at)) + def test_get_empty(self) -> None: with open(GET_EMPTY_XML, "rb") as f: response_xml = f.read().decode("utf-8") From 60fa87f07d54cdc635c06614a9f0455675bbb973 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 Feb 2024 20:18:21 -0800 Subject: [PATCH 10/73] Add failing test retrieving a task with 24 hour (aka daily) interval --- test/assets/tasks_with_interval.xml | 20 ++++++++++++++++++++ test/test_task.py | 10 ++++++++++ 2 files changed, 30 insertions(+) create mode 100644 test/assets/tasks_with_interval.xml diff --git a/test/assets/tasks_with_interval.xml b/test/assets/tasks_with_interval.xml new file mode 100644 index 000000000..a317408fb --- /dev/null +++ b/test/assets/tasks_with_interval.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_task.py b/test/test_task.py index 4e0157dfd..53da7c160 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -19,6 +19,7 @@ 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" +GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml" class TaskTests(unittest.TestCase): @@ -97,6 +98,15 @@ def test_get_task_without_schedule(self): self.assertEqual("c7a9327e-1cda-4504-b026-ddb43b976d1d", task.target.id) self.assertEqual("datasource", task.target.type) + def test_get_task_with_interval(self): + with requests_mock.mock() as m: + m.get(self.baseurl, text=GET_XML_WITH_INTERVAL.read_text()) + all_tasks, pagination_item = self.server.tasks.get() + + task = all_tasks[0] + self.assertEqual("e4de0575-fcc7-4232-5659-be09bb8e7654", 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 0dca1aae66703fb932f364bee9cdd899a9cc51ee Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Tue, 13 Feb 2024 22:38:55 -0800 Subject: [PATCH 11/73] Add 24 (hours) as a valid interval which can be returned from the server --- tableauserverclient/models/interval_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py index 537e6c14f..3ee1fee08 100644 --- a/tableauserverclient/models/interval_item.py +++ b/tableauserverclient/models/interval_item.py @@ -136,7 +136,7 @@ def interval(self): @interval.setter def interval(self, intervals): - VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12} + VALID_INTERVALS = {0.25, 0.5, 1, 2, 4, 6, 8, 12, 24} for interval in intervals: # if an hourly interval is a string, then it is a weekDay interval From 3cc0f8ee57fcb0d9ffa1adb7bb7b62b70c54e0f5 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 14 Feb 2024 11:17:32 -0800 Subject: [PATCH 12/73] Add Python 3.12 to test matrix --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 6b1629bfd..fb89d5de1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] runs-on: ${{ matrix.os }} From 0fb214e22b2aac6d4bab54d17a43e80851e66e93 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Wed, 14 Feb 2024 11:45:21 -0800 Subject: [PATCH 13/73] Tweak test action to stop double-running everything --- .github/workflows/run-tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index fb89d5de1..d70539582 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -1,6 +1,11 @@ name: Python tests -on: [push, pull_request] +on: + pull_request: {} + push: + branches: + - development + - master jobs: build: From 0ddae7ce24c457b522c87867531b91213263f7f1 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 14 Feb 2024 21:15:45 -0600 Subject: [PATCH 14/73] feat: add description support on wb publish --- tableauserverclient/models/workbook_item.py | 4 ++++ tableauserverclient/server/request_factory.py | 3 +++ test/assets/workbook_publish.xml | 4 ++-- test/test_workbook.py | 3 +++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 86a9a2f18..57ddf83f8 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -91,6 +91,10 @@ def created_at(self) -> Optional[datetime.datetime]: def description(self) -> Optional[str]: return self._description + @description.setter + def description(self, value: str): + self._description = value + @property def id(self) -> Optional[str]: return self._id diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6316527ec..70d2b30fc 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -911,6 +911,9 @@ def _generate_xml( for connection in connections: _add_connections_element(connections_element, connection) + if workbook_item.description is not None: + workbook_element.attrib["description"] = workbook_item.description + if hidden_views is not None: import warnings diff --git a/test/assets/workbook_publish.xml b/test/assets/workbook_publish.xml index dcfc79936..3e23bda71 100644 --- a/test/assets/workbook_publish.xml +++ b/test/assets/workbook_publish.xml @@ -1,6 +1,6 @@ - + @@ -8,4 +8,4 @@ - \ No newline at end of file + diff --git a/test/test_workbook.py b/test/test_workbook.py index 212d55a37..ac3d44b28 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -488,6 +488,8 @@ def test_publish(self) -> None: name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" ) + new_workbook.description = "REST API Testing" + sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") publish_mode = self.server.PublishMode.CreateNew @@ -506,6 +508,7 @@ def test_publish(self) -> None: self.assertEqual("fe0b4e89-73f4-435e-952d-3a263fbfa56c", new_workbook.views[0].id) self.assertEqual("GDP per capita", new_workbook.views[0].name) self.assertEqual("RESTAPISample_0/sheets/GDPpercapita", new_workbook.views[0].content_url) + self.assertEqual("REST API Testing", new_workbook.description) def test_publish_a_packaged_file_object(self) -> None: with open(PUBLISH_XML, "rb") as f: From eaedc29fe6a16a2060b3dbe32f9fa047f48b9994 Mon Sep 17 00:00:00 2001 From: ltiffanydev <148500608+ltiffanydev@users.noreply.github.com> Date: Mon, 4 Mar 2024 22:21:39 -0800 Subject: [PATCH 15/73] Add Data Acceleration and Data Freshness Policy support (#1343) * Add data acceleration & data freshness policy functions * Add unit tests and raise errors on missing params * fix types & spell checks * addressed some feedback * addressed feedback * cleanup code * Revert "Merge branch 'add_data_acceleration_and_data_freshness_policy_support' of https://github.com/tableau/server-client-python into add_data_acceleration_and_data_freshness_policy_support" This reverts commit 5b30e57d959ae80b8279d7eeb2e4f374fc111664, reversing changes made to 5789e32bd57f4459209da05003f1ccf4e93e01a1. * fix formatting * Address feedback * mypy & formatting changes --- samples/update_workbook_data_acceleration.py | 109 +++++++++ .../update_workbook_data_freshness_policy.py | 218 ++++++++++++++++++ tableauserverclient/__init__.py | 1 + tableauserverclient/models/__init__.py | 1 + .../models/data_freshness_policy_item.py | 210 +++++++++++++++++ .../models/property_decorators.py | 10 +- tableauserverclient/models/view_item.py | 34 +++ tableauserverclient/models/workbook_item.py | 34 ++- .../server/endpoint/workbooks_endpoint.py | 10 +- tableauserverclient/server/request_factory.py | 58 ++++- ...workbook_get_by_id_acceleration_status.xml | 19 ++ .../workbook_update_acceleration_status.xml | 16 ++ .../workbook_update_data_freshness_policy.xml | 9 + ...workbook_update_data_freshness_policy2.xml | 9 + ...workbook_update_data_freshness_policy3.xml | 11 + ...workbook_update_data_freshness_policy4.xml | 12 + ...workbook_update_data_freshness_policy5.xml | 16 ++ ...workbook_update_data_freshness_policy6.xml | 15 ++ ...kbook_update_views_acceleration_status.xml | 19 ++ test/test_data_freshness_policy.py | 189 +++++++++++++++ test/test_view_acceleration.py | 119 ++++++++++ 21 files changed, 1101 insertions(+), 18 deletions(-) create mode 100644 samples/update_workbook_data_acceleration.py create mode 100644 samples/update_workbook_data_freshness_policy.py create mode 100644 tableauserverclient/models/data_freshness_policy_item.py create mode 100644 test/assets/workbook_get_by_id_acceleration_status.xml create mode 100644 test/assets/workbook_update_acceleration_status.xml create mode 100644 test/assets/workbook_update_data_freshness_policy.xml create mode 100644 test/assets/workbook_update_data_freshness_policy2.xml create mode 100644 test/assets/workbook_update_data_freshness_policy3.xml create mode 100644 test/assets/workbook_update_data_freshness_policy4.xml create mode 100644 test/assets/workbook_update_data_freshness_policy5.xml create mode 100644 test/assets/workbook_update_data_freshness_policy6.xml create mode 100644 test/assets/workbook_update_views_acceleration_status.xml create mode 100644 test/test_data_freshness_policy.py create mode 100644 test/test_view_acceleration.py diff --git a/samples/update_workbook_data_acceleration.py b/samples/update_workbook_data_acceleration.py new file mode 100644 index 000000000..75f12262f --- /dev/null +++ b/samples/update_workbook_data_acceleration.py @@ -0,0 +1,109 @@ +#### +# This script demonstrates how to update workbook data acceleration using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC +from tableauserverclient import IntervalItem + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token used to sign into the server") + parser.add_argument("--token-value", "-v", help="value of the personal access token used to sign into the server") + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Get workbook + all_workbooks, pagination_item = server.workbooks.get() + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick 1 workbook to try data acceleration. + # Note that data acceleration has a couple of requirements, please check the Tableau help page + # to verify your workbook/view is eligible for data acceleration. + + # Assuming 1st workbook is eligible for sample purposes + sample_workbook = all_workbooks[2] + + # Enable acceleration for all the views in the workbook + enable_config = dict() + enable_config["acceleration_enabled"] = True + enable_config["accelerate_now"] = True + + sample_workbook.data_acceleration_config = enable_config + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + # Since we did not set any specific view, we will enable all views in the workbook + print("Enable acceleration for all the views in the workbook " + updated.name + ".") + + # Disable acceleration on one of the view in the workbook + # You have to populate_views first, then set the views of the workbook + # to the ones you want to update. + server.workbooks.populate_views(sample_workbook) + view_to_disable = sample_workbook.views[0] + sample_workbook.views = [view_to_disable] + + disable_config = dict() + disable_config["acceleration_enabled"] = False + disable_config["accelerate_now"] = True + + sample_workbook.data_acceleration_config = disable_config + # To get the acceleration status on the response, set includeViewAccelerationStatus=true + # Note that you have to populate_views first to get the acceleration status, since + # acceleration status is per view basis (not per workbook) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook, True) + view1 = updated.views[0] + print('Disabled acceleration for 1 view "' + view1.name + '" in the workbook ' + updated.name + ".") + + # Get acceleration status of the views in workbook using workbooks.get_by_id + # This won't need to do populate_views beforehand + my_workbook = server.workbooks.get_by_id(sample_workbook.id) + view1 = my_workbook.views[0] + view2 = my_workbook.views[1] + print( + "Fetching acceleration status for views in the workbook " + + updated.name + + ".\n" + + 'View "' + + view1.name + + '" has acceleration_status = ' + + view1.data_acceleration_config["acceleration_status"] + + ".\n" + + 'View "' + + view2.name + + '" has acceleration_status = ' + + view2.data_acceleration_config["acceleration_status"] + + "." + ) + + +if __name__ == "__main__": + main() diff --git a/samples/update_workbook_data_freshness_policy.py b/samples/update_workbook_data_freshness_policy.py new file mode 100644 index 000000000..9e4d63dc1 --- /dev/null +++ b/samples/update_workbook_data_freshness_policy.py @@ -0,0 +1,218 @@ +#### +# This script demonstrates how to update workbook data freshness policy using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +import tableauserverclient as TSC +from tableauserverclient import IntervalItem + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample schedules for each type of frequency.") + # Common options; please keep those in sync across all samples + parser.add_argument("--server", "-s", help="server address") + parser.add_argument("--site", "-S", help="site name") + parser.add_argument("--token-name", "-p", help="name of the personal access token " "used to sign into the server") + parser.add_argument( + "--token-value", "-v", help="value of the personal access token " "used to sign into the server" + ) + parser.add_argument( + "--logging-level", + "-l", + choices=["debug", "info", "error"], + default="error", + help="desired logging level (set to error by default)", + ) + # Options specific to this sample: + # This sample has no additional options, yet. If you add some, please add them here + + args = parser.parse_args() + + # Set logging level based on user input, or error by default + logging_level = getattr(logging, args.logging_level.upper()) + logging.basicConfig(level=logging_level) + + tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, args.token_value, site_id=args.site) + server = TSC.Server(args.server, use_server_version=False) + server.add_http_options({"verify": False}) + server.use_server_version() + with server.auth.sign_in(tableau_auth): + # Get workbook + all_workbooks, pagination_item = server.workbooks.get() + print("\nThere are {} workbooks on site: ".format(pagination_item.total_available)) + print([workbook.name for workbook in all_workbooks]) + + if all_workbooks: + # Pick 1 workbook that has live datasource connection. + # Assuming 1st workbook met the criteria for sample purposes + # Data Freshness Policy is not available on extract & file-based datasource. + sample_workbook = all_workbooks[2] + + # Get more info from the workbook selected + # Troubleshoot: if sample_workbook_extended.data_freshness_policy.option returns with AttributeError + # it could mean the workbook selected does not have live connection, which means it doesn't have + # data freshness policy. Change to another workbook with live datasource connection. + sample_workbook_extended = server.workbooks.get_by_id(sample_workbook.id) + try: + print( + "Workbook " + + sample_workbook.name + + " has data freshness policy option set to: " + + sample_workbook_extended.data_freshness_policy.option + ) + except AttributeError as e: + print( + "Workbook does not have data freshness policy, possibly due to the workbook selected " + "does not have live connection. Change to another workbook using live datasource connection." + ) + + # Update Workbook Data Freshness Policy to "AlwaysLive" + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.AlwaysLive + ) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + ) + + # Update Workbook Data Freshness Policy to "SiteDefault" + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.SiteDefault + ) + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + ) + + # Update Workbook Data Freshness Policy to "FreshEvery" schedule. + # Set the schedule to be fresh every 10 hours + # Once the data_freshness_policy is already populated (e.g. due to previous calls), + # it is possible to directly change the option & other parameters directly like below + sample_workbook.data_freshness_policy.option = TSC.DataFreshnessPolicyItem.Option.FreshEvery + fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery( + TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10 + ) + sample_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(updated.data_freshness_policy.fresh_every_schedule.value) + + " " + + updated.data_freshness_policy.fresh_every_schedule.frequency + ) + + # Update Workbook Data Freshness Policy to "FreshAt" schedule. + # Set the schedule to be fresh at 10AM every day + sample_workbook.data_freshness_policy.option = TSC.DataFreshnessPolicyItem.Option.FreshAt + fresh_at_ten_daily = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "10:00:00", "America/Los_Angeles" + ) + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_ten_daily + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(updated.data_freshness_policy.fresh_at_schedule.time) + + " every " + + updated.data_freshness_policy.fresh_at_schedule.frequency + ) + + # Set the schedule to be fresh at 6PM every week on Wednesday and Sunday + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_6pm_wed_sun = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week, + "18:00:00", + "America/Los_Angeles", + [IntervalItem.Day.Wednesday, "Sunday"], + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_6pm_wed_sun + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + new_fresh_at_schedule.interval_item[0] + + "," + + new_fresh_at_schedule.interval_item[1] + ) + + # Set the schedule to be fresh at 12AM every last day of the month + sample_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_last_day_of_month = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"] + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_last_day_of_month + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + new_fresh_at_schedule.interval_item[0] + ) + + # Set the schedule to be fresh at 8PM every 1st,13th,20th day of the month + fresh_at_dates_of_month = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, + "00:00:00", + "America/Los_Angeles", + ["1", "13", "20"], + ) + + sample_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_dates_of_month + updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook) + new_fresh_at_schedule = updated.data_freshness_policy.fresh_at_schedule + print( + "Workbook " + + updated.name + + " updated data freshness policy option to: " + + updated.data_freshness_policy.option + + " with frequency of " + + str(new_fresh_at_schedule.time) + + " every " + + new_fresh_at_schedule.frequency + + " on " + + str(new_fresh_at_schedule.interval_item) + ) + + +if __name__ == "__main__": + main() diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index c5c3c1922..f093f521b 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -10,6 +10,7 @@ DailyInterval, DataAlertItem, DatabaseItem, + DataFreshnessPolicyItem, DatasourceItem, FavoriteItem, FlowItem, diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 03d692583..e7a853d9a 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -5,6 +5,7 @@ from .data_acceleration_report_item import DataAccelerationReportItem from .data_alert_item import DataAlertItem from .database_item import DatabaseItem +from .data_freshness_policy_item import DataFreshnessPolicyItem from .datasource_item import DatasourceItem from .dqw_item import DQWItem from .exceptions import UnpopulatedPropertyError diff --git a/tableauserverclient/models/data_freshness_policy_item.py b/tableauserverclient/models/data_freshness_policy_item.py new file mode 100644 index 000000000..f567c501c --- /dev/null +++ b/tableauserverclient/models/data_freshness_policy_item.py @@ -0,0 +1,210 @@ +import xml.etree.ElementTree as ET + +from typing import Optional, Union, List +from tableauserverclient.models.property_decorators import property_is_enum, property_not_nullable +from .interval_item import IntervalItem + + +class DataFreshnessPolicyItem: + class Option: + AlwaysLive = "AlwaysLive" + SiteDefault = "SiteDefault" + FreshEvery = "FreshEvery" + FreshAt = "FreshAt" + + class FreshEvery: + class Frequency: + Minutes = "Minutes" + Hours = "Hours" + Days = "Days" + Weeks = "Weeks" + + def __init__(self, frequency: str, value: int): + self.frequency: str = frequency + self.value: int = value + + def __repr__(self): + return "".format(**vars(self)) + + @property + def frequency(self) -> str: + return self._frequency + + @frequency.setter + @property_is_enum(Frequency) + def frequency(self, value: str): + self._frequency = value + + @classmethod + def from_xml_element(cls, fresh_every_schedule_elem: ET.Element): + frequency = fresh_every_schedule_elem.get("frequency", None) + value_str = fresh_every_schedule_elem.get("value", None) + if (frequency is None) or (value_str is None): + return None + value = int(value_str) + return DataFreshnessPolicyItem.FreshEvery(frequency, value) + + class FreshAt: + class Frequency: + Day = "Day" + Week = "Week" + Month = "Month" + + def __init__(self, frequency: str, time: str, timezone, interval_item: Optional[List[str]] = None): + self.frequency = frequency + self.time = time + self.timezone = timezone + self.interval_item: Optional[List[str]] = interval_item + + def __repr__(self): + return ( + " timezone={_timezone} " "interval_item={_interval_time}" + ).format(**vars(self)) + + @property + def interval_item(self) -> Optional[List[str]]: + return self._interval_item + + @interval_item.setter + def interval_item(self, value: List[str]): + self._interval_item = value + + @property + def time(self): + return self._time + + @time.setter + @property_not_nullable + def time(self, value): + self._time = value + + @property + def timezone(self) -> str: + return self._timezone + + @timezone.setter + def timezone(self, value: str): + self._timezone = value + + @property + def frequency(self) -> str: + return self._frequency + + @frequency.setter + @property_is_enum(Frequency) + def frequency(self, value: str): + self._frequency = value + + @classmethod + def from_xml_element(cls, fresh_at_schedule_elem: ET.Element, ns): + frequency = fresh_at_schedule_elem.get("frequency", None) + time = fresh_at_schedule_elem.get("time", None) + if (frequency is None) or (time is None): + return None + timezone = fresh_at_schedule_elem.get("timezone", None) + interval = parse_intervals(fresh_at_schedule_elem, frequency, ns) + return DataFreshnessPolicyItem.FreshAt(frequency, time, timezone, interval) + + def __init__(self, option: str): + self.option = option + self.fresh_every_schedule: Optional[DataFreshnessPolicyItem.FreshEvery] = None + self.fresh_at_schedule: Optional[DataFreshnessPolicyItem.FreshAt] = None + + def __repr__(self): + return "".format(**vars(self)) + + @property + def option(self) -> str: + return self._option + + @option.setter + @property_is_enum(Option) + def option(self, value: str): + self._option = value + + @property + def fresh_every_schedule(self) -> Optional[FreshEvery]: + return self._fresh_every_schedule + + @fresh_every_schedule.setter + def fresh_every_schedule(self, value: FreshEvery): + self._fresh_every_schedule = value + + @property + def fresh_at_schedule(self) -> Optional[FreshAt]: + return self._fresh_at_schedule + + @fresh_at_schedule.setter + def fresh_at_schedule(self, value: FreshAt): + self._fresh_at_schedule = value + + @classmethod + def from_xml_element(cls, data_freshness_policy_elem, ns): + option = data_freshness_policy_elem.get("option", None) + if option is None: + return None + data_freshness_policy = DataFreshnessPolicyItem(option) + + fresh_at_schedule = None + fresh_every_schedule = None + if option == "FreshAt": + fresh_at_schedule_elem = data_freshness_policy_elem.find(".//t:freshAtSchedule", namespaces=ns) + fresh_at_schedule = DataFreshnessPolicyItem.FreshAt.from_xml_element(fresh_at_schedule_elem, ns) + data_freshness_policy.fresh_at_schedule = fresh_at_schedule + elif option == "FreshEvery": + fresh_every_schedule_elem = data_freshness_policy_elem.find(".//t:freshEverySchedule", namespaces=ns) + fresh_every_schedule = DataFreshnessPolicyItem.FreshEvery.from_xml_element(fresh_every_schedule_elem) + data_freshness_policy.fresh_every_schedule = fresh_every_schedule + + return data_freshness_policy + + +def parse_intervals(intervals_elem, frequency, ns): + interval_elems = intervals_elem.findall(".//t:intervals/t:interval", namespaces=ns) + interval = [] + for interval_elem in interval_elems: + interval.extend(interval_elem.attrib.items()) + + # No intervals expected for Day frequency + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Day: + return None + + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Week: + interval_values = [(i[1]).title() for i in interval] + return parse_week_intervals(interval_values) + + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Month: + interval_values = [(i[1]) for i in interval] + return parse_month_intervals(interval_values) + + +def parse_week_intervals(interval_values): + # Using existing IntervalItem.Day to check valid weekday string + if not all(hasattr(IntervalItem.Day, day) for day in interval_values): + raise ValueError("Invalid week day defined " + str(interval_values)) + return interval_values + + +def parse_month_intervals(interval_values): + error = "Invalid interval value for a monthly frequency: {}.".format(interval_values) + + # Month interval can have value either only ['LastDay'] or list of dates e.g. ["1", 20", "30"] + # First check if the list only have LastDay value. When using LastDay, there shouldn't be + # any other values, hence checking the first element of the list is enough. + # If the value is not "LastDay", we assume intervals is on list of dates format. + # We created this function instead of using existing MonthlyInterval because we allow list of dates interval, + + intervals = [] + if interval_values[0] == "LastDay": + intervals.append(interval_values[0]) + else: + for interval in interval_values: + try: + if 1 <= int(interval) <= 31: + intervals.append(interval) + else: + raise ValueError(error) + except ValueError: + if interval_values[0] != "LastDay": + raise ValueError(error) + return intervals diff --git a/tableauserverclient/models/property_decorators.py b/tableauserverclient/models/property_decorators.py index 58c33699b..ce31b1428 100644 --- a/tableauserverclient/models/property_decorators.py +++ b/tableauserverclient/models/property_decorators.py @@ -147,15 +147,7 @@ def property_is_data_acceleration_config(func): def wrapper(self, value): if not isinstance(value, dict): raise ValueError("{} is not type 'dict', cannot update {})".format(value.__class__.__name__, func.__name__)) - if len(value) != 4 or not all( - attr in value.keys() - for attr in ( - "acceleration_enabled", - "accelerate_now", - "last_updated_at", - "acceleration_status", - ) - ): + if len(value) < 2 or not all(attr in value.keys() for attr in ("acceleration_enabled", "accelerate_now")): error = "{} should have 2 keys ".format(func.__name__) error += "'acceleration_enabled' and 'accelerate_now'" error += "instead you have {}".format(value.keys()) diff --git a/tableauserverclient/models/view_item.py b/tableauserverclient/models/view_item.py index 90cff490b..a26e364a3 100644 --- a/tableauserverclient/models/view_item.py +++ b/tableauserverclient/models/view_item.py @@ -31,6 +31,10 @@ def __init__(self) -> None: self._workbook_id: Optional[str] = None self._permissions: Optional[Callable[[], List[PermissionsRule]]] = None self.tags: Set[str] = set() + self._data_acceleration_config = { + "acceleration_enabled": None, + "acceleration_status": None, + } def __str__(self): return "".format( @@ -133,6 +137,14 @@ def updated_at(self) -> Optional[datetime]: def workbook_id(self) -> Optional[str]: return self._workbook_id + @property + def data_acceleration_config(self): + return self._data_acceleration_config + + @data_acceleration_config.setter + def data_acceleration_config(self, value): + self._data_acceleration_config = value + @property def permissions(self) -> List[PermissionsRule]: if self._permissions is None: @@ -164,6 +176,7 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": owner_elem = view_xml.find(".//t:owner", namespaces=ns) project_elem = view_xml.find(".//t:project", namespaces=ns) tags_elem = view_xml.find(".//t:tags", namespaces=ns) + data_acceleration_config_elem = view_xml.find(".//t:dataAccelerationConfig", namespaces=ns) view_item._created_at = parse_datetime(view_xml.get("createdAt", None)) view_item._updated_at = parse_datetime(view_xml.get("updatedAt", None)) view_item._id = view_xml.get("id", None) @@ -186,4 +199,25 @@ def from_xml(cls, view_xml, ns, workbook_id="") -> "ViewItem": tags = TagItem.from_xml_element(tags_elem, ns) view_item.tags = tags view_item._initial_tags = copy.copy(tags) + if data_acceleration_config_elem is not None: + data_acceleration_config = parse_data_acceleration_config(data_acceleration_config_elem) + view_item.data_acceleration_config = data_acceleration_config return view_item + + +def parse_data_acceleration_config(data_acceleration_elem): + data_acceleration_config = dict() + + acceleration_enabled = data_acceleration_elem.get("accelerationEnabled", None) + if acceleration_enabled is not None: + acceleration_enabled = string_to_bool(acceleration_enabled) + + acceleration_status = data_acceleration_elem.get("accelerationStatus", None) + + data_acceleration_config["acceleration_enabled"] = acceleration_enabled + data_acceleration_config["acceleration_status"] = acceleration_status + return data_acceleration_config + + +def string_to_bool(s: str) -> bool: + return s.lower() == "true" diff --git a/tableauserverclient/models/workbook_item.py b/tableauserverclient/models/workbook_item.py index 57ddf83f8..58fd2a9a9 100644 --- a/tableauserverclient/models/workbook_item.py +++ b/tableauserverclient/models/workbook_item.py @@ -17,6 +17,7 @@ from .revision_item import RevisionItem from .tag_item import TagItem from .view_item import ViewItem +from .data_freshness_policy_item import DataFreshnessPolicyItem class WorkbookItem(object): @@ -34,7 +35,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, self._revisions = None self._size = None self._updated_at = None - self._views = None + self._views: Optional[Callable[[], List[ViewItem]]] = None self.name = name self._description = None self.owner_id: Optional[str] = None @@ -49,6 +50,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None, "last_updated_at": None, "acceleration_status": None, } + self.data_freshness_policy = None self._permissions = None return None @@ -166,6 +168,10 @@ def views(self) -> List[ViewItem]: # We had views included in a WorkbookItem response return self._views + @views.setter + def views(self, value): + self._views = value + @property def data_acceleration_config(self): return self._data_acceleration_config @@ -175,6 +181,15 @@ def data_acceleration_config(self): def data_acceleration_config(self, value): self._data_acceleration_config = value + @property + def data_freshness_policy(self): + return self._data_freshness_policy + + @data_freshness_policy.setter + # @property_is_data_freshness_policy + def data_freshness_policy(self, value): + self._data_freshness_policy = value + @property def revisions(self) -> List[RevisionItem]: if self._revisions is None: @@ -221,8 +236,9 @@ def _parse_common_tags(self, workbook_xml, ns): project_name, owner_id, _, - _, + views, data_acceleration_config, + data_freshness_policy, ) = self._parse_element(workbook_xml, ns) self._set_values( @@ -239,8 +255,9 @@ def _parse_common_tags(self, workbook_xml, ns): project_name, owner_id, None, - None, + views, data_acceleration_config, + data_freshness_policy, ) return self @@ -262,6 +279,7 @@ def _set_values( tags, views, data_acceleration_config, + data_freshness_policy, ): if id is not None: self._id = id @@ -290,10 +308,12 @@ def _set_values( if tags: self.tags = tags self._initial_tags = copy.copy(tags) - if views: + if views is not None: self._views = views if data_acceleration_config is not None: self.data_acceleration_config = data_acceleration_config + if data_freshness_policy is not None: + self.data_freshness_policy = data_freshness_policy @classmethod def from_response(cls, resp: str, ns: Dict[str, str]) -> List["WorkbookItem"]: @@ -360,6 +380,11 @@ def _parse_element(workbook_xml, ns): if data_acceleration_elem is not None: data_acceleration_config = parse_data_acceleration_config(data_acceleration_elem) + data_freshness_policy = None + data_freshness_policy_elem = workbook_xml.find(".//t:dataFreshnessPolicy", namespaces=ns) + if data_freshness_policy_elem is not None: + data_freshness_policy = DataFreshnessPolicyItem.from_xml_element(data_freshness_policy_elem, ns) + return ( id, name, @@ -376,6 +401,7 @@ def _parse_element(workbook_xml, ns): tags, views, data_acceleration_config, + data_freshness_policy, ) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 393a028c8..bc535b2d6 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -137,7 +137,12 @@ def delete(self, workbook_id: str) -> None: # Update workbook @api(version="2.0") - def update(self, workbook_item: WorkbookItem) -> WorkbookItem: + @parameter_added_in(include_view_acceleration_status="3.22") + def update( + self, + workbook_item: WorkbookItem, + include_view_acceleration_status: bool = False, + ) -> WorkbookItem: if not workbook_item.id: error = "Workbook item missing ID. Workbook must be retrieved from server first." raise MissingRequiredFieldError(error) @@ -146,6 +151,9 @@ def update(self, workbook_item: WorkbookItem) -> WorkbookItem: # Update the workbook itself url = "{0}/{1}".format(self.baseurl, workbook_item.id) + if include_view_acceleration_status: + url += "?includeViewAccelerationStatus=True" + update_req = RequestFactory.Workbook.update_req(workbook_item) server_response = self.put_request(url, update_req) logger.info("Updated workbook item (ID: {0})".format(workbook_item.id)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 70d2b30fc..1f6dfbfc6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -57,6 +57,11 @@ def _add_hiddenview_element(views_element, view_name): view_element.attrib["hidden"] = "true" +def _add_view_element(views_element, view_id): + view_element = ET.SubElement(views_element, "view") + view_element.attrib["id"] = view_id + + def _add_credentials_element(parent_element, connection_credentials): credentials_element = ET.SubElement(parent_element, "connectionCredentials") if connection_credentials.password is None or connection_credentials.name is None: @@ -944,16 +949,61 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, "owner") owner_element.attrib["id"] = workbook_item.owner_id - if workbook_item.data_acceleration_config["acceleration_enabled"] is not None: + if workbook_item._views is not None: + views_element = ET.SubElement(workbook_element, "views") + for view in workbook_item.views: + _add_view_element(views_element, view.id) + if workbook_item.data_acceleration_config: data_acceleration_config = workbook_item.data_acceleration_config data_acceleration_element = ET.SubElement(workbook_element, "dataAccelerationConfig") - data_acceleration_element.attrib["accelerationEnabled"] = str( - data_acceleration_config["acceleration_enabled"] - ).lower() + if data_acceleration_config["acceleration_enabled"] is not None: + data_acceleration_element.attrib["accelerationEnabled"] = str( + data_acceleration_config["acceleration_enabled"] + ).lower() if data_acceleration_config["accelerate_now"] is not None: data_acceleration_element.attrib["accelerateNow"] = str( data_acceleration_config["accelerate_now"] ).lower() + if workbook_item.data_freshness_policy is not None: + data_freshness_policy_config = workbook_item.data_freshness_policy + data_freshness_policy_element = ET.SubElement(workbook_element, "dataFreshnessPolicy") + data_freshness_policy_element.attrib["option"] = str(data_freshness_policy_config.option) + # Fresh Every Schedule + if data_freshness_policy_config.option == "FreshEvery": + if data_freshness_policy_config.fresh_every_schedule is not None: + fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") + fresh_every_element.attrib[ + "frequency" + ] = data_freshness_policy_config.fresh_every_schedule.frequency + fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) + else: + raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") + # Fresh At Schedule + if data_freshness_policy_config.option == "FreshAt": + if data_freshness_policy_config.fresh_at_schedule is not None: + fresh_at_element = ET.SubElement(data_freshness_policy_element, "freshAtSchedule") + frequency = data_freshness_policy_config.fresh_at_schedule.frequency + fresh_at_element.attrib["frequency"] = frequency + fresh_at_element.attrib["time"] = str(data_freshness_policy_config.fresh_at_schedule.time) + fresh_at_element.attrib["timezone"] = str(data_freshness_policy_config.fresh_at_schedule.timezone) + intervals = data_freshness_policy_config.fresh_at_schedule.interval_item + # Fresh At Schedule intervals if Frequency is Week or Month + if frequency != DataFreshnessPolicyItem.FreshAt.Frequency.Day: + if intervals is not None: + # if intervals is not None or frequency != DataFreshnessPolicyItem.FreshAt.Frequency.Day: + intervals_element = ET.SubElement(fresh_at_element, "intervals") + for interval in intervals: + expression = IntervalItem.Occurrence.WeekDay + if frequency == DataFreshnessPolicyItem.FreshAt.Frequency.Month: + expression = IntervalItem.Occurrence.MonthDay + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = interval + else: + raise ValueError( + f"fresh_at_schedule.interval_item must be populated for " f"Week & Month frequency." + ) + else: + raise ValueError(f"data_freshness_policy_config.fresh_at_schedule must be populated.") return ET.tostring(xml_request) diff --git a/test/assets/workbook_get_by_id_acceleration_status.xml b/test/assets/workbook_get_by_id_acceleration_status.xml new file mode 100644 index 000000000..0d1f9b93d --- /dev/null +++ b/test/assets/workbook_get_by_id_acceleration_status.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/test/assets/workbook_update_acceleration_status.xml b/test/assets/workbook_update_acceleration_status.xml new file mode 100644 index 000000000..7c3366fee --- /dev/null +++ b/test/assets/workbook_update_acceleration_status.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy.xml b/test/assets/workbook_update_data_freshness_policy.xml new file mode 100644 index 000000000..a69a097ba --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy2.xml b/test/assets/workbook_update_data_freshness_policy2.xml new file mode 100644 index 000000000..384f79ec0 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy2.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy3.xml b/test/assets/workbook_update_data_freshness_policy3.xml new file mode 100644 index 000000000..195013517 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy3.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy4.xml b/test/assets/workbook_update_data_freshness_policy4.xml new file mode 100644 index 000000000..8208d986a --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy4.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy5.xml b/test/assets/workbook_update_data_freshness_policy5.xml new file mode 100644 index 000000000..b6e0358b6 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy5.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_data_freshness_policy6.xml b/test/assets/workbook_update_data_freshness_policy6.xml new file mode 100644 index 000000000..c8be8f6c1 --- /dev/null +++ b/test/assets/workbook_update_data_freshness_policy6.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/workbook_update_views_acceleration_status.xml b/test/assets/workbook_update_views_acceleration_status.xml new file mode 100644 index 000000000..f2055fb79 --- /dev/null +++ b/test/assets/workbook_update_views_acceleration_status.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_data_freshness_policy.py b/test/test_data_freshness_policy.py new file mode 100644 index 000000000..9591a6380 --- /dev/null +++ b/test/test_data_freshness_policy.py @@ -0,0 +1,189 @@ +import os +import requests_mock +import unittest + +import tableauserverclient as TSC + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +UPDATE_DFP_ALWAYS_LIVE_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy.xml") +UPDATE_DFP_SITE_DEFAULT_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy2.xml") +UPDATE_DFP_FRESH_EVERY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy3.xml") +UPDATE_DFP_FRESH_AT_DAILY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy4.xml") +UPDATE_DFP_FRESH_AT_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy5.xml") +UPDATE_DFP_FRESH_AT_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_data_freshness_policy6.xml") + + +class WorkbookTests(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.workbooks.baseurl + + def test_update_DFP_always_live(self) -> None: + with open(UPDATE_DFP_ALWAYS_LIVE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.AlwaysLive + ) + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("AlwaysLive", single_workbook.data_freshness_policy.option) + + def test_update_DFP_site_default(self) -> None: + with open(UPDATE_DFP_SITE_DEFAULT_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.SiteDefault + ) + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("SiteDefault", single_workbook.data_freshness_policy.option) + + def test_update_DFP_fresh_every(self) -> None: + with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshEvery + ) + fresh_every_ten_hours = TSC.DataFreshnessPolicyItem.FreshEvery( + TSC.DataFreshnessPolicyItem.FreshEvery.Frequency.Hours, 10 + ) + single_workbook.data_freshness_policy.fresh_every_schedule = fresh_every_ten_hours + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshEvery", single_workbook.data_freshness_policy.option) + self.assertEqual("Hours", single_workbook.data_freshness_policy.fresh_every_schedule.frequency) + self.assertEqual(10, single_workbook.data_freshness_policy.fresh_every_schedule.value) + + def test_update_DFP_fresh_every_missing_attributes(self) -> None: + with open(UPDATE_DFP_FRESH_EVERY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshEvery + ) + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) + + def test_update_DFP_fresh_at_day(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_10pm_daily = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Day, "22:00:00", " Asia/Singapore" + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10pm_daily + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Day", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("22:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("Asia/Singapore", single_workbook.data_freshness_policy.fresh_at_schedule.timezone) + + def test_update_DFP_fresh_at_week(self) -> None: + with open(UPDATE_DFP_FRESH_AT_WEEKLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_10am_mon_wed = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Week, + "10:00:00", + "America/Los_Angeles", + ["Monday", "Wednesday"], + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_10am_mon_wed + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Week", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("10:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("Wednesday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0]) + self.assertEqual("Monday", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[1]) + + def test_update_DFP_fresh_at_month(self) -> None: + with open(UPDATE_DFP_FRESH_AT_MONTHLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_00am_lastDayOfMonth = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles", ["LastDay"] + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_00am_lastDayOfMonth + single_workbook = self.server.workbooks.update(single_workbook) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("FreshAt", single_workbook.data_freshness_policy.option) + self.assertEqual("Month", single_workbook.data_freshness_policy.fresh_at_schedule.frequency) + self.assertEqual("00:00:00", single_workbook.data_freshness_policy.fresh_at_schedule.time) + self.assertEqual("LastDay", single_workbook.data_freshness_policy.fresh_at_schedule.interval_item[0]) + + def test_update_DFP_fresh_at_missing_params(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) + + def test_update_DFP_fresh_at_missing_interval(self) -> None: + with open(UPDATE_DFP_FRESH_AT_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_freshness_policy = TSC.DataFreshnessPolicyItem( + TSC.DataFreshnessPolicyItem.Option.FreshAt + ) + fresh_at_month_no_interval = TSC.DataFreshnessPolicyItem.FreshAt( + TSC.DataFreshnessPolicyItem.FreshAt.Frequency.Month, "00:00:00", "America/Los_Angeles" + ) + single_workbook.data_freshness_policy.fresh_at_schedule = fresh_at_month_no_interval + + self.assertRaises(ValueError, self.server.workbooks.update, single_workbook) diff --git a/test/test_view_acceleration.py b/test/test_view_acceleration.py new file mode 100644 index 000000000..6f94f0c10 --- /dev/null +++ b/test/test_view_acceleration.py @@ -0,0 +1,119 @@ +import os +import requests_mock +import unittest + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import format_datetime + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_BY_ID_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_by_id_acceleration_status.xml") +POPULATE_VIEWS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_views.xml") +UPDATE_VIEWS_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_views_acceleration_status.xml") +UPDATE_WORKBOOK_ACCELERATION_STATUS_XML = os.path.join(TEST_ASSET_DIR, "workbook_update_acceleration_status.xml") + + +class WorkbookTests(unittest.TestCase): + def setUp(self) -> None: + self.server = TSC.Server("http://test", False) + + # Fake sign in + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.workbooks.baseurl + + def test_get_by_id(self) -> None: + with open(GET_BY_ID_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/3cc6cd06-89ce-4fdc-b935-5294135d6d42", text=response_xml) + single_workbook = self.server.workbooks.get_by_id("3cc6cd06-89ce-4fdc-b935-5294135d6d42") + + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", single_workbook.id) + self.assertEqual("SafariSample", single_workbook.name) + self.assertEqual("SafariSample", single_workbook.content_url) + self.assertEqual("http://tableauserver/#/workbooks/2/views", single_workbook.webpage_url) + self.assertEqual(False, single_workbook.show_tabs) + self.assertEqual(26, single_workbook.size) + self.assertEqual("2016-07-26T20:34:56Z", format_datetime(single_workbook.created_at)) + self.assertEqual("description for SafariSample", single_workbook.description) + self.assertEqual("2016-07-26T20:35:05Z", format_datetime(single_workbook.updated_at)) + self.assertEqual("ee8c6e70-43b6-11e6-af4f-f7b0d8e20760", single_workbook.project_id) + self.assertEqual("default", single_workbook.project_name) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", single_workbook.owner_id) + self.assertEqual(set(["Safari", "Sample"]), single_workbook.tags) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", single_workbook.views[0].id) + self.assertEqual("ENDANGERED SAFARI", single_workbook.views[0].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) + self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Enabled", single_workbook.views[0].data_acceleration_config["acceleration_status"]) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id) + self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url) + self.assertEqual(False, single_workbook.views[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Suspended", single_workbook.views[1].data_acceleration_config["acceleration_status"]) + + def test_update_workbook_acceleration(self) -> None: + with open(UPDATE_WORKBOOK_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_acceleration_config = { + "acceleration_enabled": True, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + # update with parameter includeViewAccelerationStatus=True + single_workbook = self.server.workbooks.update(single_workbook, True) + + self.assertEqual("1f951daf-4061-451a-9df1-69a8062664f2", single_workbook.id) + self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_workbook.project_id) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", single_workbook.views[0].content_url) + self.assertEqual(True, single_workbook.views[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", single_workbook.views[0].data_acceleration_config["acceleration_status"]) + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff9", single_workbook.views[1].id) + self.assertEqual("ENDANGERED SAFARI 2", single_workbook.views[1].name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI2", single_workbook.views[1].content_url) + self.assertEqual(True, single_workbook.views[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", single_workbook.views[1].data_acceleration_config["acceleration_status"]) + + def test_update_views_acceleration(self) -> None: + with open(POPULATE_VIEWS_XML, "rb") as f: + views_xml = f.read().decode("utf-8") + with open(UPDATE_VIEWS_ACCELERATION_STATUS_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2/views", text=views_xml) + m.put(self.baseurl + "/1f951daf-4061-451a-9df1-69a8062664f2", text=response_xml) + single_workbook = TSC.WorkbookItem("1d0304cd-3796-429f-b815-7258370b9b74", show_tabs=True) + single_workbook._id = "1f951daf-4061-451a-9df1-69a8062664f2" + single_workbook.data_acceleration_config = { + "acceleration_enabled": False, + "accelerate_now": False, + "last_updated_at": None, + "acceleration_status": None, + } + self.server.workbooks.populate_views(single_workbook) + single_workbook.views = [single_workbook.views[1], single_workbook.views[2]] + # update with parameter includeViewAccelerationStatus=True + single_workbook = self.server.workbooks.update(single_workbook, True) + + views_list = single_workbook.views + self.assertEqual("097dbe13-de89-445f-b2c3-02f28bd010c1", views_list[0].id) + self.assertEqual("GDP per capita", views_list[0].name) + self.assertEqual(False, views_list[0].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Disabled", views_list[0].data_acceleration_config["acceleration_status"]) + + self.assertEqual("2c1ab9d7-8d64-4cc6-b495-52e40c60c330", views_list[1].id) + self.assertEqual("Country ranks", views_list[1].name) + self.assertEqual(True, views_list[1].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", views_list[1].data_acceleration_config["acceleration_status"]) + + self.assertEqual("0599c28c-6d82-457e-a453-e52c1bdb00f5", views_list[2].id) + self.assertEqual("Interest rates", views_list[2].name) + self.assertEqual(True, views_list[2].data_acceleration_config["acceleration_enabled"]) + self.assertEqual("Pending", views_list[2].data_acceleration_config["acceleration_status"]) From 114214beb947db6bf74926337bb14fbd8e7d1c45 Mon Sep 17 00:00:00 2001 From: Brian Cantoni Date: Fri, 26 Apr 2024 18:27:19 -0700 Subject: [PATCH 16/73] Improve robustness of Pager results In some cases, Tableau Server might have a different between the advertised total number of object and the actual number returned via the Pager. This change adds one more check to prevent errors from happening in these situations. Fixes #1304 --- tableauserverclient/server/pager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index b65d75ae5..3220f5372 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -47,7 +47,11 @@ def __iter__(self): # Get the rest on demand as a generator while self._count < last_pagination_item.total_available: - if len(current_item_list) == 0: + if ( + len(current_item_list) == 0 + and (last_pagination_item.page_number * last_pagination_item.page_size) + < last_pagination_item.total_available + ): current_item_list, last_pagination_item = self._load_next_page(last_pagination_item) try: From bdce9822ffbac122b5a7072497fe1e841084c012 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Tue, 7 May 2024 21:41:32 -0700 Subject: [PATCH 17/73] Add Cloud Flow Task endpoint --- tableauserverclient/models/task_item.py | 1 + .../server/endpoint/flow_task_endpoint.py | 29 +++++++++ tableauserverclient/server/request_factory.py | 37 +++++++++++ tableauserverclient/server/server.py | 2 + test/test_flowtask.py | 61 +++++++++++++++++++ 5 files changed, 130 insertions(+) create mode 100644 tableauserverclient/server/endpoint/flow_task_endpoint.py create mode 100644 test/test_flowtask.py diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index 0ffc3bfab..01cfcfb11 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -18,6 +18,7 @@ class Type: _TASK_TYPE_MAPPING = { "RefreshExtractTask": Type.ExtractRefresh, "MaterializeViewsTask": Type.DataAcceleration, + "RunFlowTask": Type.RunFlow, } def __init__( diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py new file mode 100644 index 000000000..1e53b22f1 --- /dev/null +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -0,0 +1,29 @@ +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 +from tableauserverclient.models import TaskItem, PaginationItem +from tableauserverclient.server import RequestFactory + +from tableauserverclient.helpers.logging import logger + +if TYPE_CHECKING: + from tableauserverclient.server.request_options import RequestOptions + + +class FlowTasks(Endpoint): + @property + def baseurl(self) -> str: + return "{0}/sites/{1}/tasks/flows".format(self.parent_srv.baseurl, self.parent_srv.site_id) + + @api(version="3.22") + def create(self, flow_item: TaskItem) -> TaskItem: + if not flow_item: + error = "No flow provided" + raise ValueError(error) + logger.info("Creating an flow task %s", flow_item) + url = self.baseurl + create_req = RequestFactory.Task.create_flow_task_req(flow_item) + server_response = self.post_request(url, create_req) + return server_response.content \ No newline at end of file diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 1f6dfbfc6..904df1215 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1113,6 +1113,43 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) +class FlowTaskRequest(object): + @_tsrequest_wrapped + def run_req(self, xml_request, task_item): + # Send an empty tsRequest + pass + + @_tsrequest_wrapped + def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: + flow_element = ET.SubElement(xml_request, "runFlow") + + # Main attributes + flow_element.attrib["type"] = flow_item.task_type + + if flow_item.target is not None: + target_element = ET.SubElement(flow_element, flow_item.target.type) + target_element.attrib["id"] = flow_item.target.id + + if flow_item.schedule_item is None: + return ET.tostring(xml_request) + + # Schedule attributes + schedule_element = ET.SubElement(xml_request, "schedule") + + interval_item = flow_item.schedule_item.interval_item + schedule_element.attrib["frequency"] = interval_item._frequency + frequency_element = ET.SubElement(schedule_element, "frequencyDetails") + frequency_element.attrib["start"] = str(interval_item.start_time) + if hasattr(interval_item, "end_time") and interval_item.end_time is not None: + 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(): # type: ignore + expression, value = interval + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = value + + return ET.tostring(xml_request) class SubscriptionRequest(object): @_tsrequest_wrapped diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index ee23789b1..3a6831458 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -25,6 +25,7 @@ Databases, Tables, Flows, + FlowTasks, Webhooks, DataAccelerationReport, Favorites, @@ -82,6 +83,7 @@ def __init__(self, server_address, use_server_version=False, http_options=None, self.datasources = Datasources(self) self.favorites = Favorites(self) self.flows = Flows(self) + self.flow_tasks = FlowTasks(self) self.projects = Projects(self) self.schedules = Schedules(self) self.server_info = ServerInfo(self) diff --git a/test/test_flowtask.py b/test/test_flowtask.py new file mode 100644 index 000000000..aaa4b0932 --- /dev/null +++ b/test/test_flowtask.py @@ -0,0 +1,61 @@ +import os +import unittest +from datetime import time +from pathlib import Path + +import requests_mock + +import tableauserverclient as TSC +from tableauserverclient.datetime_helpers import parse_datetime +from tableauserverclient.models.task_item import TaskItem + +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") +GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.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" +GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml" + +GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml") + + + +class TaskTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server("http://test", False) + self.server.version = "3.22" + + # Fake Signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + # default task type is extractRefreshes TODO change this + # self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes") + self.baseurl = self.server.flow_tasks.baseurl + + def test_create_flow_task(self): + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + target_item = TSC.Target("flow_id", "flow") + + task = TaskItem(schedule_item=monthly_schedule, target=target_item) + # task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) + + with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post("{}".format(self.baseurl), text=response_xml) + create_response_content = self.server.flow_tasks.create(task).decode("utf-8") + + self.assertTrue("task_id" in create_response_content) + self.assertTrue("flow_id" in create_response_content) + #self.assertTrue("FullRefresh" in create_response_content) From 67812858dd4ce43154d8ce9e22fbdc069875ffce Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 11:44:54 -0700 Subject: [PATCH 18/73] cleanup --- tableauserverclient/server/endpoint/__init__.py | 1 + tableauserverclient/server/endpoint/flow_task_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 1 + test/test_flowtask.py | 4 ---- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index c018d8334..b2f291369 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -10,6 +10,7 @@ from .fileuploads_endpoint import Fileuploads from .flow_runs_endpoint import FlowRuns from .flows_endpoint import Flows +from .flow_task_endpoint import FlowTasks from .groups_endpoint import Groups from .jobs_endpoint import Jobs from .metadata_endpoint import Metadata diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index 1e53b22f1..18a9c2550 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -24,6 +24,6 @@ def create(self, flow_item: TaskItem) -> TaskItem: raise ValueError(error) logger.info("Creating an flow task %s", flow_item) url = self.baseurl - create_req = RequestFactory.Task.create_flow_task_req(flow_item) + create_req = RequestFactory.FlowTask.create_flow_task_req(flow_item) server_response = self.post_request(url, create_req) return server_response.content \ No newline at end of file diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 904df1215..825451187 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1290,6 +1290,7 @@ class RequestFactory(object): Favorite = FavoriteRequest() Fileupload = FileuploadRequest() Flow = FlowRequest() + FlowTask = FlowTaskRequest() Group = GroupRequest() Metric = MetricRequest() Permission = PermissionRequest() diff --git a/test/test_flowtask.py b/test/test_flowtask.py index aaa4b0932..8588d5701 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -32,8 +32,6 @@ def setUp(self): self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - # default task type is extractRefreshes TODO change this - # self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes") self.baseurl = self.server.flow_tasks.baseurl def test_create_flow_task(self): @@ -48,7 +46,6 @@ def test_create_flow_task(self): target_item = TSC.Target("flow_id", "flow") task = TaskItem(schedule_item=monthly_schedule, target=target_item) - # task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") @@ -58,4 +55,3 @@ def test_create_flow_task(self): self.assertTrue("task_id" in create_response_content) self.assertTrue("flow_id" in create_response_content) - #self.assertTrue("FullRefresh" in create_response_content) From 06b76d6dbce43cecb1b872d265c764b614d4fad7 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 14:12:53 -0700 Subject: [PATCH 19/73] black format --- tableauserverclient/server/endpoint/flow_task_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 8 +++++--- test/test_flowtask.py | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/server/endpoint/flow_task_endpoint.py b/tableauserverclient/server/endpoint/flow_task_endpoint.py index 18a9c2550..eea3f9710 100644 --- a/tableauserverclient/server/endpoint/flow_task_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_task_endpoint.py @@ -26,4 +26,4 @@ def create(self, flow_item: TaskItem) -> TaskItem: url = self.baseurl create_req = RequestFactory.FlowTask.create_flow_task_req(flow_item) server_response = self.post_request(url, create_req) - return server_response.content \ No newline at end of file + return server_response.content diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 825451187..cca4b82a6 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -972,9 +972,9 @@ def update_req(self, workbook_item): if data_freshness_policy_config.option == "FreshEvery": if data_freshness_policy_config.fresh_every_schedule is not None: fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") - fresh_every_element.attrib[ - "frequency" - ] = data_freshness_policy_config.fresh_every_schedule.frequency + fresh_every_element.attrib["frequency"] = ( + data_freshness_policy_config.fresh_every_schedule.frequency + ) fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) else: raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") @@ -1113,6 +1113,7 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") return ET.tostring(xml_request) + class FlowTaskRequest(object): @_tsrequest_wrapped def run_req(self, xml_request, task_item): @@ -1151,6 +1152,7 @@ def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") - return ET.tostring(xml_request) + class SubscriptionRequest(object): @_tsrequest_wrapped def create_req(self, xml_request: ET.Element, subscription_item: "SubscriptionItem") -> bytes: diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 8588d5701..61a09b429 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -22,7 +22,6 @@ GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml") - class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) From 4735bd31185c6dec8b1fdccce86ee8aa32f129dd Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 14:29:26 -0700 Subject: [PATCH 20/73] add xml --- test/assets/tasks_create_flow_task.xml | 14 ++++++++++++++ test/test_flowtask.py | 9 --------- 2 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 test/assets/tasks_create_flow_task.xml diff --git a/test/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml new file mode 100644 index 000000000..44826a94a --- /dev/null +++ b/test/assets/tasks_create_flow_task.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 61a09b429..1f7d82c30 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -10,15 +10,6 @@ from tableauserverclient.models.task_item import TaskItem 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") -GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.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" -GET_XML_WITH_INTERVAL = TEST_ASSET_DIR / "tasks_with_interval.xml" - GET_XML_CREATE_FLOW_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_flow_task.xml") From d6fd8291378d2393a02a8dc96cd46853d2455515 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 15:17:40 -0700 Subject: [PATCH 21/73] edit test initialization --- test/assets/tasks_create_flow_task.xml | 38 ++++++++++++++++++-------- test/test_flowtask.py | 8 +++--- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/test/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml index 44826a94a..b5a6aa6f4 100644 --- a/test/assets/tasks_create_flow_task.xml +++ b/test/assets/tasks_create_flow_task.xml @@ -1,14 +1,28 @@ - - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_flowtask.py b/test/test_flowtask.py index 1f7d82c30..ed2627147 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -27,10 +27,10 @@ def setUp(self): def test_create_flow_task(self): monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) monthly_schedule = TSC.ScheduleItem( - None, - None, - None, - None, + "Monthly Schedule", + 50, + TSC.ScheduleItem.Type.Flow, + TSC.ScheduleItem.ExecutionOrder.Parallel, monthly_interval, ) target_item = TSC.Target("flow_id", "flow") From 7f11a6d4ff7d4da1d526784d30ef30182f9592aa Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 15:31:14 -0700 Subject: [PATCH 22/73] fix task initialization --- test/test_flowtask.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_flowtask.py b/test/test_flowtask.py index ed2627147..dd2d07eef 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -35,7 +35,7 @@ def test_create_flow_task(self): ) target_item = TSC.Target("flow_id", "flow") - task = TaskItem(schedule_item=monthly_schedule, target=target_item) + task = TaskItem(None, "RunFlow", None, schedule_item=monthly_schedule, target=target_item) with open(GET_XML_CREATE_FLOW_TASK_RESPONSE, "rb") as f: response_xml = f.read().decode("utf-8") From c746957b3293f1fedc46af86f07432d86bc803b5 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 15:45:12 -0700 Subject: [PATCH 23/73] third times the charm --- test/assets/tasks_create_flow_task.xml | 12 ++++++------ test/test_flowtask.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/assets/tasks_create_flow_task.xml b/test/assets/tasks_create_flow_task.xml index b5a6aa6f4..11c9a4ff0 100644 --- a/test/assets/tasks_create_flow_task.xml +++ b/test/assets/tasks_create_flow_task.xml @@ -1,11 +1,11 @@ - - - - + - diff --git a/test/test_flowtask.py b/test/test_flowtask.py index dd2d07eef..034066e64 100644 --- a/test/test_flowtask.py +++ b/test/test_flowtask.py @@ -43,5 +43,5 @@ def test_create_flow_task(self): m.post("{}".format(self.baseurl), text=response_xml) create_response_content = self.server.flow_tasks.create(task).decode("utf-8") - self.assertTrue("task_id" in create_response_content) + self.assertTrue("schedule_id" in create_response_content) self.assertTrue("flow_id" in create_response_content) From 0e5ce785d601a3c013c97a305188d281a867c866 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Wed, 8 May 2024 15:51:58 -0700 Subject: [PATCH 24/73] cleanup --- tableauserverclient/server/request_factory.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index cca4b82a6..61507ea2e 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1115,11 +1115,6 @@ def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") class FlowTaskRequest(object): - @_tsrequest_wrapped - def run_req(self, xml_request, task_item): - # Send an empty tsRequest - pass - @_tsrequest_wrapped def create_flow_task_req(self, xml_request: ET.Element, flow_item: "TaskItem") -> bytes: flow_element = ET.SubElement(xml_request, "runFlow") From bcb02ac5e294246e07859ddc1281bba11b58ee09 Mon Sep 17 00:00:00 2001 From: "liu.r" Date: Thu, 9 May 2024 17:33:27 -0700 Subject: [PATCH 25/73] fix formatting --- tableauserverclient/server/request_factory.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 61507ea2e..c204e7217 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -972,9 +972,9 @@ def update_req(self, workbook_item): if data_freshness_policy_config.option == "FreshEvery": if data_freshness_policy_config.fresh_every_schedule is not None: fresh_every_element = ET.SubElement(data_freshness_policy_element, "freshEverySchedule") - fresh_every_element.attrib["frequency"] = ( - data_freshness_policy_config.fresh_every_schedule.frequency - ) + fresh_every_element.attrib[ + "frequency" + ] = data_freshness_policy_config.fresh_every_schedule.frequency fresh_every_element.attrib["value"] = str(data_freshness_policy_config.fresh_every_schedule.value) else: raise ValueError(f"data_freshness_policy_config.fresh_every_schedule must be populated.") From 435f1aed2e25542b894070440558289f8527a53c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 9 May 2024 21:06:35 -0500 Subject: [PATCH 26/73] feat: pass parameters in request options --- tableauserverclient/server/request_options.py | 16 +++++++++++++-- test/test_request_option.py | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 8304b8f68..5cc06bf9d 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -1,5 +1,7 @@ import sys +from typing_extensions import Self + from tableauserverclient.models.property_decorators import property_is_int import logging @@ -154,17 +156,27 @@ class _FilterOptionsBase(RequestOptionsBase): def __init__(self): self.view_filters = [] + self.view_parameters = [] def get_query_params(self): raise NotImplementedError() - def vf(self, name, value): + def vf(self, name: str, value: str) -> Self: + """Apply a filter to the view for a filter that is a normal column + within the view.""" self.view_filters.append((name, value)) return self - def _append_view_filters(self, params): + def parameter(self, name: str, value: str) -> Self: + """Apply a filter based on a parameter within the workbook.""" + self.view_parameters.append((name, value)) + return self + + def _append_view_filters(self, params) -> None: for name, value in self.view_filters: params["vf_" + name] = value + for name, value in self.view_parameters: + params[name] = value class CSVRequestOptions(_FilterOptionsBase): diff --git a/test/test_request_option.py b/test/test_request_option.py index 32526d1e6..40dd3345a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -2,6 +2,7 @@ from pathlib import Path import re import unittest +from urllib.parse import parse_qs import requests_mock @@ -311,3 +312,22 @@ def test_slicing_queryset_multi_page(self) -> None: def test_queryset_filter_args_error(self) -> None: with self.assertRaises(RuntimeError): workbooks = self.server.workbooks.filter("argument") + + def test_filtering_parameters(self) -> None: + self.server.version = "3.6" + with requests_mock.mock() as m: + m.get(requests_mock.ANY) + url = self.baseurl + "/views/456/data" + opts = TSC.PDFRequestOptions() + opts.parameter("name1@", "value1") + opts.parameter("name2$", "value2") + opts.page_type = TSC.PDFRequestOptions.PageType.Tabloid + + resp = self.server.workbooks.get_request(url, request_object=opts) + query_params = parse_qs(resp.request.query) + self.assertIn("name1@", query_params) + self.assertIn("value1", query_params["name1@"]) + self.assertIn("name2$", query_params) + self.assertIn("value2", query_params["name2$"]) + self.assertIn("type", query_params) + self.assertIn("tabloid", query_params["type"]) From 397e275804a7321a7c2b0e45ee8e91c2f6ca11c8 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 9 May 2024 21:09:41 -0500 Subject: [PATCH 27/73] chore: pin typing_extensions version --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 9c35a42e7..fceb37237 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ 'packaging>=23.1', # latest as at 7/31/23 'requests>=2.31', # latest as at 7/31/23 'urllib3==2.0.7', # latest as at 7/31/23 + 'typing_extensions>=4.0.1', ] requires-python = ">=3.7" classifiers = [ From 4029583561f4bda1ace8167e4feaba82a071ced5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 6 Apr 2024 20:36:24 -0500 Subject: [PATCH 28/73] feat: enable combining PermissionsRules --- .gitignore | 1 + .../models/permissions_item.py | 26 ++++++++++ tableauserverclient/models/reference_item.py | 3 ++ test/test_permissionsrule.py | 49 +++++++++++++++++++ 4 files changed, 79 insertions(+) create mode 100644 test/test_permissionsrule.py diff --git a/.gitignore b/.gitignore index 92778cd81..b3b3ff80f 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,4 @@ docs/_site/ docs/.jekyll-metadata docs/Gemfile.lock samples/credentials +.venv/ diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index d2b2227db..71ffb7013 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -53,6 +53,32 @@ def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> def __repr__(self): return "".format(self.grantee, self.capabilities) + def __and__(self, other: "PermissionsRule") -> "PermissionsRule": + if self.grantee != other.grantee: + raise ValueError("Cannot AND two permissions rules with different grantees") + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + new_capabilities = {} + for capability in capabilities: + if (self.capabilities.get(capability), other.capabilities.get(capability)) == (Permission.Mode.Allow, Permission.Mode.Allow): + new_capabilities[capability] = Permission.Mode.Allow + elif Permission.Mode.Deny in (self.capabilities.get(capability), other.capabilities.get(capability)): + new_capabilities[capability] = Permission.Mode.Deny + + return PermissionsRule(self.grantee, new_capabilities) + + def __or__(self, other: "PermissionsRule") -> "PermissionsRule": + if self.grantee != other.grantee: + raise ValueError("Cannot AND two permissions rules with different grantees") + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) + new_capabilities = {} + for capability in capabilities: + if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): + new_capabilities[capability] = Permission.Mode.Allow + elif (self.capabilities.get(capability), other.capabilities.get(capability)) == (Permission.Mode.Deny, Permission.Mode.Deny): + new_capabilities[capability] = Permission.Mode.Deny + + return PermissionsRule(self.grantee, new_capabilities) + @classmethod def from_response(cls, resp, ns=None) -> List["PermissionsRule"]: parsed_response = fromstring(resp) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 6fc6b0c22..c46f96867 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -8,6 +8,9 @@ def __str__(self): __repr__ = __str__ + def __eq__(self, other): + return (self.id == other.id) and (self.tag_name == other.tag_name) + @property def id(self): return self._id diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py new file mode 100644 index 000000000..34965d610 --- /dev/null +++ b/test/test_permissionsrule.py @@ -0,0 +1,49 @@ +import unittest + +import tableauserverclient as TSC +from tableauserverclient.models.reference_item import ResourceReference + +class TestPermissionsRules(unittest.TestCase): + def test_and(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule(grantee, { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }) + rule2 = TSC.PermissionsRule(grantee, { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }) + + composite = rule1 & rule2 + + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Deny) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), None) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + + + def test_or(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule(grantee, { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }) + rule2 = TSC.PermissionsRule(grantee, { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }) + + composite = rule1 | rule2 + + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportData), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow) + self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + From e8b01dddec2533a1853dd277ddfa7f09a263423c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 29 May 2024 22:10:34 -0500 Subject: [PATCH 29/73] style: black --- .../models/permissions_item.py | 10 +++- test/test_permissionsrule.py | 59 +++++++++++-------- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 71ffb7013..14a97169c 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -59,7 +59,10 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: - if (self.capabilities.get(capability), other.capabilities.get(capability)) == (Permission.Mode.Allow, Permission.Mode.Allow): + if (self.capabilities.get(capability), other.capabilities.get(capability)) == ( + Permission.Mode.Allow, + Permission.Mode.Allow, + ): new_capabilities[capability] = Permission.Mode.Allow elif Permission.Mode.Deny in (self.capabilities.get(capability), other.capabilities.get(capability)): new_capabilities[capability] = Permission.Mode.Deny @@ -74,7 +77,10 @@ def __or__(self, other: "PermissionsRule") -> "PermissionsRule": for capability in capabilities: if Permission.Mode.Allow in (self.capabilities.get(capability), other.capabilities.get(capability)): new_capabilities[capability] = Permission.Mode.Allow - elif (self.capabilities.get(capability), other.capabilities.get(capability)) == (Permission.Mode.Deny, Permission.Mode.Deny): + elif (self.capabilities.get(capability), other.capabilities.get(capability)) == ( + Permission.Mode.Deny, + Permission.Mode.Deny, + ): new_capabilities[capability] = Permission.Mode.Deny return PermissionsRule(self.grantee, new_capabilities) diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py index 34965d610..7f18055ab 100644 --- a/test/test_permissionsrule.py +++ b/test/test_permissionsrule.py @@ -3,20 +3,27 @@ import tableauserverclient as TSC from tableauserverclient.models.reference_item import ResourceReference + class TestPermissionsRules(unittest.TestCase): def test_and(self): grantee = ResourceReference("a", "user") - rule1 = TSC.PermissionsRule(grantee, { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }) - rule2 = TSC.PermissionsRule(grantee, { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }) + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) composite = rule1 & rule2 @@ -25,20 +32,25 @@ def test_and(self): self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), None) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) - def test_or(self): grantee = ResourceReference("a", "user") - rule1 = TSC.PermissionsRule(grantee, { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, - TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }) - rule2 = TSC.PermissionsRule(grantee, { - TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, - TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, - }) + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) composite = rule1 | rule2 @@ -46,4 +58,3 @@ def test_or(self): self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) - From 6dcabb29371866198062d8ea1d681d25d382f1f4 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:21:22 -0500 Subject: [PATCH 30/73] fix: typo in exception --- tableauserverclient/models/permissions_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 14a97169c..61afa16ee 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -71,7 +71,7 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.grantee != other.grantee: - raise ValueError("Cannot AND two permissions rules with different grantees") + raise ValueError("Cannot OR two permissions rules with different grantees") capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: From 691ba7f6b16b9c935ad4c8e783b674c8d0b307c1 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:38:08 -0500 Subject: [PATCH 31/73] feat: add eq comparison for PermissionsRule --- .../models/permissions_item.py | 11 +++++ test/test_permissionsrule.py | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 61afa16ee..949f861ca 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -53,9 +53,16 @@ def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> def __repr__(self): return "".format(self.grantee, self.capabilities) + def __eq__(self, other: "PermissionsRule") -> bool: + return self.grantee == other.grantee and self.capabilities == other.capabilities + def __and__(self, other: "PermissionsRule") -> "PermissionsRule": if self.grantee != other.grantee: raise ValueError("Cannot AND two permissions rules with different grantees") + + if self.capabilities == other.capabilities: + return self + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: @@ -72,6 +79,10 @@ def __and__(self, other: "PermissionsRule") -> "PermissionsRule": def __or__(self, other: "PermissionsRule") -> "PermissionsRule": if self.grantee != other.grantee: raise ValueError("Cannot OR two permissions rules with different grantees") + + if self.capabilities == other.capabilities: + return self + capabilities = set((*self.capabilities.keys(), *other.capabilities.keys())) new_capabilities = {} for capability in capabilities: diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py index 7f18055ab..c10bc1e92 100644 --- a/test/test_permissionsrule.py +++ b/test/test_permissionsrule.py @@ -58,3 +58,49 @@ def test_or(self): self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.Delete), TSC.Permission.Mode.Allow) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ViewComments), TSC.Permission.Mode.Allow) self.assertEqual(composite.capabilities.get(TSC.Permission.Capability.ExportXml), TSC.Permission.Mode.Deny) + + def test_eq_false(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + + self.assertNotEqual(rule1, rule2) + + def test_eq_true(self): + grantee = ResourceReference("a", "user") + rule1 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + rule2 = TSC.PermissionsRule( + grantee, + { + TSC.Permission.Capability.ExportData: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.Delete: TSC.Permission.Mode.Deny, + TSC.Permission.Capability.ViewComments: TSC.Permission.Mode.Allow, + TSC.Permission.Capability.ExportXml: TSC.Permission.Mode.Deny, + }, + ) + self.assertEqual(rule1, rule2) + + From 07e1fe22911f36618564cca23dbf49b45ef768af Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:42:43 -0500 Subject: [PATCH 32/73] style: black --- test/test_permissionsrule.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/test_permissionsrule.py b/test/test_permissionsrule.py index c10bc1e92..d7bceb258 100644 --- a/test/test_permissionsrule.py +++ b/test/test_permissionsrule.py @@ -102,5 +102,3 @@ def test_eq_true(self): }, ) self.assertEqual(rule1, rule2) - - From 73b125a5b47478563cd8255799f0adb6818be2d9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:47:15 -0500 Subject: [PATCH 33/73] fix: generalize eq methods --- tableauserverclient/models/permissions_item.py | 6 ++++-- tableauserverclient/models/reference_item.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/permissions_item.py b/tableauserverclient/models/permissions_item.py index 949f861ca..fecdb9723 100644 --- a/tableauserverclient/models/permissions_item.py +++ b/tableauserverclient/models/permissions_item.py @@ -45,7 +45,7 @@ def __repr__(self): return "" -class PermissionsRule(object): +class PermissionsRule: def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> None: self.grantee = grantee self.capabilities = capabilities @@ -53,7 +53,9 @@ def __init__(self, grantee: ResourceReference, capabilities: Dict[str, str]) -> def __repr__(self): return "".format(self.grantee, self.capabilities) - def __eq__(self, other: "PermissionsRule") -> bool: + def __eq__(self, other: object) -> bool: + if not hasattr(other, "grantee") or not hasattr(other, "capabilities"): + return False return self.grantee == other.grantee and self.capabilities == other.capabilities def __and__(self, other: "PermissionsRule") -> "PermissionsRule": diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index c46f96867..99c990287 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -8,7 +8,9 @@ def __str__(self): __repr__ = __str__ - def __eq__(self, other): + def __eq__(self, other: object): + if not hasattr(other, 'id') or not hasattr(other, 'tag_name'): + return False return (self.id == other.id) and (self.tag_name == other.tag_name) @property From 2c0e2bdc49ed28079cd19aab820fd683e454beb7 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:49:07 -0500 Subject: [PATCH 34/73] style: black --- tableauserverclient/models/reference_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index 99c990287..b69e43db7 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -9,7 +9,7 @@ def __str__(self): __repr__ = __str__ def __eq__(self, other: object): - if not hasattr(other, 'id') or not hasattr(other, 'tag_name'): + if not hasattr(other, "id") or not hasattr(other, "tag_name"): return False return (self.id == other.id) and (self.tag_name == other.tag_name) From cad17111e06f84cd2b7ecbb000e911d28093602b Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:52:44 -0500 Subject: [PATCH 35/73] fix: add missing type hint --- tableauserverclient/models/reference_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/models/reference_item.py b/tableauserverclient/models/reference_item.py index b69e43db7..710548fcc 100644 --- a/tableauserverclient/models/reference_item.py +++ b/tableauserverclient/models/reference_item.py @@ -8,7 +8,7 @@ def __str__(self): __repr__ = __str__ - def __eq__(self, other: object): + def __eq__(self, other: object) -> bool: if not hasattr(other, "id") or not hasattr(other, "tag_name"): return False return (self.id == other.id) and (self.tag_name == other.tag_name) From 4feeffda8829bc73d612b92ce7c459bb4282f7e5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:06:32 -0500 Subject: [PATCH 36/73] chore: remove deprecated group update argument --- .../server/endpoint/groups_endpoint.py | 2 +- tableauserverclient/server/request_factory.py | 14 +------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ab5f672d1..148151d12 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -93,7 +93,7 @@ def update( elif as_job: url = "?".join([url, "asJob=True"]) - update_req = RequestFactory.Group.update_req(group_item, None) + update_req = RequestFactory.Group.update_req(group_item) server_response = self.put_request(url, update_req) logger.info("Updated group item (ID: {0})".format(group_item.id)) if as_job: diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c204e7217..6ebd08dd1 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -418,19 +418,7 @@ def create_ad_req(self, group_item: GroupItem) -> bytes: import_element.attrib["siteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item: GroupItem, default_site_role: Optional[str] = None) -> bytes: - # (1/8/2021): Deprecated starting v0.15 - if default_site_role is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - 'RequestFactory.Group.update_req(...default_site_role="") is deprecated, ' - "please set the minimum_site_role field of GroupItem", - DeprecationWarning, - ) - group_item.minimum_site_role = default_site_role - + def update_req(self, group_item: GroupItem, ) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") From 1b7eb9b244a5dd1deb095cb9783206ae905aed85 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:07:37 -0500 Subject: [PATCH 37/73] chore: remove deprecated workbook method --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index bc535b2d6..0eb7115f4 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -160,13 +160,6 @@ def update( updated_workbook = copy.copy(workbook_item) return updated_workbook._parse_common_tags(server_response.content, self.parent_srv.namespace) - @api(version="2.3") - def update_conn(self, *args, **kwargs): - import warnings - - warnings.warn("update_conn is deprecated, please use update_connection instead") - return self.update_connection(*args, **kwargs) - # Update workbook_connection @api(version="2.3") def update_connection(self, workbook_item: WorkbookItem, connection_item: ConnectionItem) -> ConnectionItem: From 7a7587dee5e53314d1f658c13eee62bd819d1551 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:08:01 -0500 Subject: [PATCH 38/73] chore: remove deprecated workbook publish arguments --- .../server/endpoint/workbooks_endpoint.py | 15 ---- tableauserverclient/server/request_factory.py | 27 ------ test/test_workbook.py | 82 ------------------- 3 files changed, 124 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 0eb7115f4..8a5b7a112 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -308,21 +308,12 @@ def publish( workbook_item: WorkbookItem, file: PathOrFileR, mode: str, - connection_credentials: Optional["ConnectionCredentials"] = None, connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, parameters=None, ): - if connection_credentials is not None: - import warnings - - warnings.warn( - "connection_credentials is being deprecated. Use connections instead", - DeprecationWarning, - ) - if isinstance(file, (str, os.PathLike)): if not os.path.isfile(file): error = "File path does not lead to an existing file." @@ -384,12 +375,9 @@ def publish( logger.info("Publishing {0} to server with chunking method (workbook over 64MB)".format(workbook_item.name)) upload_session_id = self.parent_srv.fileuploads.upload(file) url = "{0}&uploadSessionId={1}".format(url, upload_session_id) - conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req_chunked( workbook_item, - connection_credentials=conn_creds, connections=connections, - hidden_views=hidden_views, ) else: logger.info("Publishing {0} to server".format(filename)) @@ -404,14 +392,11 @@ def publish( else: raise TypeError("file should be a filepath or file object.") - conn_creds = connection_credentials xml_request, content_type = RequestFactory.Workbook.publish_req( workbook_item, filename, file_contents, - connection_credentials=conn_creds, connections=connections, - hidden_views=hidden_views, ) logger.debug("Request xml: {0} ".format(redact_xml(xml_request[:1000]))) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 6ebd08dd1..68889d4e5 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -881,9 +881,7 @@ class WorkbookRequest(object): def _generate_xml( self, workbook_item, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = ET.Element("tsRequest") workbook_element = ET.SubElement(xml_request, "workbook") @@ -893,12 +891,6 @@ def _generate_xml( project_element = ET.SubElement(workbook_element, "project") project_element.attrib["id"] = str(workbook_item.project_id) - if connection_credentials is not None and connections is not None: - raise RuntimeError("You cannot set both `connections` and `connection_credentials`") - - if connection_credentials is not None and connection_credentials != False: - _add_credentials_element(workbook_element, connection_credentials) - if connections is not None and connections != False and len(connections) > 0: connections_element = ET.SubElement(workbook_element, "connections") for connection in connections: @@ -907,17 +899,6 @@ def _generate_xml( if workbook_item.description is not None: workbook_element.attrib["description"] = workbook_item.description - if hidden_views is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - "the hidden_views parameter should now be set on the workbook directly", - DeprecationWarning, - ) - if workbook_item.hidden_views is None: - workbook_item.hidden_views = hidden_views - if workbook_item.hidden_views is not None: views_element = ET.SubElement(workbook_element, "views") for view_name in workbook_item.hidden_views: @@ -1000,15 +981,11 @@ def publish_req( workbook_item, filename, file_contents, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = self._generate_xml( workbook_item, - connection_credentials=connection_credentials, connections=connections, - hidden_views=hidden_views, ) parts = { @@ -1020,15 +997,11 @@ def publish_req( def publish_req_chunked( self, workbook_item, - connection_credentials=None, connections=None, - hidden_views=None, ): xml_request = self._generate_xml( workbook_item, - connection_credentials=connection_credentials, connections=connections, - hidden_views=hidden_views, ) parts = {"request_payload": ("", xml_request, "text/xml")} diff --git a/test/test_workbook.py b/test/test_workbook.py index ac3d44b28..991af2ad8 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -621,31 +621,6 @@ def test_publish_with_hidden_views_on_workbook(self) -> None: self.assertTrue(re.search(rb"<\/views>", request_body)) self.assertTrue(re.search(rb"<\/views>", request_body)) - # this tests the old method of including workbook views as a parameter for publishing - # should be removed when that functionality is removed - # see https://github.com/tableau/server-client-python/pull/617 - def test_publish_with_hidden_view(self) -> None: - with open(PUBLISH_XML, "rb") as f: - response_xml = f.read().decode("utf-8") - with requests_mock.mock() as m: - m.post(self.baseurl, text=response_xml) - - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - - sample_workbook = os.path.join(TEST_ASSET_DIR, "SampleWB.twbx") - publish_mode = self.server.PublishMode.CreateNew - - new_workbook = self.server.workbooks.publish( - new_workbook, sample_workbook, publish_mode, hidden_views=["GDP per capita"] - ) - - request_body = m._adapter.request_history[0]._request.body - # order of attributes in xml is unspecified - self.assertTrue(re.search(rb"<\/views>", request_body)) - self.assertTrue(re.search(rb"<\/views>", request_body)) - def test_publish_with_query_params(self) -> None: with open(PUBLISH_ASYNC_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -775,63 +750,6 @@ def test_publish_multi_connection_flat(self) -> None: self.assertEqual(connection_results[1].get("serverAddress", None), "pgsql.test.com") self.assertEqual(connection_results[1].find("connectionCredentials").get("password", None), "secret") # type: ignore[union-attr] - def test_publish_single_connection(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials("test", "secret", True) - - response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) - # Can't use ConnectionItem parser due to xml namespace problems - credentials = fromstring(response).findall(".//connectionCredentials") - self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get("name", None), "test") - self.assertEqual(credentials[0].get("password", None), "secret") - self.assertEqual(credentials[0].get("embed", None), "true") - - def test_publish_single_connection_username_none(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials(None, "secret", True) - - self.assertRaises( - ValueError, - RequestFactory.Workbook._generate_xml, - new_workbook, - connection_credentials=connection_creds, - ) - - def test_publish_single_connection_username_empty(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - connection_creds = TSC.ConnectionCredentials("", "secret", True) - - response = RequestFactory.Workbook._generate_xml(new_workbook, connection_credentials=connection_creds) - # Can't use ConnectionItem parser due to xml namespace problems - credentials = fromstring(response).findall(".//connectionCredentials") - self.assertEqual(len(credentials), 1) - self.assertEqual(credentials[0].get("name", None), "") - self.assertEqual(credentials[0].get("password", None), "secret") - self.assertEqual(credentials[0].get("embed", None), "true") - - def test_credentials_and_multi_connect_raises_exception(self) -> None: - new_workbook = TSC.WorkbookItem( - name="Sample", show_tabs=False, project_id="ee8c6e70-43b6-11e6-af4f-f7b0d8e20760" - ) - - connection_creds = TSC.ConnectionCredentials("test", "secret", True) - - connection1 = TSC.ConnectionItem() - connection1.server_address = "mysql.test.com" - connection1.connection_credentials = TSC.ConnectionCredentials("test", "secret", True) - - with self.assertRaises(RuntimeError): - response = RequestFactory.Workbook._generate_xml( - new_workbook, connection_credentials=connection_creds, connections=[connection1] - ) - def test_synchronous_publish_timeout_error(self) -> None: with requests_mock.mock() as m: m.register_uri("POST", self.baseurl, status_code=504) From c2ab2bef78f92b04a977ae582d2974aeb3ee8ba3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:10:52 -0500 Subject: [PATCH 39/73] style: black --- tableauserverclient/server/request_factory.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 68889d4e5..fe268892a 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -418,7 +418,10 @@ def create_ad_req(self, group_item: GroupItem) -> bytes: import_element.attrib["siteRole"] = group_item.minimum_site_role return ET.tostring(xml_request) - def update_req(self, group_item: GroupItem, ) -> bytes: + def update_req( + self, + group_item: GroupItem, + ) -> bytes: xml_request = ET.Element("tsRequest") group_element = ET.SubElement(xml_request, "group") From 2c4b7871cd39df1fbf2336e05ff886b799aa36eb Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:25:32 -0500 Subject: [PATCH 40/73] chore: remove other deprecated methods --- .../server/endpoint/databases_endpoint.py | 11 ----------- .../server/endpoint/datasources_endpoint.py | 11 ----------- .../server/endpoint/flows_endpoint.py | 10 ---------- .../server/endpoint/groups_endpoint.py | 13 +------------ .../server/endpoint/projects_endpoint.py | 11 ----------- .../server/endpoint/tables_endpoint.py | 10 ---------- 6 files changed, 1 insertion(+), 65 deletions(-) diff --git a/tableauserverclient/server/endpoint/databases_endpoint.py b/tableauserverclient/server/endpoint/databases_endpoint.py index 125996277..849072a17 100644 --- a/tableauserverclient/server/endpoint/databases_endpoint.py +++ b/tableauserverclient/server/endpoint/databases_endpoint.py @@ -88,17 +88,6 @@ def _get_tables_for_database(self, database_item): def populate_permissions(self, item): self._permissions.populate(item) - @api(version="3.5") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.databases.update_permission is deprecated, " - "please use Server.databases.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="3.5") def update_permissions(self, item, rules): return self._permissions.update(item, rules) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 28226d280..3991456de 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -351,17 +351,6 @@ def update_hyper_data( def populate_permissions(self, item: DatasourceItem) -> None: self._permissions.populate(item) - @api(version="2.0") - def update_permission(self, item, permission_item): - import warnings - - warnings.warn( - "Server.datasources.update_permission is deprecated, " - "please use Server.datasources.update_permissions instead.", - DeprecationWarning, - ) - self._permissions.update(item, permission_item) - @api(version="2.0") def update_permissions(self, item: DatasourceItem, permission_item: List["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 77b01c478..a2d68a0d7 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -265,16 +265,6 @@ def publish( def populate_permissions(self, item: FlowItem) -> None: self._permissions.populate(item) - @api(version="3.3") - def update_permission(self, item, permission_item): - import warnings - - warnings.warn( - "Server.flows.update_permission is deprecated, " "please use Server.flows.update_permissions instead.", - DeprecationWarning, - ) - self._permissions.update(item, permission_item) - @api(version="3.3") def update_permissions(self, item: FlowItem, permission_item: Iterable["PermissionsRule"]) -> None: self._permissions.update(item, permission_item) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 148151d12..40e649c21 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -68,20 +68,9 @@ def delete(self, group_id: str) -> None: @api(version="2.0") def update( - self, group_item: GroupItem, default_site_role: Optional[str] = None, as_job: bool = False + self, group_item: GroupItem, as_job: bool = False ) -> Union[GroupItem, JobItem]: # (1/8/2021): Deprecated starting v0.15 - if default_site_role is not None: - import warnings - - warnings.simplefilter("always", DeprecationWarning) - warnings.warn( - 'Groups.update(...default_site_role=""...) is deprecated, ' - "please set the minimum_site_role field of GroupItem", - DeprecationWarning, - ) - group_item.minimum_site_role = default_site_role - url = "{0}/{1}".format(self.baseurl, group_item.id) if not group_item.id: diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 99bb2e39b..b56a480ec 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -75,17 +75,6 @@ def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte def populate_permissions(self, item: ProjectItem) -> None: self._permissions.populate(item) - @api(version="2.0") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.projects.update_permission is deprecated, " - "please use Server.projects.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="2.0") def update_permissions(self, item, rules): return self._permissions.update(item, rules) diff --git a/tableauserverclient/server/endpoint/tables_endpoint.py b/tableauserverclient/server/endpoint/tables_endpoint.py index dfb2e6d7c..b4c5181e9 100644 --- a/tableauserverclient/server/endpoint/tables_endpoint.py +++ b/tableauserverclient/server/endpoint/tables_endpoint.py @@ -101,16 +101,6 @@ def update_column(self, table_item, column_item): def populate_permissions(self, item): self._permissions.populate(item) - @api(version="3.5") - def update_permission(self, item, rules): - import warnings - - warnings.warn( - "Server.tables.update_permission is deprecated, " "please use Server.tables.update_permissions instead.", - DeprecationWarning, - ) - return self._permissions.update(item, rules) - @api(version="3.5") def update_permissions(self, item, rules): return self._permissions.update(item, rules) From 98d27b4357e43af04c83bb46473c515adbc7f75b Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:27:54 -0500 Subject: [PATCH 41/73] chore: black --- tableauserverclient/server/endpoint/groups_endpoint.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 40e649c21..286e8126c 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -67,9 +67,7 @@ def delete(self, group_id: str) -> None: logger.info("Deleted single group (ID: {0})".format(group_id)) @api(version="2.0") - def update( - self, group_item: GroupItem, as_job: bool = False - ) -> Union[GroupItem, JobItem]: + def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: # (1/8/2021): Deprecated starting v0.15 url = "{0}/{1}".format(self.baseurl, group_item.id) From 560fff82b68957ab37c99b7408b80d6c7cf619b0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 06:30:43 -0500 Subject: [PATCH 42/73] chore: remove comment --- tableauserverclient/server/endpoint/groups_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index 286e8126c..35f17e53b 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -68,7 +68,6 @@ def delete(self, group_id: str) -> None: @api(version="2.0") def update(self, group_item: GroupItem, as_job: bool = False) -> Union[GroupItem, JobItem]: - # (1/8/2021): Deprecated starting v0.15 url = "{0}/{1}".format(self.baseurl, group_item.id) if not group_item.id: From 83233a56bfd27d0439a6a60b88129a2290b24ec7 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 19:51:11 -0500 Subject: [PATCH 43/73] chore: remove hidden_views from wb publish --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index 8a5b7a112..e74329a35 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -310,7 +310,6 @@ def publish( mode: str, connections: Optional[Sequence[ConnectionItem]] = None, as_job: bool = False, - hidden_views: Optional[Sequence[str]] = None, skip_connection_check: bool = False, parameters=None, ): From d84adecd30cd89604cb475f2749b2ac49d13b08e Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 7 Jun 2024 06:46:43 -0500 Subject: [PATCH 44/73] chore: remove deprecated site arg in auth --- tableauserverclient/models/tableau_auth.py | 15 +-------------- test/test_tableauauth_model.py | 8 -------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 9aca206d7..8cb2a8848 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -28,10 +28,7 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - def __init__(self, username, password, site=None, site_id=None, user_id_to_impersonate=None): - if site is not None: - deprecate_site_attribute() - site_id = site + def __init__(self, username, password, site_id=None, user_id_to_impersonate=None): super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") @@ -49,16 +46,6 @@ def __repr__(self): uid = "" return f"" - @property - def site(self): - deprecate_site_attribute() - return self.site_id - - @site.setter - def site(self, value): - deprecate_site_attribute() - self.site_id = value - # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): diff --git a/test/test_tableauauth_model.py b/test/test_tableauauth_model.py index e8ae242d9..f1deed6a3 100644 --- a/test/test_tableauauth_model.py +++ b/test/test_tableauauth_model.py @@ -1,5 +1,4 @@ import unittest -import warnings import tableauserverclient as TSC @@ -12,10 +11,3 @@ def test_username_password_required(self): with self.assertRaises(TypeError): TSC.TableauAuth() - def test_site_arg_raises_warning(self): - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - - tableau_auth = TSC.TableauAuth("user", "password", site="Default") - - self.assertTrue(any(item.category == DeprecationWarning for item in w)) From 58bc727539a4ff0d89a41411a3722894c5a85ee9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 7 Jun 2024 06:47:42 -0500 Subject: [PATCH 45/73] chore: remove no_extract arg from workbook download --- .../server/endpoint/workbooks_endpoint.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index e74329a35..b5ac80982 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -182,9 +182,8 @@ def download( workbook_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> str: - return self.download_revision(workbook_id, None, filepath, include_extract, no_extract) + return self.download_revision(workbook_id, None, filepath, include_extract, ) # Get all views of workbook @api(version="2.0") @@ -445,7 +444,6 @@ def download_revision( revision_number: Optional[str], filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> PathOrFileW: if not workbook_id: error = "Workbook ID undefined." @@ -455,15 +453,6 @@ def download_revision( else: url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, workbook_id, revision_number) - if no_extract is False or no_extract is True: - import warnings - - warnings.warn( - "no_extract is deprecated, use include_extract instead.", - DeprecationWarning, - ) - include_extract = not no_extract - if not include_extract: url += "?includeExtract=False" From dd04bbd9f195dd1cee1604bebe5cfb9f25afb727 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 7 Jun 2024 07:28:03 -0500 Subject: [PATCH 46/73] style: black --- tableauserverclient/server/endpoint/workbooks_endpoint.py | 7 ++++++- test/test_tableauauth_model.py | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index b5ac80982..1cec71c08 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -183,7 +183,12 @@ def download( filepath: Optional[PathOrFileW] = None, include_extract: bool = True, ) -> str: - return self.download_revision(workbook_id, None, filepath, include_extract, ) + return self.download_revision( + workbook_id, + None, + filepath, + include_extract, + ) # Get all views of workbook @api(version="2.0") diff --git a/test/test_tableauauth_model.py b/test/test_tableauauth_model.py index f1deed6a3..195bcf0a9 100644 --- a/test/test_tableauauth_model.py +++ b/test/test_tableauauth_model.py @@ -10,4 +10,3 @@ def setUp(self): def test_username_password_required(self): with self.assertRaises(TypeError): TSC.TableauAuth() - From 281ae3e1763e11e09f0de1111710dd39bebd7e7e Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 7 Jun 2024 07:35:44 -0500 Subject: [PATCH 47/73] chore: remove deprecated arg from download datasource --- .../server/endpoint/datasources_endpoint.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 3991456de..69f6f9747 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -126,9 +126,13 @@ def download( datasource_id: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> str: - return self.download_revision(datasource_id, None, filepath, include_extract, no_extract) + return self.download_revision( + datasource_id, + None, + filepath, + include_extract, + ) # Update datasource @api(version="2.0") @@ -404,7 +408,6 @@ def download_revision( revision_number: str, filepath: Optional[PathOrFileW] = None, include_extract: bool = True, - no_extract: Optional[bool] = None, ) -> PathOrFileW: if not datasource_id: error = "Datasource ID undefined." @@ -413,14 +416,6 @@ def download_revision( url = "{0}/{1}/content".format(self.baseurl, datasource_id) else: url = "{0}/{1}/revisions/{2}/content".format(self.baseurl, datasource_id, revision_number) - if no_extract is False or no_extract is True: - import warnings - - warnings.warn( - "no_extract is deprecated, use include_extract instead.", - DeprecationWarning, - ) - include_extract = not no_extract if not include_extract: url += "?includeExtract=False" From e6900e0636cb2f7f4fb36b89a01bd405c959a26c Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Tue, 11 Jun 2024 08:38:21 -0500 Subject: [PATCH 48/73] fix: don't lowercase OData server addresses Closes #1392 OData strings are case sensitive. If the ConnectionItem has a connection_type indicating it is an OData connection, do not force the server address of the ConnectionItem to lowercase. --- tableauserverclient/server/request_factory.py | 9 ++++-- test/assets/odata_connection.xml | 7 +++++ test/test_workbook.py | 29 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 test/assets/odata_connection.xml diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index c204e7217..1336576b5 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1061,8 +1061,13 @@ class Connection(object): @_tsrequest_wrapped def update_req(self, xml_request: ET.Element, connection_item: "ConnectionItem") -> None: connection_element = ET.SubElement(xml_request, "connection") - if connection_item.server_address is not None: - connection_element.attrib["serverAddress"] = connection_item.server_address.lower() + if (server_address := connection_item.server_address) is not None: + if (conn_type := connection_item.connection_type) is not None: + if conn_type.casefold() != "odata".casefold(): + server_address = server_address.lower() + else: + server_address = server_address.lower() + connection_element.attrib["serverAddress"] = server_address if connection_item.server_port is not None: connection_element.attrib["serverPort"] = str(connection_item.server_port) if connection_item.username is not None: diff --git a/test/assets/odata_connection.xml b/test/assets/odata_connection.xml new file mode 100644 index 000000000..0c16fcca6 --- /dev/null +++ b/test/assets/odata_connection.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/test/test_workbook.py b/test/test_workbook.py index ac3d44b28..025fc55ab 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -22,6 +22,7 @@ GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_empty.xml") GET_INVALID_DATE_XML = os.path.join(TEST_ASSET_DIR, "workbook_get_invalid_date.xml") GET_XML = os.path.join(TEST_ASSET_DIR, "workbook_get.xml") +ODATA_XML = os.path.join(TEST_ASSET_DIR, "odata_connection.xml") POPULATE_CONNECTIONS_XML = os.path.join(TEST_ASSET_DIR, "workbook_populate_connections.xml") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") POPULATE_POWERPOINT = os.path.join(TEST_ASSET_DIR, "populate_powerpoint.pptx") @@ -944,3 +945,31 @@ def test_bad_download_response(self) -> None: ) file_path = self.server.workbooks.download("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", td) self.assertTrue(os.path.exists(file_path)) + + def test_odata_connection(self) -> None: + self.baseurl = self.server.workbooks.baseurl + workbook = TSC.WorkbookItem("project", "test") + workbook._id = "06b944d2-959d-4604-9305-12323c95e70e" + connection = TSC.ConnectionItem() + url = "https://odata.website.com/TestODataEndpoint" + connection.server_address = url + connection._connection_type = "odata" + connection._id = "17376070-64d1-4d17-acb4-a56e4b5b1768" + + creds = TSC.ConnectionCredentials("", "", True) + connection.connection_credentials = creds + with open(ODATA_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{workbook.id}/connections/{connection.id}", text=response_xml) + self.server.workbooks.update_connection(workbook, connection) + + history = m.request_history + + request = history[0] + xml = fromstring(request.body) + xml_connection = xml.find(".//connection") + + assert xml_connection is not None + self.assertEqual(xml_connection.get("serverAddress"), url) From b1b387355e3a90e2ac031a21747f29d8b7b8046a Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:34:37 -0500 Subject: [PATCH 49/73] feat: add size to datasource item --- tableauserverclient/models/datasource_item.py | 12 ++++++++++++ test/assets/datasource_get.xml | 6 +++--- test/test_datasource.py | 2 ++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 5a867135c..fb2db6663 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -47,6 +47,7 @@ def __init__(self, project_id: Optional[str] = None, name: Optional[str] = None) self._initial_tags: Set = set() self._project_name: Optional[str] = None self._revisions = None + self._size: Optional[int] = None self._updated_at = None self._use_remote_query_agent = None self._webpage_url = None @@ -182,6 +183,10 @@ def revisions(self) -> List[RevisionItem]: raise UnpopulatedPropertyError(error) return self._revisions() + @property + def size(self) -> Optional[int]: + return self._size + def _set_connections(self, connections): self._connections = connections @@ -217,6 +222,7 @@ def _parse_common_elements(self, datasource_xml, ns): updated_at, use_remote_query_agent, webpage_url, + size, ) = self._parse_element(datasource_xml, ns) self._set_values( ask_data_enablement, @@ -237,6 +243,7 @@ def _parse_common_elements(self, datasource_xml, ns): updated_at, use_remote_query_agent, webpage_url, + size, ) return self @@ -260,6 +267,7 @@ def _set_values( updated_at, use_remote_query_agent, webpage_url, + size, ): if ask_data_enablement is not None: self._ask_data_enablement = ask_data_enablement @@ -297,6 +305,8 @@ def _set_values( self._use_remote_query_agent = str(use_remote_query_agent).lower() == "true" if webpage_url: self._webpage_url = webpage_url + if size is not None: + self._size = int(size) @classmethod def from_response(cls, resp: str, ns: Dict) -> List["DatasourceItem"]: @@ -330,6 +340,7 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: has_extracts = datasource_xml.get("hasExtracts", None) use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) + size = datasource_xml.get("size", None) tags = None tags_elem = datasource_xml.find(".//t:tags", namespaces=ns) @@ -372,4 +383,5 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: updated_at, use_remote_query_agent, webpage_url, + size, ) diff --git a/test/assets/datasource_get.xml b/test/assets/datasource_get.xml index 951409caa..1c420d116 100644 --- a/test/assets/datasource_get.xml +++ b/test/assets/datasource_get.xml @@ -2,12 +2,12 @@ - + - + @@ -17,4 +17,4 @@ - \ No newline at end of file + diff --git a/test/test_datasource.py b/test/test_datasource.py index f258fdc52..624eb93e1 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -52,6 +52,7 @@ def test_get(self) -> None: self.assertEqual("dataengine", all_datasources[0].datasource_type) self.assertEqual("SampleDsDescription", all_datasources[0].description) self.assertEqual("SampleDS", all_datasources[0].content_url) + self.assertEqual(4096, all_datasources[0].size) self.assertEqual("2016-08-11T21:22:40Z", format_datetime(all_datasources[0].created_at)) self.assertEqual("2016-08-11T21:34:17Z", format_datetime(all_datasources[0].updated_at)) self.assertEqual("default", all_datasources[0].project_name) @@ -67,6 +68,7 @@ def test_get(self) -> None: self.assertEqual("dataengine", all_datasources[1].datasource_type) self.assertEqual("description Sample", all_datasources[1].description) self.assertEqual("Sampledatasource", all_datasources[1].content_url) + self.assertEqual(10240, all_datasources[1].size) self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].created_at)) self.assertEqual("2016-08-04T21:31:55Z", format_datetime(all_datasources[1].updated_at)) self.assertEqual("default", all_datasources[1].project_name) From 30100a07dd1b235dacd05c9fc629d8316b2daf61 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 14 Jun 2024 08:54:51 -0500 Subject: [PATCH 50/73] chore: remove outdated dependencies argparse and mock were listed as test dependencies, but both packages are part of the python standard library and do not need to be installed. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fceb37237..062e84109 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==23.7", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] From 8c8b88cc4abaf7ed3dd9f1c1ee5da9bd47f8f45d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Fri, 14 Jun 2024 09:05:31 -0500 Subject: [PATCH 51/73] ci: add dependency for build --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 062e84109..402b735b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ classifiers = [ repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["black==23.7", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", +test = ["black==23.7", "build", "mypy==1.4", "pytest>=7.0", "pytest-cov", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] From 71697916df84856c9f5408b2496b1702f9b095dc Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 9 Jun 2024 12:58:39 -0500 Subject: [PATCH 52/73] chore: no implicit reexport Mypy has a setting to check for implicit reexports. In separate testing I have found that explicit exports, like what is used in this commit, make it easier for language servers to detect what is actually exported by the library which makes use by end users easier. PEP8 specifies that "relative imports for intra-package imports are highly discouraged. Always use the absolute package path for all imports." This commit also makes imports absolute to comply with PEP8. --- pyproject.toml | 2 + tableauserverclient/__init__.py | 68 +++++++++- tableauserverclient/models/__init__.py | 125 ++++++++++++------ tableauserverclient/server/__init__.py | 91 +++++++++++-- .../server/endpoint/__init__.py | 89 +++++++++---- tableauserverclient/server/server.py | 11 +- 6 files changed, 303 insertions(+), 83 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 402b735b4..1ecb01f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ disable_error_code = [ files = ["tableauserverclient", "test"] show_error_codes = true ignore_missing_imports = true # defusedxml library has no types +no_implicit_reexport = true + [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index f093f521b..91205d810 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,6 @@ -from ._version import get_versions -from .namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE -from .models import ( +from tableauserverclient._version import get_versions +from tableauserverclient.namespace import NEW_NAMESPACE as DEFAULT_NAMESPACE +from tableauserverclient.models import ( BackgroundJobItem, ColumnItem, ConnectionCredentials, @@ -43,7 +43,8 @@ WeeklyInterval, WorkbookItem, ) -from .server import ( + +from tableauserverclient.server import ( CSVRequestOptions, ExcelRequestOptions, ImageRequestOptions, @@ -57,3 +58,62 @@ Server, Sort, ) + +__all__ = [ + "get_versions", + "DEFAULT_NAMESPACE", + "BackgroundJobItem", + "BackgroundJobItem", + "ColumnItem", + "ConnectionCredentials", + "ConnectionItem", + "CustomViewItem", + "DQWItem", + "DailyInterval", + "DataAlertItem", + "DatabaseItem", + "DataFreshnessPolicyItem", + "DatasourceItem", + "FavoriteItem", + "FlowItem", + "FlowRunItem", + "FileuploadItem", + "GroupItem", + "HourlyInterval", + "IntervalItem", + "JobItem", + "JWTAuth", + "MetricItem", + "MonthlyInterval", + "PaginationItem", + "Permission", + "PermissionsRule", + "PersonalAccessTokenAuth", + "ProjectItem", + "RevisionItem", + "ScheduleItem", + "SiteItem", + "ServerInfoItem", + "SubscriptionItem", + "TableItem", + "TableauAuth", + "Target", + "TaskItem", + "UserItem", + "ViewItem", + "WebhookItem", + "WeeklyInterval", + "WorkbookItem", + "CSVRequestOptions", + "ExcelRequestOptions", + "ImageRequestOptions", + "PDFRequestOptions", + "RequestOptions", + "MissingRequiredFieldError", + "NotSignedInError", + "ServerResponseError", + "Filter", + "Pager", + "Server", + "Sort", +] diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index e7a853d9a..5fdf3c2c3 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -1,43 +1,94 @@ -from .column_item import ColumnItem -from .connection_credentials import ConnectionCredentials -from .connection_item import ConnectionItem -from .custom_view_item import CustomViewItem -from .data_acceleration_report_item import DataAccelerationReportItem -from .data_alert_item import DataAlertItem -from .database_item import DatabaseItem -from .data_freshness_policy_item import DataFreshnessPolicyItem -from .datasource_item import DatasourceItem -from .dqw_item import DQWItem -from .exceptions import UnpopulatedPropertyError -from .favorites_item import FavoriteItem -from .fileupload_item import FileuploadItem -from .flow_item import FlowItem -from .flow_run_item import FlowRunItem -from .group_item import GroupItem -from .interval_item import ( +from tableauserverclient.models.column_item import ColumnItem +from tableauserverclient.models.connection_credentials import ConnectionCredentials +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.custom_view_item import CustomViewItem +from tableauserverclient.models.data_acceleration_report_item import DataAccelerationReportItem +from tableauserverclient.models.data_alert_item import DataAlertItem +from tableauserverclient.models.database_item import DatabaseItem +from tableauserverclient.models.data_freshness_policy_item import DataFreshnessPolicyItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.dqw_item import DQWItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.favorites_item import FavoriteItem +from tableauserverclient.models.fileupload_item import FileuploadItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.flow_run_item import FlowRunItem +from tableauserverclient.models.group_item import GroupItem +from tableauserverclient.models.interval_item import ( IntervalItem, DailyInterval, WeeklyInterval, MonthlyInterval, HourlyInterval, ) -from .job_item import JobItem, BackgroundJobItem -from .metric_item import MetricItem -from .pagination_item import PaginationItem -from .permissions_item import PermissionsRule, Permission -from .project_item import ProjectItem -from .revision_item import RevisionItem -from .schedule_item import ScheduleItem -from .server_info_item import ServerInfoItem -from .site_item import SiteItem -from .subscription_item import SubscriptionItem -from .table_item import TableItem -from .tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth -from .tableau_types import Resource, TableauItem, plural_type -from .tag_item import TagItem -from .target import Target -from .task_item import TaskItem -from .user_item import UserItem -from .view_item import ViewItem -from .webhook_item import WebhookItem -from .workbook_item import WorkbookItem +from tableauserverclient.models.job_item import JobItem, BackgroundJobItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.models.permissions_item import PermissionsRule, Permission +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.schedule_item import ScheduleItem +from tableauserverclient.models.server_info_item import ServerInfoItem +from tableauserverclient.models.site_item import SiteItem +from tableauserverclient.models.subscription_item import SubscriptionItem +from tableauserverclient.models.table_item import TableItem +from tableauserverclient.models.tableau_auth import Credentials, TableauAuth, PersonalAccessTokenAuth, JWTAuth +from tableauserverclient.models.tableau_types import Resource, TableauItem, plural_type +from tableauserverclient.models.tag_item import TagItem +from tableauserverclient.models.target import Target +from tableauserverclient.models.task_item import TaskItem +from tableauserverclient.models.user_item import UserItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.webhook_item import WebhookItem +from tableauserverclient.models.workbook_item import WorkbookItem + +__all__ = [ + "ColumnItem", + "ConnectionCredentials", + "ConnectionItem", + "Credentials", + "CustomViewItem", + "DataAccelerationReportItem", + "DataAlertItem", + "DatabaseItem", + "DataFreshnessPolicyItem", + "DatasourceItem", + "DQWItem", + "UnpopulatedPropertyError", + "FavoriteItem", + "FileuploadItem", + "FlowItem", + "FlowRunItem", + "GroupItem", + "IntervalItem", + "JobItem", + "DailyInterval", + "WeeklyInterval", + "MonthlyInterval", + "HourlyInterval", + "BackgroundJobItem", + "MetricItem", + "PaginationItem", + "Permission", + "PermissionsRule", + "ProjectItem", + "RevisionItem", + "ScheduleItem", + "ServerInfoItem", + "SiteItem", + "SubscriptionItem", + "TableItem", + "TableauAuth", + "PersonalAccessTokenAuth", + "JWTAuth", + "Resource", + "TableauItem", + "plural_type", + "TagItem", + "Target", + "TaskItem", + "UserItem", + "ViewItem", + "WebhookItem", + "WorkbookItem", +] diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 5abe19446..f5cd1d236 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -1,16 +1,91 @@ # These two imports must come first -from .request_factory import RequestFactory -from .request_options import ( +from tableauserverclient.server.request_factory import RequestFactory +from tableauserverclient.server.request_options import ( CSVRequestOptions, ExcelRequestOptions, ImageRequestOptions, PDFRequestOptions, RequestOptions, ) +from tableauserverclient.server.filter import Filter +from tableauserverclient.server.sort import Sort +from tableauserverclient.server.server import Server +from tableauserverclient.server.pager import Pager +from tableauserverclient.server.endpoint.exceptions import NotSignedInError -from .filter import Filter -from .sort import Sort -from .endpoint import * -from .server import Server -from .pager import Pager -from .endpoint.exceptions import NotSignedInError +from tableauserverclient.server.endpoint import ( + Auth, + CustomViews, + DataAccelerationReport, + DataAlerts, + Databases, + Datasources, + QuerysetEndpoint, + MissingRequiredFieldError, + Endpoint, + Favorites, + Fileuploads, + FlowRuns, + Flows, + FlowTasks, + Groups, + Jobs, + Metadata, + Metrics, + Projects, + Schedules, + ServerInfo, + ServerResponseError, + Sites, + Subscriptions, + Tables, + Tasks, + Users, + Views, + Webhooks, + Workbooks, +) + +__all__ = [ + "RequestFactory", + "CSVRequestOptions", + "ExcelRequestOptions", + "ImageRequestOptions", + "PDFRequestOptions", + "RequestOptions", + "Filter", + "Sort", + "Server", + "Pager", + "NotSignedInError", + "Auth", + "CustomViews", + "DataAccelerationReport", + "DataAlerts", + "Databases", + "Datasources", + "QuerysetEndpoint", + "MissingRequiredFieldError", + "Endpoint", + "Favorites", + "Fileuploads", + "FlowRuns", + "Flows", + "FlowTasks", + "Groups", + "Jobs", + "Metadata", + "Metrics", + "Projects", + "Schedules", + "ServerInfo", + "ServerResponseError", + "Sites", + "Subscriptions", + "Tables", + "Tasks", + "Users", + "Views", + "Webhooks", + "Workbooks", +] diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index b2f291369..024350aaa 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -1,28 +1,61 @@ -from .auth_endpoint import Auth -from .custom_views_endpoint import CustomViews -from .data_acceleration_report_endpoint import DataAccelerationReport -from .data_alert_endpoint import DataAlerts -from .databases_endpoint import Databases -from .datasources_endpoint import Datasources -from .endpoint import Endpoint, QuerysetEndpoint -from .exceptions import ServerResponseError, MissingRequiredFieldError -from .favorites_endpoint import Favorites -from .fileuploads_endpoint import Fileuploads -from .flow_runs_endpoint import FlowRuns -from .flows_endpoint import Flows -from .flow_task_endpoint import FlowTasks -from .groups_endpoint import Groups -from .jobs_endpoint import Jobs -from .metadata_endpoint import Metadata -from .metrics_endpoint import Metrics -from .projects_endpoint import Projects -from .schedules_endpoint import Schedules -from .server_info_endpoint import ServerInfo -from .sites_endpoint import Sites -from .subscriptions_endpoint import Subscriptions -from .tables_endpoint import Tables -from .tasks_endpoint import Tasks -from .users_endpoint import Users -from .views_endpoint import Views -from .webhooks_endpoint import Webhooks -from .workbooks_endpoint import Workbooks +from tableauserverclient.server.endpoint.auth_endpoint import Auth +from tableauserverclient.server.endpoint.custom_views_endpoint import CustomViews +from tableauserverclient.server.endpoint.data_acceleration_report_endpoint import DataAccelerationReport +from tableauserverclient.server.endpoint.data_alert_endpoint import DataAlerts +from tableauserverclient.server.endpoint.databases_endpoint import Databases +from tableauserverclient.server.endpoint.datasources_endpoint import Datasources +from tableauserverclient.server.endpoint.endpoint import Endpoint, QuerysetEndpoint +from tableauserverclient.server.endpoint.exceptions import ServerResponseError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.favorites_endpoint import Favorites +from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads +from tableauserverclient.server.endpoint.flow_runs_endpoint import FlowRuns +from tableauserverclient.server.endpoint.flows_endpoint import Flows +from tableauserverclient.server.endpoint.flow_task_endpoint import FlowTasks +from tableauserverclient.server.endpoint.groups_endpoint import Groups +from tableauserverclient.server.endpoint.jobs_endpoint import Jobs +from tableauserverclient.server.endpoint.metadata_endpoint import Metadata +from tableauserverclient.server.endpoint.metrics_endpoint import Metrics +from tableauserverclient.server.endpoint.projects_endpoint import Projects +from tableauserverclient.server.endpoint.schedules_endpoint import Schedules +from tableauserverclient.server.endpoint.server_info_endpoint import ServerInfo +from tableauserverclient.server.endpoint.sites_endpoint import Sites +from tableauserverclient.server.endpoint.subscriptions_endpoint import Subscriptions +from tableauserverclient.server.endpoint.tables_endpoint import Tables +from tableauserverclient.server.endpoint.tasks_endpoint import Tasks +from tableauserverclient.server.endpoint.users_endpoint import Users +from tableauserverclient.server.endpoint.views_endpoint import Views +from tableauserverclient.server.endpoint.webhooks_endpoint import Webhooks +from tableauserverclient.server.endpoint.workbooks_endpoint import Workbooks + +__all__ = [ + "Auth", + "CustomViews", + "DataAccelerationReport", + "DataAlerts", + "Databases", + "Datasources", + "QuerysetEndpoint", + "MissingRequiredFieldError", + "Endpoint", + "Favorites", + "Fileuploads", + "FlowRuns", + "Flows", + "FlowTasks", + "Groups", + "Jobs", + "Metadata", + "Metrics", + "Projects", + "Schedules", + "ServerInfo", + "ServerResponseError", + "Sites", + "Subscriptions", + "Tables", + "Tasks", + "Users", + "Views", + "Webhooks", + "Workbooks", +] diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 3a6831458..10b1a53ad 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -5,9 +5,7 @@ from defusedxml.ElementTree import fromstring, ParseError from packaging.version import Version - -from . import CustomViews -from .endpoint import ( +from tableauserverclient.server.endpoint import ( Sites, Views, Users, @@ -34,13 +32,14 @@ FlowRuns, Metrics, Endpoint, + CustomViews, ) -from .exceptions import ( +from tableauserverclient.server.exceptions import ( ServerInfoEndpointNotFoundError, EndpointUnavailableError, ) -from .endpoint.exceptions import NotSignedInError -from ..namespace import Namespace +from tableauserverclient.server.endpoint.exceptions import NotSignedInError +from tableauserverclient.namespace import Namespace _PRODUCT_TO_REST_VERSION = { From 6e68d8b20f842daabf6b3b0e998141c8d8daace9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 5 Jun 2024 20:43:20 -0500 Subject: [PATCH 53/73] chore: add typing to Pager --- tableauserverclient/server/pager.py | 93 +++++++++++++++-------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 3220f5372..21b5a4ed0 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,9 +1,28 @@ +import copy from functools import partial +from typing import Generic, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable -from . import RequestOptions +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.request_options import RequestOptions -class Pager(object): +T = TypeVar("T") +ReturnType = Tuple[List[T], PaginationItem] + + +@runtime_checkable +class Endpoint(Protocol): + def get(self, req_options: Optional[RequestOptions], **kwargs) -> ReturnType: + ... + + +@runtime_checkable +class CallableEndpoint(Protocol): + def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> ReturnType: + ... + + +class Pager(Generic[T]): """ Generator that takes an endpoint (top level endpoints with `.get)` and lazily loads items from Server. Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models @@ -12,12 +31,17 @@ class Pager(object): Will loop over anything that returns (List[ModelItem], PaginationItem). """ - def __init__(self, endpoint, request_opts=None, **kwargs): - if hasattr(endpoint, "get"): + def __init__( + self, + endpoint: Union[CallableEndpoint, Endpoint], + request_opts: Optional[RequestOptions] = None, + **kwargs, + ) -> None: + if isinstance(endpoint, Endpoint): # The simpliest case is to take an Endpoint and call its get endpoint = partial(endpoint.get, **kwargs) self._endpoint = endpoint - elif callable(endpoint): + elif isinstance(endpoint, CallableEndpoint): # but if they pass a callable then use that instead (used internally) endpoint = partial(endpoint, **kwargs) self._endpoint = endpoint @@ -25,47 +49,24 @@ def __init__(self, endpoint, request_opts=None, **kwargs): # Didn't get something we can page over raise ValueError("Pager needs a server endpoint to page through.") - self._options = request_opts + self._options = request_opts or RequestOptions() - # If we have options we could be starting on any page, backfill the count - if self._options: - self._count = (self._options.pagenumber - 1) * self._options.pagesize - else: - self._count = 0 - self._options = RequestOptions() - - def __iter__(self): - # Fetch the first page - current_item_list, last_pagination_item = self._endpoint(self._options) - - if last_pagination_item.total_available is None: - # This endpoint does not support pagination, drain the list and return - while current_item_list: - yield current_item_list.pop(0) - - return - - # Get the rest on demand as a generator - while self._count < last_pagination_item.total_available: - if ( - len(current_item_list) == 0 - and (last_pagination_item.page_number * last_pagination_item.page_size) - < last_pagination_item.total_available - ): - current_item_list, last_pagination_item = self._load_next_page(last_pagination_item) - - try: - yield current_item_list.pop(0) - self._count += 1 - - except IndexError: - # The total count on Server changed while fetching exit gracefully + def __iter__(self) -> Iterator[T]: + options = copy.deepcopy(self._options) + while True: + # Fetch the first page + current_item_list, pagination_item = self._endpoint(options) + + if pagination_item.total_available is None: + # This endpoint does not support pagination, drain the list and return + yield from current_item_list + return + yield from current_item_list + + if pagination_item.page_size * pagination_item.page_number >= pagination_item.total_available: + # Last page, exit return - def _load_next_page(self, last_pagination_item): - next_page = last_pagination_item.page_number + 1 - opts = RequestOptions(pagenumber=next_page, pagesize=last_pagination_item.page_size) - if self._options is not None: - opts.sort, opts.filter = self._options.sort, self._options.filter - current_item_list, last_pagination_item = self._endpoint(opts) - return current_item_list, last_pagination_item + # Update the options to fetch the next page + options.pagenumber = pagination_item.page_number + 1 + options.pagesize = pagination_item.page_size From c5d6abcaabf6a44a510f9aaab26715c915410ef6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 30 May 2024 20:33:46 -0500 Subject: [PATCH 54/73] feat: add usage to views.get_by_id --- .../server/endpoint/views_endpoint.py | 4 +++- test/assets/view_get_id_usage.xml | 13 ++++++++++++ test/test_view.py | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 test/assets/view_get_id_usage.xml diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 9c4b90657..c2075dbd2 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -50,12 +50,14 @@ def get( return all_view_items, pagination_item @api(version="3.1") - def get_by_id(self, view_id: str) -> ViewItem: + def get_by_id(self, view_id: str, usage: bool = False) -> ViewItem: if not view_id: error = "View item missing ID." raise MissingRequiredFieldError(error) logger.info("Querying single view (ID: {0})".format(view_id)) url = "{0}/{1}".format(self.baseurl, view_id) + if usage: + url += "?includeUsageStatistics=true" server_response = self.get_request(url) return ViewItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/test/assets/view_get_id_usage.xml b/test/assets/view_get_id_usage.xml new file mode 100644 index 000000000..a0cdd98db --- /dev/null +++ b/test/assets/view_get_id_usage.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/test/test_view.py b/test/test_view.py index 720a0ce64..1c667a4c3 100644 --- a/test/test_view.py +++ b/test/test_view.py @@ -13,6 +13,7 @@ GET_XML = os.path.join(TEST_ASSET_DIR, "view_get.xml") GET_XML_ID = os.path.join(TEST_ASSET_DIR, "view_get_id.xml") GET_XML_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_usage.xml") +GET_XML_ID_USAGE = os.path.join(TEST_ASSET_DIR, "view_get_id_usage.xml") POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png") POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf") POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv") @@ -81,6 +82,25 @@ def test_get_by_id(self) -> None: self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) self.assertEqual("story", view.sheet_type) + def test_get_by_id_usage(self) -> None: + with open(GET_XML_ID_USAGE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5?includeUsageStatistics=true", text=response_xml) + view = self.server.views.get_by_id("d79634e1-6063-4ec9-95ff-50acbf609ff5", usage=True) + + self.assertEqual("d79634e1-6063-4ec9-95ff-50acbf609ff5", view.id) + self.assertEqual("ENDANGERED SAFARI", view.name) + self.assertEqual("SafariSample/sheets/ENDANGEREDSAFARI", view.content_url) + self.assertEqual("3cc6cd06-89ce-4fdc-b935-5294135d6d42", view.workbook_id) + self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", view.owner_id) + self.assertEqual("5241e88d-d384-4fd7-9c2f-648b5247efc5", view.project_id) + self.assertEqual(set(["tag1", "tag2"]), view.tags) + self.assertEqual("2002-05-30T09:00:00Z", format_datetime(view.created_at)) + self.assertEqual("2002-06-05T08:00:59Z", format_datetime(view.updated_at)) + self.assertEqual("story", view.sheet_type) + self.assertEqual(7, view.total_views) + def test_get_by_id_missing_id(self) -> None: self.assertRaises(TSC.MissingRequiredFieldError, self.server.views.get_by_id, None) From ff7ab6514cab92cd973154c7497661ae3b5eec99 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 16 Jun 2024 20:36:12 -0500 Subject: [PATCH 55/73] chore: make pager generic type more specific --- tableauserverclient/server/pager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/pager.py b/tableauserverclient/server/pager.py index 21b5a4ed0..fede56012 100644 --- a/tableauserverclient/server/pager.py +++ b/tableauserverclient/server/pager.py @@ -1,6 +1,6 @@ import copy from functools import partial -from typing import Generic, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable +from typing import Generic, Iterable, Iterator, List, Optional, Protocol, Tuple, TypeVar, Union, runtime_checkable from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.request_options import RequestOptions @@ -22,7 +22,7 @@ def __call__(self, __req_options: Optional[RequestOptions], **kwargs) -> ReturnT ... -class Pager(Generic[T]): +class Pager(Iterable[T]): """ Generator that takes an endpoint (top level endpoints with `.get)` and lazily loads items from Server. Supports all `RequestOptions` including starting on any page. Also used by models to load sub-models From c7cec8592bafce238644926e9971034631882017 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 16 Jun 2024 22:17:21 -0500 Subject: [PATCH 56/73] chore: type hint QuerySet and QuerySetEndpoint --- .../server/endpoint/custom_views_endpoint.py | 2 +- .../server/endpoint/datasources_endpoint.py | 2 +- .../server/endpoint/endpoint.py | 27 +++++-- .../server/endpoint/flow_runs_endpoint.py | 2 +- .../server/endpoint/flows_endpoint.py | 2 +- .../server/endpoint/groups_endpoint.py | 2 +- .../server/endpoint/jobs_endpoint.py | 2 +- .../server/endpoint/metrics_endpoint.py | 2 +- .../server/endpoint/projects_endpoint.py | 2 +- .../server/endpoint/users_endpoint.py | 2 +- .../server/endpoint/views_endpoint.py | 2 +- .../server/endpoint/workbooks_endpoint.py | 2 +- tableauserverclient/server/query.py | 71 ++++++++++++------- 13 files changed, 78 insertions(+), 42 deletions(-) diff --git a/tableauserverclient/server/endpoint/custom_views_endpoint.py b/tableauserverclient/server/endpoint/custom_views_endpoint.py index 119580609..d1446b1fe 100644 --- a/tableauserverclient/server/endpoint/custom_views_endpoint.py +++ b/tableauserverclient/server/endpoint/custom_views_endpoint.py @@ -17,7 +17,7 @@ """ -class CustomViews(QuerysetEndpoint): +class CustomViews(QuerysetEndpoint[CustomViewItem]): def __init__(self, parent_srv): super(CustomViews, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 28226d280..da2ee3def 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -54,7 +54,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Datasources(QuerysetEndpoint): +class Datasources(QuerysetEndpoint[DatasourceItem]): def __init__(self, parent_srv: "Server") -> None: super(Datasources, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index 2b7f57069..d9dac47b2 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -1,9 +1,13 @@ from tableauserverclient import datetime_helpers as datetime +import abc from packaging.version import Version from functools import wraps from xml.etree.ElementTree import ParseError -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING, Union +from typing import Any, Callable, Dict, Generic, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union + +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.request_options import RequestOptions from .exceptions import ( ServerResponseError, @@ -300,25 +304,36 @@ def wrapper(self, *args, **kwargs): return _decorator -class QuerysetEndpoint(Endpoint): +T = TypeVar("T") + + +class QuerysetEndpoint(Endpoint, Generic[T]): @api(version="2.0") - def all(self, *args, **kwargs): + def all(self, *args, **kwargs) -> QuerySet[T]: + if args or kwargs: + raise ValueError(".all method takes no arguments.") queryset = QuerySet(self) return queryset @api(version="2.0") - def filter(self, *_, **kwargs) -> QuerySet: + def filter(self, *_, **kwargs) -> QuerySet[T]: if _: raise RuntimeError("Only keyword arguments accepted.") queryset = QuerySet(self).filter(**kwargs) return queryset @api(version="2.0") - def order_by(self, *args, **kwargs): + def order_by(self, *args, **kwargs) -> QuerySet[T]: + if kwargs: + raise ValueError(".order_by does not accept keyword arguments.") queryset = QuerySet(self).order_by(*args) return queryset @api(version="2.0") - def paginate(self, **kwargs): + def paginate(self, **kwargs) -> QuerySet[T]: queryset = QuerySet(self).paginate(**kwargs) return queryset + + @abc.abstractmethod + def get(self, request_options: RequestOptions) -> Tuple[List[T], PaginationItem]: + raise NotImplementedError(f".get has not been implemented for {self.__class__.__qualname__}") diff --git a/tableauserverclient/server/endpoint/flow_runs_endpoint.py b/tableauserverclient/server/endpoint/flow_runs_endpoint.py index 63b32e006..ea45ce802 100644 --- a/tableauserverclient/server/endpoint/flow_runs_endpoint.py +++ b/tableauserverclient/server/endpoint/flow_runs_endpoint.py @@ -13,7 +13,7 @@ from ..request_options import RequestOptions -class FlowRuns(QuerysetEndpoint): +class FlowRuns(QuerysetEndpoint[FlowRunItem]): def __init__(self, parent_srv: "Server") -> None: super(FlowRuns, self).__init__(parent_srv) return None diff --git a/tableauserverclient/server/endpoint/flows_endpoint.py b/tableauserverclient/server/endpoint/flows_endpoint.py index 77b01c478..e392d807d 100644 --- a/tableauserverclient/server/endpoint/flows_endpoint.py +++ b/tableauserverclient/server/endpoint/flows_endpoint.py @@ -50,7 +50,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Flows(QuerysetEndpoint): +class Flows(QuerysetEndpoint[FlowItem]): def __init__(self, parent_srv): super(Flows, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ab5f672d1..caa928f88 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -14,7 +14,7 @@ from ..request_options import RequestOptions -class Groups(QuerysetEndpoint): +class Groups(QuerysetEndpoint[GroupItem]): @property def baseurl(self) -> str: return "{0}/sites/{1}/groups".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/jobs_endpoint.py b/tableauserverclient/server/endpoint/jobs_endpoint.py index d0b865e21..74770e22b 100644 --- a/tableauserverclient/server/endpoint/jobs_endpoint.py +++ b/tableauserverclient/server/endpoint/jobs_endpoint.py @@ -11,7 +11,7 @@ from typing import List, Optional, Tuple, Union -class Jobs(QuerysetEndpoint): +class Jobs(QuerysetEndpoint[JobItem]): @property def baseurl(self): return "{0}/sites/{1}/jobs".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/metrics_endpoint.py b/tableauserverclient/server/endpoint/metrics_endpoint.py index a0e984475..ab1ec5852 100644 --- a/tableauserverclient/server/endpoint/metrics_endpoint.py +++ b/tableauserverclient/server/endpoint/metrics_endpoint.py @@ -18,7 +18,7 @@ from tableauserverclient.helpers.logging import logger -class Metrics(QuerysetEndpoint): +class Metrics(QuerysetEndpoint[MetricItem]): def __init__(self, parent_srv: "Server") -> None: super(Metrics, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 99bb2e39b..7645e72eb 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -16,7 +16,7 @@ from tableauserverclient.helpers.logging import logger -class Projects(QuerysetEndpoint): +class Projects(QuerysetEndpoint[ProjectItem]): def __init__(self, parent_srv: "Server") -> None: super(Projects, self).__init__(parent_srv) diff --git a/tableauserverclient/server/endpoint/users_endpoint.py b/tableauserverclient/server/endpoint/users_endpoint.py index e8c5cc962..a84ca7399 100644 --- a/tableauserverclient/server/endpoint/users_endpoint.py +++ b/tableauserverclient/server/endpoint/users_endpoint.py @@ -11,7 +11,7 @@ from tableauserverclient.helpers.logging import logger -class Users(QuerysetEndpoint): +class Users(QuerysetEndpoint[UserItem]): @property def baseurl(self) -> str: return "{0}/sites/{1}/users".format(self.parent_srv.baseurl, self.parent_srv.site_id) diff --git a/tableauserverclient/server/endpoint/views_endpoint.py b/tableauserverclient/server/endpoint/views_endpoint.py index 9c4b90657..87a77053f 100644 --- a/tableauserverclient/server/endpoint/views_endpoint.py +++ b/tableauserverclient/server/endpoint/views_endpoint.py @@ -21,7 +21,7 @@ ) -class Views(QuerysetEndpoint): +class Views(QuerysetEndpoint[ViewItem]): def __init__(self, parent_srv): super(Views, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/endpoint/workbooks_endpoint.py b/tableauserverclient/server/endpoint/workbooks_endpoint.py index bc535b2d6..5b4b29969 100644 --- a/tableauserverclient/server/endpoint/workbooks_endpoint.py +++ b/tableauserverclient/server/endpoint/workbooks_endpoint.py @@ -56,7 +56,7 @@ PathOrFileW = Union[FilePath, FileObjectW] -class Workbooks(QuerysetEndpoint): +class Workbooks(QuerysetEndpoint[WorkbookItem]): def __init__(self, parent_srv: "Server") -> None: super(Workbooks, self).__init__(parent_srv) self._resource_tagger = _ResourceTagger(parent_srv) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index c5613b2d6..d52332622 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,9 +1,25 @@ -from typing import Tuple -from .filter import Filter -from .request_options import RequestOptions -from .sort import Sort +from collections.abc import Iterable, Sized +from itertools import count +from typing import Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from tableauserverclient.models.pagination_item import PaginationItem +from tableauserverclient.server.filter import Filter +from tableauserverclient.server.request_options import RequestOptions +from tableauserverclient.server.sort import Sort import math +from typing_extensions import Self + +if TYPE_CHECKING: + from tableauserverclient.server.endpoint import QuerysetEndpoint + +T = TypeVar("T") + + +class Slice(Protocol): + start: Optional[int] + step: Optional[int] + stop: Optional[int] + def to_camel_case(word: str) -> str: return word.split("_")[0] + "".join(x.capitalize() or "_" for x in word.split("_")[1:]) @@ -16,28 +32,33 @@ def to_camel_case(word: str) -> str: """ -class QuerySet: - def __init__(self, model): +class QuerySet(Iterable[T], Sized): + def __init__(self, model: "QuerysetEndpoint[T]") -> None: self.model = model self.request_options = RequestOptions() - self._result_cache = None - self._pagination_item = None + self._result_cache: List[T] = [] + self._pagination_item = PaginationItem() - def __iter__(self): + def __iter__(self) -> Iterator[T]: # Not built to be re-entrant. Starts back at page 1, and empties # the result cache. - self.request_options.pagenumber = 1 - self._result_cache = None - total = self.total_available - size = self.page_size - yield from self._result_cache - # Loop through the subsequent pages. - for page in range(1, math.ceil(total / size)): - self.request_options.pagenumber = page + 1 - self._result_cache = None + for page in count(1): + self.request_options.pagenumber = page self._fetch_all() yield from self._result_cache + # Set result_cache to empty so the fetch will populate + self._result_cache = [] + if (page * self.page_size) >= len(self): + return + + @overload + def __getitem__(self, k: Slice) -> List[T]: + ... + + @overload + def __getitem__(self, k: int) -> T: + ... def __getitem__(self, k): page = self.page_number @@ -78,7 +99,7 @@ def __getitem__(self, k): return self._result_cache[k % size] elif k in range(self.total_available): # Otherwise, check if k is even sensible to return - self._result_cache = None + self._result_cache = [] # Add one to k, otherwise it gets stuck at page boundaries, e.g. 100 self.request_options.pagenumber = max(1, math.ceil((k + 1) / size)) return self[k] @@ -86,11 +107,11 @@ def __getitem__(self, k): # If k is unreasonable, raise an IndexError. raise IndexError - def _fetch_all(self): + def _fetch_all(self) -> None: """ Retrieve the data and store result and pagination item in cache """ - if self._result_cache is None: + if not self._result_cache: self._result_cache, self._pagination_item = self.model.get(self.request_options) def __len__(self) -> int: @@ -111,21 +132,21 @@ def page_size(self) -> int: self._fetch_all() return self._pagination_item.page_size - def filter(self, *invalid, **kwargs): + def filter(self, *invalid, **kwargs) -> Self: if invalid: - raise RuntimeError(f"Only accepts keyword arguments.") + raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): field_name, operator = self._parse_shorthand_filter(kwarg_key) self.request_options.filter.add(Filter(field_name, operator, value)) return self - def order_by(self, *args): + def order_by(self, *args) -> Self: for arg in args: field_name, direction = self._parse_shorthand_sort(arg) self.request_options.sort.add(Sort(field_name, direction)) return self - def paginate(self, **kwargs): + def paginate(self, **kwargs) -> Self: if "page_number" in kwargs: self.request_options.pagenumber = kwargs["page_number"] if "page_size" in kwargs: From f7524e8dfc819fa645de27374834e6b8e3e1faa6 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 16 Jun 2024 22:24:38 -0500 Subject: [PATCH 57/73] fix: make 3.8 friendly --- tableauserverclient/server/query.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index d52332622..373987f31 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -1,6 +1,6 @@ -from collections.abc import Iterable, Sized +from collections.abc import Sized from itertools import count -from typing import Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload +from typing import Iterable, Iterator, List, Optional, Protocol, Tuple, TYPE_CHECKING, TypeVar, overload from tableauserverclient.models.pagination_item import PaginationItem from tableauserverclient.server.filter import Filter from tableauserverclient.server.request_options import RequestOptions From 3ae6de8472d8c41d44c02d663b6b5001e94238d3 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 17 Jun 2024 06:05:43 -0500 Subject: [PATCH 58/73] fix: ensure result_cache is empty before looping --- tableauserverclient/server/query.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 373987f31..ad9b6f291 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -41,7 +41,9 @@ def __init__(self, model: "QuerysetEndpoint[T]") -> None: def __iter__(self) -> Iterator[T]: # Not built to be re-entrant. Starts back at page 1, and empties - # the result cache. + # the result cache. Ensure the result_cache is empty to not yield + # items from prior usage. + self._result_cache = [] for page in count(1): self.request_options.pagenumber = page From 35643e540932d25370248722d7c9a98e18ec7971 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 17 Jun 2024 06:30:52 -0500 Subject: [PATCH 59/73] chore: add self type hints --- tableauserverclient/server/query.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index ad9b6f291..99e70894d 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -39,7 +39,7 @@ def __init__(self, model: "QuerysetEndpoint[T]") -> None: self._result_cache: List[T] = [] self._pagination_item = PaginationItem() - def __iter__(self) -> Iterator[T]: + def __iter__(self: Self) -> Iterator[T]: # Not built to be re-entrant. Starts back at page 1, and empties # the result cache. Ensure the result_cache is empty to not yield # items from prior usage. @@ -55,11 +55,11 @@ def __iter__(self) -> Iterator[T]: return @overload - def __getitem__(self, k: Slice) -> List[T]: + def __getitem__(self: Self, k: Slice) -> List[T]: ... @overload - def __getitem__(self, k: int) -> T: + def __getitem__(self: Self, k: int) -> T: ... def __getitem__(self, k): @@ -109,32 +109,32 @@ def __getitem__(self, k): # If k is unreasonable, raise an IndexError. raise IndexError - def _fetch_all(self) -> None: + def _fetch_all(self: Self) -> None: """ Retrieve the data and store result and pagination item in cache """ if not self._result_cache: self._result_cache, self._pagination_item = self.model.get(self.request_options) - def __len__(self) -> int: + def __len__(self: Self) -> int: return self.total_available @property - def total_available(self) -> int: + def total_available(self: Self) -> int: self._fetch_all() return self._pagination_item.total_available @property - def page_number(self) -> int: + def page_number(self: Self) -> int: self._fetch_all() return self._pagination_item.page_number @property - def page_size(self) -> int: + def page_size(self: Self) -> int: self._fetch_all() return self._pagination_item.page_size - def filter(self, *invalid, **kwargs) -> Self: + def filter(self: Self, *invalid, **kwargs) -> Self: if invalid: raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): @@ -142,20 +142,20 @@ def filter(self, *invalid, **kwargs) -> Self: self.request_options.filter.add(Filter(field_name, operator, value)) return self - def order_by(self, *args) -> Self: + def order_by(self: Self, *args) -> Self: for arg in args: field_name, direction = self._parse_shorthand_sort(arg) self.request_options.sort.add(Sort(field_name, direction)) return self - def paginate(self, **kwargs) -> Self: + def paginate(self: Self, **kwargs) -> Self: if "page_number" in kwargs: self.request_options.pagenumber = kwargs["page_number"] if "page_size" in kwargs: self.request_options.pagesize = kwargs["page_size"] return self - def _parse_shorthand_filter(self, key: str) -> Tuple[str, str]: + def _parse_shorthand_filter(self: Self, key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals @@ -169,7 +169,7 @@ def _parse_shorthand_filter(self, key: str) -> Tuple[str, str]: raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) - def _parse_shorthand_sort(self, key: str) -> Tuple[str, str]: + def _parse_shorthand_sort(self: Self, key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc From de32333a989b76a30161a2a4bec5c1672efa3914 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 19 Jun 2024 07:35:28 -0500 Subject: [PATCH 60/73] feat: add with_page_size method onto QuerySet --- tableauserverclient/server/query.py | 4 ++++ test/test_request_option.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 99e70894d..98eb88a07 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -155,6 +155,10 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self + def with_page_size(self: Self, value: int) -> Self: + self.request_options.pagesize = value + return self + def _parse_shorthand_filter(self: Self, key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: diff --git a/test/test_request_option.py b/test/test_request_option.py index 40dd3345a..a9d2941c0 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -331,3 +331,15 @@ def test_filtering_parameters(self) -> None: self.assertIn("value2", query_params["name2$"]) self.assertIn("type", query_params) self.assertIn("tabloid", query_params["type"]) + + def test_queryset_pagesize(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get( + f"{self.baseurl}/views?pageSize={page_size}", + text=SLICING_QUERYSET_PAGE_1.read_text() + ) + _ = self.server.views.all().with_page_size(page_size) + + From c9b92ecaa4e9a2a6baa4cc5f6cfc83b4f9a45781 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 19 Jun 2024 07:37:01 -0500 Subject: [PATCH 61/73] style: black --- test/test_request_option.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test/test_request_option.py b/test/test_request_option.py index a9d2941c0..9870695d9 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -336,10 +336,5 @@ def test_queryset_pagesize(self) -> None: for page_size in (1, 10, 100, 1000): with self.subTest(page_size): with requests_mock.mock() as m: - m.get( - f"{self.baseurl}/views?pageSize={page_size}", - text=SLICING_QUERYSET_PAGE_1.read_text() - ) + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) _ = self.server.views.all().with_page_size(page_size) - - From 7b0cd6aeade0799230c7b8d888dc5861d5e3daac Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 19 Jun 2024 07:43:24 -0500 Subject: [PATCH 62/73] fix: ensure queryset iterator is called in test --- test/test_request_option.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_request_option.py b/test/test_request_option.py index 9870695d9..5ade81ea1 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -337,4 +337,5 @@ def test_queryset_pagesize(self) -> None: with self.subTest(page_size): with requests_mock.mock() as m: m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) - _ = self.server.views.all().with_page_size(page_size) + queryset = self.server.views.all().with_page_size(page_size) + _ = list(queryset) From 2def515bd92168a49eb3049024e0eb40f1735048 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Wed, 19 Jun 2024 09:33:14 -0500 Subject: [PATCH 63/73] fix: change when result cache gets emptied There are many methods on QuerySet that implicitly call fetch_all. This moves emptying the result cache to immediately before the explicit call to fetch_call after the page number has been updated. This ensures that the correct latest page is fetched. --- tableauserverclient/server/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 98eb88a07..51c34d082 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -47,10 +47,10 @@ def __iter__(self: Self) -> Iterator[T]: for page in count(1): self.request_options.pagenumber = page + self._result_cache = [] self._fetch_all() yield from self._result_cache # Set result_cache to empty so the fetch will populate - self._result_cache = [] if (page * self.page_size) >= len(self): return From a5c28dacc123a3b3c7970a8630b11f5d06ecb0ad Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 20 Jun 2024 06:35:35 -0500 Subject: [PATCH 64/73] chore: type hint auth models --- tableauserverclient/models/tableau_auth.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 8cb2a8848..76f5c38e4 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,16 +1,18 @@ import abc +from typing import Optional class Credentials(abc.ABC): - def __init__(self, site_id=None, user_id_to_impersonate=None): + def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: self.site_id = site_id or "" self.user_id_to_impersonate = user_id_to_impersonate or None @property @abc.abstractmethod - def credentials(self): - credentials = "Credentials can be username/password, Personal Access Token, or JWT" - +"This method returns values to set as an attribute on the credentials element of the request" + def credentials(self) -> dict[str, str]: + credentials = ("Credentials can be username/password, Personal Access Token, or JWT" + "This method returns values to set as an attribute on the credentials element of the request") + return {"key": "value"} @abc.abstractmethod def __repr__(self): @@ -28,7 +30,7 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - def __init__(self, username, password, site_id=None, user_id_to_impersonate=None): + def __init__(self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") @@ -36,7 +38,7 @@ def __init__(self, username, password, site_id=None, user_id_to_impersonate=None self.username = username @property - def credentials(self): + def credentials(self) -> dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -49,7 +51,7 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=None): + def __init__(self, token_name: str, personal_access_token: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) @@ -57,7 +59,7 @@ def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_i self.personal_access_token = personal_access_token @property - def credentials(self): + def credentials(self) -> dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -76,14 +78,14 @@ def __repr__(self): # A standard JWT generated specifically for Tableau class JWTAuth(Credentials): - def __init__(self, jwt: str, site_id=None, user_id_to_impersonate=None): + def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: if jwt is None: raise TabError("Must provide a JWT token when using JWT authentication") super().__init__(site_id, user_id_to_impersonate) self.jwt = jwt @property - def credentials(self): + def credentials(self) -> dict[str, str]: return {"jwt": self.jwt} def __repr__(self): From 22745a01b4324615f185e8ce6807ce70e0d05d19 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 20 Jun 2024 06:40:46 -0500 Subject: [PATCH 65/73] fix: dict[type, type] was added in 3.9 --- tableauserverclient/models/tableau_auth.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index 76f5c38e4..d011809e9 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,5 +1,5 @@ import abc -from typing import Optional +from typing import Dict, Optional class Credentials(abc.ABC): @@ -9,7 +9,7 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: credentials = ("Credentials can be username/password, Personal Access Token, or JWT" "This method returns values to set as an attribute on the credentials element of the request") return {"key": "value"} @@ -38,7 +38,7 @@ def __init__(self, username: str, password: str, site_id: Optional[str] = None, self.username = username @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return {"name": self.username, "password": self.password} def __repr__(self): @@ -59,7 +59,7 @@ def __init__(self, token_name: str, personal_access_token: str, site_id: Optiona self.personal_access_token = personal_access_token @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return { "personalAccessTokenName": self.token_name, "personalAccessTokenSecret": self.personal_access_token, @@ -85,7 +85,7 @@ def __init__(self, jwt: str, site_id: Optional[str] = None, user_id_to_impersona self.jwt = jwt @property - def credentials(self) -> dict[str, str]: + def credentials(self) -> Dict[str, str]: return {"jwt": self.jwt} def __repr__(self): From 2adcaccb11dd9ef9897a826176b776f26be82461 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Thu, 20 Jun 2024 06:47:17 -0500 Subject: [PATCH 66/73] style: black --- tableauserverclient/models/tableau_auth.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index d011809e9..10cf58723 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -10,8 +10,10 @@ def __init__(self, site_id: Optional[str] = None, user_id_to_impersonate: Option @property @abc.abstractmethod def credentials(self) -> Dict[str, str]: - credentials = ("Credentials can be username/password, Personal Access Token, or JWT" - "This method returns values to set as an attribute on the credentials element of the request") + credentials = ( + "Credentials can be username/password, Personal Access Token, or JWT" + "This method returns values to set as an attribute on the credentials element of the request" + ) return {"key": "value"} @abc.abstractmethod @@ -30,7 +32,9 @@ def deprecate_site_attribute(): # The traditional auth type: username/password class TableauAuth(Credentials): - def __init__(self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: + def __init__( + self, username: str, password: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None + ) -> None: super().__init__(site_id, user_id_to_impersonate) if password is None: raise TabError("Must provide a password when using traditional authentication") @@ -51,7 +55,13 @@ def __repr__(self): # A Tableau-generated Personal Access Token class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name: str, personal_access_token: str, site_id: Optional[str] = None, user_id_to_impersonate: Optional[str] = None) -> None: + def __init__( + self, + token_name: str, + personal_access_token: str, + site_id: Optional[str] = None, + user_id_to_impersonate: Optional[str] = None, + ) -> None: if personal_access_token is None or token_name is None: raise TabError("Must provide a token and token name when using PAT authentication") super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) From bae9dd0cd74b029adb09c7ffb32d3182c96d94f0 Mon Sep 17 00:00:00 2001 From: Patrick Franco Braz Date: Thu, 20 Jun 2024 11:34:31 -0300 Subject: [PATCH 67/73] fix(endpoint): pop from empty list --- tableauserverclient/server/endpoint/metadata_endpoint.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tableauserverclient/server/endpoint/metadata_endpoint.py b/tableauserverclient/server/endpoint/metadata_endpoint.py index 39146d062..38c3eebb6 100644 --- a/tableauserverclient/server/endpoint/metadata_endpoint.py +++ b/tableauserverclient/server/endpoint/metadata_endpoint.py @@ -42,9 +42,9 @@ def extract(obj, arr, key): def get_page_info(result): - next_page = extract_values(result, "hasNextPage").pop() - cursor = extract_values(result, "endCursor").pop() - return next_page, cursor + next_page = extract_values(result, "hasNextPage") + cursor = extract_values(result, "endCursor") + return next_page.pop() if next_page else None, cursor.pop() if cursor else None class Metadata(Endpoint): From 75e7aaa650d7b91a085a203cf265e866246c9f3d Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 29 Jun 2024 13:22:21 -0500 Subject: [PATCH 68/73] chore: absolute imports for favorites --- tableauserverclient/models/favorites_item.py | 14 +++++++------- .../server/endpoint/favorites_endpoint.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/favorites_item.py b/tableauserverclient/models/favorites_item.py index 987623404..caff755e3 100644 --- a/tableauserverclient/models/favorites_item.py +++ b/tableauserverclient/models/favorites_item.py @@ -1,14 +1,14 @@ import logging from defusedxml.ElementTree import fromstring -from .tableau_types import TableauItem +from tableauserverclient.models.tableau_types import TableauItem -from .datasource_item import DatasourceItem -from .flow_item import FlowItem -from .project_item import ProjectItem -from .metric_item import MetricItem -from .view_item import ViewItem -from .workbook_item import WorkbookItem +from tableauserverclient.models.datasource_item import DatasourceItem +from tableauserverclient.models.flow_item import FlowItem +from tableauserverclient.models.project_item import ProjectItem +from tableauserverclient.models.metric_item import MetricItem +from tableauserverclient.models.view_item import ViewItem +from tableauserverclient.models.workbook_item import WorkbookItem from typing import Dict, List from tableauserverclient.helpers.logging import logger diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index f82b1b3d5..5f298f37e 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,4 +1,4 @@ -from .endpoint import Endpoint, api +from tableauserverclient.server.endpoint.endpoint import Endpoint, api from requests import Response from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( From 3c91a2e4e3ca0dc1730cdc15a856e78f06c510b9 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:06:30 -0500 Subject: [PATCH 69/73] feat: add support for changing project owner Tableau REST API docs recently show this being supported. Adding it to TSC. Closes #157 --- tableauserverclient/models/project_item.py | 3 ++- tableauserverclient/server/request_factory.py | 3 +++ test/assets/project_update.xml | 4 +++- test/test_project.py | 4 +++- test/test_project_model.py | 5 ----- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 4918f1a14..0188f46db 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -34,6 +34,7 @@ def __init__( self.content_permissions: Optional[str] = content_permissions self.parent_id: Optional[str] = parent_id self._samples: Optional[bool] = samples + self._owner_id: Optional[str] = None self._permissions = None self._default_workbook_permissions = None @@ -119,7 +120,7 @@ def owner_id(self) -> Optional[str]: @owner_id.setter def owner_id(self, value: str) -> None: - raise NotImplementedError("REST API does not currently support updating project owner.") + self._owner_id = value def is_default(self): return self.name.lower() == "default" diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 95460b54e..87438ecde 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -482,6 +482,9 @@ def update_req(self, project_item: "ProjectItem") -> bytes: project_element.attrib["contentPermissions"] = project_item.content_permissions if project_item.parent_id is not None: project_element.attrib["parentProjectId"] = project_item.parent_id + if (owner := project_item.owner_id) is not None: + owner_element = ET.SubElement(project_element, "owner") + owner_element.attrib["id"] = owner return ET.tostring(xml_request) def create_req(self, project_item: "ProjectItem") -> bytes: diff --git a/test/assets/project_update.xml b/test/assets/project_update.xml index eaa884627..f2485c898 100644 --- a/test/assets/project_update.xml +++ b/test/assets/project_update.xml @@ -1,4 +1,6 @@ - + + + diff --git a/test/test_project.py b/test/test_project.py index 33d9c3865..e05785f86 100644 --- a/test/test_project.py +++ b/test/test_project.py @@ -79,6 +79,7 @@ def test_update(self) -> None: parent_id="9a8f2265-70f3-4494-96c5-e5949d7a1120", ) single_project._id = "1d0304cd-3796-429f-b815-7258370b9b74" + single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_project = self.server.projects.update(single_project) self.assertEqual("1d0304cd-3796-429f-b815-7258370b9b74", single_project.id) @@ -86,6 +87,7 @@ def test_update(self) -> None: self.assertEqual("Project created for testing", single_project.description) self.assertEqual("LockedToProject", single_project.content_permissions) self.assertEqual("9a8f2265-70f3-4494-96c5-e5949d7a1120", single_project.parent_id) + self.assertEqual("dd2239f6-ddf1-4107-981a-4cf94e415794", single_project.owner_id) def test_content_permission_locked_to_project_without_nested(self) -> None: with open(SET_CONTENT_PERMISSIONS_XML, "rb") as f: @@ -185,7 +187,7 @@ def test_populate_workbooks(self) -> None: self.baseurl + "/9dbd2263-16b5-46e1-9c43-a76bb8ab65fb/default-permissions/workbooks", text=response_xml ) single_project = TSC.ProjectItem("test", "1d0304cd-3796-429f-b815-7258370b9b74") - single_project._owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" + single_project.owner_id = "dd2239f6-ddf1-4107-981a-4cf94e415794" single_project._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.projects.populate_workbook_default_permissions(single_project) diff --git a/test/test_project_model.py b/test/test_project_model.py index 6ddaf8607..ecfe1bd14 100644 --- a/test/test_project_model.py +++ b/test/test_project_model.py @@ -19,8 +19,3 @@ def test_parent_id(self): project = TSC.ProjectItem("proj") project.parent_id = "foo" self.assertEqual(project.parent_id, "foo") - - def test_owner_id(self): - project = TSC.ProjectItem("proj") - with self.assertRaises(NotImplementedError): - project.owner_id = "new_owner" From b031d019b1ab52ea77b444c3fa4552dde0f0f423 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:11:05 -0500 Subject: [PATCH 70/73] chore: absolute imports --- tableauserverclient/models/project_item.py | 4 ++-- .../server/endpoint/projects_endpoint.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 0188f46db..9fb382885 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -4,8 +4,8 @@ from defusedxml.ElementTree import fromstring -from .exceptions import UnpopulatedPropertyError -from .property_decorators import property_is_enum, property_not_empty +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.property_decorators import property_is_enum, property_not_empty class ProjectItem(object): diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index f25c91387..259f53b14 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -1,17 +1,17 @@ import logging -from .default_permissions_endpoint import _DefaultPermissionsEndpoint -from .endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE -from .exceptions import MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.default_permissions_endpoint import _DefaultPermissionsEndpoint +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, XML_CONTENT_TYPE +from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint from tableauserverclient.server import RequestFactory, RequestOptions from tableauserverclient.models import ProjectItem, PaginationItem, Resource from typing import List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: - from ..server import Server - from ..request_options import RequestOptions + from tableauserverclient.server.server import Server + from tableauserverclient.server.request_options import RequestOptions from tableauserverclient.helpers.logging import logger From 9cd86ce03a946eed59ce4e336dcfd16ba6248c3b Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:04:50 -0500 Subject: [PATCH 71/73] chore: ignore known internal warnings on tests --- test/test_site.py | 4 ++++ test/test_workbook.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/test/test_site.py b/test/test_site.py index b8469e56c..96b75f9ff 100644 --- a/test/test_site.py +++ b/test/test_site.py @@ -1,6 +1,7 @@ import os.path import unittest +import pytest import requests_mock import tableauserverclient as TSC @@ -109,6 +110,8 @@ def test_get_by_name(self) -> None: def test_get_by_name_missing_name(self) -> None: self.assertRaises(ValueError, self.server.sites.get_by_name, "") + @pytest.mark.filterwarnings("ignore:Tiered license level is set") + @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") def test_update(self) -> None: with open(UPDATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") @@ -206,6 +209,7 @@ def test_replace_license_tiers_with_user_quota(self) -> None: self.assertEqual(1, test_site.user_quota) self.assertIsNone(test_site.tier_explorer_capacity) + @pytest.mark.filterwarnings("ignore:FlowsEnabled has been removed") def test_create(self) -> None: with open(CREATE_XML, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_workbook.py b/test/test_workbook.py index 595373e6e..950118dc0 100644 --- a/test/test_workbook.py +++ b/test/test_workbook.py @@ -7,6 +7,8 @@ from io import BytesIO from pathlib import Path +import pytest + import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.models import UserItem, GroupItem, PermissionsRule @@ -622,6 +624,7 @@ def test_publish_with_hidden_views_on_workbook(self) -> None: self.assertTrue(re.search(rb"<\/views>", request_body)) self.assertTrue(re.search(rb"<\/views>", request_body)) + @pytest.mark.filterwarnings("ignore:'as_job' not available") def test_publish_with_query_params(self) -> None: with open(PUBLISH_ASYNC_XML, "rb") as f: response_xml = f.read().decode("utf-8") From 776c0099a7d078828ec1fe635c4d932f0105c2c0 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sat, 29 Jun 2024 13:25:50 -0500 Subject: [PATCH 72/73] chore: absolute imports for datasource --- tableauserverclient/models/datasource_item.py | 12 ++++++------ .../server/endpoint/datasources_endpoint.py | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index fb2db6663..e4e71c4a2 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -6,16 +6,16 @@ from defusedxml.ElementTree import fromstring from tableauserverclient.datetime_helpers import parse_datetime -from .connection_item import ConnectionItem -from .exceptions import UnpopulatedPropertyError -from .permissions_item import PermissionsRule -from .property_decorators import ( +from tableauserverclient.models.connection_item import ConnectionItem +from tableauserverclient.models.exceptions import UnpopulatedPropertyError +from tableauserverclient.models.permissions_item import PermissionsRule +from tableauserverclient.models.property_decorators import ( property_not_nullable, property_is_boolean, property_is_enum, ) -from .revision_item import RevisionItem -from .tag_item import TagItem +from tableauserverclient.models.revision_item import RevisionItem +from tableauserverclient.models.tag_item import TagItem class DatasourceItem(object): diff --git a/tableauserverclient/server/endpoint/datasources_endpoint.py b/tableauserverclient/server/endpoint/datasources_endpoint.py index 6233e3142..316f078a2 100644 --- a/tableauserverclient/server/endpoint/datasources_endpoint.py +++ b/tableauserverclient/server/endpoint/datasources_endpoint.py @@ -15,11 +15,11 @@ from tableauserverclient.models import PermissionsRule from .schedules_endpoint import AddResponse -from .dqw_endpoint import _DataQualityWarningEndpoint -from .endpoint import QuerysetEndpoint, api, parameter_added_in -from .exceptions import InternalServerError, MissingRequiredFieldError -from .permissions_endpoint import _PermissionsEndpoint -from .resource_tagger import _ResourceTagger +from tableauserverclient.server.endpoint.dqw_endpoint import _DataQualityWarningEndpoint +from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api, parameter_added_in +from tableauserverclient.server.endpoint.exceptions import InternalServerError, MissingRequiredFieldError +from tableauserverclient.server.endpoint.permissions_endpoint import _PermissionsEndpoint +from tableauserverclient.server.endpoint.resource_tagger import _ResourceTagger from tableauserverclient.config import ALLOWED_FILE_EXTENSIONS, FILESIZE_LIMIT_MB, BYTES_PER_MB, CHUNK_SIZE_MB from tableauserverclient.filesys_helpers import ( From dcf89aba85612a7e9a3e19ad7cc548b88caf43f5 Mon Sep 17 00:00:00 2001 From: Jordan Woods <13803242+jorwoods@users.noreply.github.com> Date: Sun, 30 Jun 2024 07:28:24 -0500 Subject: [PATCH 73/73] feat: allow setting page_size in .all and .filter 1399 introduced a `with_page_size` method that allowed a user to specify the page_size of requests in the chains. It felt awkward in practice, so this moves it to be a keyword only argument of the `.all` and `.filter` methods for querysets. As 1399 has not yet been merged into master, this should be a non breaking change for consumers of TSC. --- .../server/endpoint/endpoint.py | 8 +++---- tableauserverclient/server/query.py | 19 +++++++-------- test/test_request_option.py | 23 +++++++++++++++++-- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/tableauserverclient/server/endpoint/endpoint.py b/tableauserverclient/server/endpoint/endpoint.py index d9dac47b2..6b29e736a 100644 --- a/tableauserverclient/server/endpoint/endpoint.py +++ b/tableauserverclient/server/endpoint/endpoint.py @@ -309,17 +309,17 @@ def wrapper(self, *args, **kwargs): class QuerysetEndpoint(Endpoint, Generic[T]): @api(version="2.0") - def all(self, *args, **kwargs) -> QuerySet[T]: + def all(self, *args, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]: if args or kwargs: raise ValueError(".all method takes no arguments.") - queryset = QuerySet(self) + queryset = QuerySet(self, page_size=page_size) return queryset @api(version="2.0") - def filter(self, *_, **kwargs) -> QuerySet[T]: + def filter(self, *_, page_size: Optional[int] = None, **kwargs) -> QuerySet[T]: if _: raise RuntimeError("Only keyword arguments accepted.") - queryset = QuerySet(self).filter(**kwargs) + queryset = QuerySet(self, page_size=page_size).filter(**kwargs) return queryset @api(version="2.0") diff --git a/tableauserverclient/server/query.py b/tableauserverclient/server/query.py index 51c34d082..195139269 100644 --- a/tableauserverclient/server/query.py +++ b/tableauserverclient/server/query.py @@ -33,9 +33,9 @@ def to_camel_case(word: str) -> str: class QuerySet(Iterable[T], Sized): - def __init__(self, model: "QuerysetEndpoint[T]") -> None: + def __init__(self, model: "QuerysetEndpoint[T]", page_size: Optional[int] = None) -> None: self.model = model - self.request_options = RequestOptions() + self.request_options = RequestOptions(pagesize=page_size or 100) self._result_cache: List[T] = [] self._pagination_item = PaginationItem() @@ -134,12 +134,15 @@ def page_size(self: Self) -> int: self._fetch_all() return self._pagination_item.page_size - def filter(self: Self, *invalid, **kwargs) -> Self: + def filter(self: Self, *invalid, page_size: Optional[int] = None, **kwargs) -> Self: if invalid: raise RuntimeError("Only accepts keyword arguments.") for kwarg_key, value in kwargs.items(): field_name, operator = self._parse_shorthand_filter(kwarg_key) self.request_options.filter.add(Filter(field_name, operator, value)) + + if page_size: + self.request_options.pagesize = page_size return self def order_by(self: Self, *args) -> Self: @@ -155,11 +158,8 @@ def paginate(self: Self, **kwargs) -> Self: self.request_options.pagesize = kwargs["page_size"] return self - def with_page_size(self: Self, value: int) -> Self: - self.request_options.pagesize = value - return self - - def _parse_shorthand_filter(self: Self, key: str) -> Tuple[str, str]: + @staticmethod + def _parse_shorthand_filter(key: str) -> Tuple[str, str]: tokens = key.split("__", 1) if len(tokens) == 1: operator = RequestOptions.Operator.Equals @@ -173,7 +173,8 @@ def _parse_shorthand_filter(self: Self, key: str) -> Tuple[str, str]: raise ValueError("Field name `{}` is not valid.".format(field)) return (field, operator) - def _parse_shorthand_sort(self: Self, key: str) -> Tuple[str, str]: + @staticmethod + def _parse_shorthand_sort(key: str) -> Tuple[str, str]: direction = RequestOptions.Direction.Asc if key.startswith("-"): direction = RequestOptions.Direction.Desc diff --git a/test/test_request_option.py b/test/test_request_option.py index 5ade81ea1..e48f8510a 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -332,10 +332,29 @@ def test_filtering_parameters(self) -> None: self.assertIn("type", query_params) self.assertIn("tabloid", query_params["type"]) - def test_queryset_pagesize(self) -> None: + def test_queryset_endpoint_pagesize_all(self) -> None: for page_size in (1, 10, 100, 1000): with self.subTest(page_size): with requests_mock.mock() as m: m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) - queryset = self.server.views.all().with_page_size(page_size) + queryset = self.server.views.all(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + def test_queryset_endpoint_pagesize_filter(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = self.server.views.filter(page_size=page_size) + assert queryset.request_options.pagesize == page_size + _ = list(queryset) + + def test_queryset_pagesize_filter(self) -> None: + for page_size in (1, 10, 100, 1000): + with self.subTest(page_size): + with requests_mock.mock() as m: + m.get(f"{self.baseurl}/views?pageSize={page_size}", text=SLICING_QUERYSET_PAGE_1.read_text()) + queryset = self.server.views.all().filter(page_size=page_size) + assert queryset.request_options.pagesize == page_size _ = list(queryset)