From 341dcd27bfa5eadfd852486ed2e812e713239941 Mon Sep 17 00:00:00 2001 From: Jac Date: Fri, 22 Sep 2023 11:08:14 -0700 Subject: [PATCH 1/2] 0.27 (#1272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: TableauIDWithMFA added to the user_item model to allow creating users on Tableau Cloud with MFA enabled (#1216) * fix: make project optional in datasources #1210 * fix: allow setting timeout on workbook endpoint #1087 * fix: can't certify datasource on publish #1058 * fix filter in operator spaces bug (#1259) * fix: remove logging configuration from TSC (#1248) * Hotfix schedule_item.py for issue 1237 (#1239), Remove duplicate assignments to fields (#1244) * Fix shared attribute for custom views (#1280) New functionality * enable filtering for Excel downloads #1209, #1281 * query view by content url #456 * update datasource to use bridge (#1224) * Add JWTAuth, add repr using qualname * Add publish samples attribute (#1264) * add support for custom schedules in TOL (#1273) * Enable asJob for group update (#1276) Co-authored-by: Tim Payne <47423639+ma7tcsp@users.noreply.github.com> Co-authored-by: Lars Breddemann <139097050+LarsBreddemann@users.noreply.github.com> Co-authored-by: jorwoods Co-authored-by: Austin <110413815+austinpeters-gohealthuccom@users.noreply.github.com> Co-authored-by: Yasuhisa Yoshida Co-authored-by: Brian Cantoni Co-authored-by: a-torres-2 <142839181+a-torres-2@users.noreply.github.com> Co-authored-by: Łukasz Włodarczyk --- .github/workflows/code-coverage.yml | 4 +- .github/workflows/meta-checks.yml | 4 +- .github/workflows/publish-pypi.yml | 2 +- .github/workflows/run-tests.yml | 8 +- pyproject.toml | 26 ++--- samples/create_extract_task.py | 84 ++++++++++++++++ samples/create_project.py | 9 +- samples/explore_workbook.py | 10 ++ samples/getting_started/1_hello_server.py | 21 ++++ samples/getting_started/2_hello_site.py | 50 ++++++++++ samples/getting_started/3_hello_universe.py | 96 +++++++++++++++++++ tableauserverclient/helpers/logging.py | 2 - .../models/custom_view_item.py | 5 + tableauserverclient/models/datasource_item.py | 9 -- tableauserverclient/models/project_item.py | 2 + tableauserverclient/models/schedule_item.py | 8 +- .../models/subscription_item.py | 8 ++ tableauserverclient/models/tableau_auth.py | 30 +++++- .../server/endpoint/auth_endpoint.py | 28 ++++-- .../server/endpoint/favorites_endpoint.py | 1 - .../server/endpoint/groups_endpoint.py | 5 +- .../server/endpoint/projects_endpoint.py | 2 + .../server/endpoint/tasks_endpoint.py | 11 +++ tableauserverclient/server/filter.py | 6 +- tableauserverclient/server/request_factory.py | 28 ++++++ tableauserverclient/server/request_options.py | 1 + test/assets/group_update_async.xml | 10 ++ test/assets/request_option_filter_name_in.xml | 12 +++ test/assets/tasks_create_extract_task.xml | 12 +++ test/models/test_repr.py | 2 +- test/test_custom_view.py | 3 +- test/test_datasource.py | 6 +- test/test_filter.py | 22 +++++ test/test_group.py | 19 +++- test/test_request_option.py | 25 +++++ test/test_task.py | 27 +++++- 36 files changed, 541 insertions(+), 57 deletions(-) create mode 100644 samples/create_extract_task.py create mode 100644 samples/getting_started/1_hello_server.py create mode 100644 samples/getting_started/2_hello_site.py create mode 100644 samples/getting_started/3_hello_universe.py create mode 100644 test/assets/group_update_async.xml create mode 100644 test/assets/request_option_filter_name_in.xml create mode 100644 test/assets/tasks_create_extract_task.xml create mode 100644 test/test_filter.py diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index 6d74c5c38..d858c3389 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -16,10 +16,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/meta-checks.yml b/.github/workflows/meta-checks.yml index 3fcb852d1..7d6cd068a 100644 --- a/.github/workflows/meta-checks.yml +++ b/.github/workflows/meta-checks.yml @@ -13,10 +13,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b8a70e9c5..fe8fffc42 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-python@v1 + - uses: actions/setup-python@v4 with: python-version: 3.7 - name: Build dist files diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 10df02c04..3df497806 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,15 +8,15 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -33,4 +33,4 @@ jobs: - name: Test build if: always() run: | - python -m build \ No newline at end of file + python -m build diff --git a/pyproject.toml b/pyproject.toml index ee793ec41..717ca7cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=45.0", "versioneer>=0.24", "wheel"] +requires = ["setuptools>=68.0", "versioneer>=0.29", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -12,39 +12,41 @@ license = {file = "LICENSE"} readme = "README.md" dependencies = [ - 'defusedxml>=0.7.1', - 'packaging>=22.0', # bumping to minimum version required by black - 'requests>=2.28', - 'urllib3~=1.26.8', + 'defusedxml>=0.7.1', # latest as at 7/31/23 + 'packaging>=23.1', # latest as at 7/31/23 + 'requests>=2.31', # latest as at 7/31/23 + 'urllib3==2.0.4', # latest as at 7/31/23 ] requires-python = ">=3.7" classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10" + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12" ] [project.urls] repository = "https://github.com/tableau/server-client-python" [project.optional-dependencies] -test = ["argparse", "black", "mock", "mypy", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] +test = ["argparse", "black==23.7", "mock", "mypy==1.4", "pytest>=7.0", "pytest-subtests", "requests-mock>=1.0,<2.0"] [tool.black] line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] [tool.mypy] +check_untyped_defs = false disable_error_code = [ 'misc', - 'import' + # tableauserverclient\server\endpoint\datasources_endpoint.py:48: error: Cannot assign multiple types to name "FilePath" without an explicit "Type[...]" annotation [misc] + 'annotation-unchecked' # can be removed when check_untyped_defs = true ] files = ["tableauserverclient", "test"] show_error_codes = true -ignore_missing_imports = true - +ignore_missing_imports = true # defusedxml library has no types [tool.pytest.ini_options] testpaths = ["test"] addopts = "--junitxml=./test.junit.xml" diff --git a/samples/create_extract_task.py b/samples/create_extract_task.py new file mode 100644 index 000000000..8408f67ee --- /dev/null +++ b/samples/create_extract_task.py @@ -0,0 +1,84 @@ +#### +# This script demonstrates how to create extract tasks in Tableau Cloud +# using the Tableau Server Client. +# +# To run the script, you must have installed Python 3.7 or later. +#### + + +import argparse +import logging + +from datetime import time + +import tableauserverclient as TSC + + +def main(): + parser = argparse.ArgumentParser(description="Creates sample extract refresh task.") + # 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): + # Monthly Schedule + # This schedule will run on the 15th of every month at 11:30PM + monthly_interval = TSC.MonthlyInterval(start_time=time(23, 30), interval_value=15) + monthly_schedule = TSC.ScheduleItem( + None, + None, + None, + None, + monthly_interval, + ) + + # Default to using first workbook found in server + all_workbook_items, pagination_item = server.workbooks.get() + my_workbook: TSC.WorkbookItem = all_workbook_items[0] + + target_item = TSC.Target( + my_workbook.id, # the id of the workbook or datasource + "workbook", # alternatively can be "datasource" + ) + + extract_item = TSC.TaskItem( + None, + "FullRefresh", + None, + None, + None, + monthly_schedule, + None, + target_item, + ) + + try: + response = server.tasks.create(extract_item) + print(response) + except Exception as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/samples/create_project.py b/samples/create_project.py index 611dbe366..1fc649f8c 100644 --- a/samples/create_project.py +++ b/samples/create_project.py @@ -57,7 +57,14 @@ def main(): server.use_server_version() # Without parent_id specified, projects are created at the top level. - top_level_project = TSC.ProjectItem(name="Top Level Project") + # With the publish-samples attribute, the project will be created with sample items + top_level_project = TSC.ProjectItem( + name="Top Level Project", + description="A sample tsc project", + content_permissions=None, + parent_id=None, + samples=True, + ) top_level_project = create_project(server, top_level_project) # Specifying parent_id creates a nested projects. diff --git a/samples/explore_workbook.py b/samples/explore_workbook.py index c61b9b637..57f88aa07 100644 --- a/samples/explore_workbook.py +++ b/samples/explore_workbook.py @@ -36,6 +36,9 @@ def main(): parser.add_argument( "--preview-image", "-i", metavar="FILENAME", help="filename (a .png file) to save the preview image" ) + parser.add_argument( + "--powerpoint", "-ppt", metavar="FILENAME", help="filename (a .ppt file) to save the powerpoint deck" + ) args = parser.parse_args() @@ -145,6 +148,13 @@ def main(): f.write(c.image) print("saved to " + filename) + if args.powerpoint: + # Populate workbook preview image + server.workbooks.populate_powerpoint(sample_workbook) + with open(args.powerpoint, "wb") as f: + f.write(sample_workbook.powerpoint) + print("\nDownloaded powerpoint of workbook to {}".format(os.path.abspath(args.powerpoint))) + if args.delete: print("deleting {}".format(c.id)) unlucky = TSC.CustomViewItem(c.id) diff --git a/samples/getting_started/1_hello_server.py b/samples/getting_started/1_hello_server.py new file mode 100644 index 000000000..454b225de --- /dev/null +++ b/samples/getting_started/1_hello_server.py @@ -0,0 +1,21 @@ +#### +# Getting started Part One of Three +# This script demonstrates how to use the Tableau Server Client to connect to a server +# You don't need to have a site or any experience with Tableau to run it +# +#### + +import tableauserverclient as TSC + + +def main(): + # This is the domain for Tableau's Developer Program + server_url = "https://10ax.online.tableau.com" + server = TSC.Server(server_url) + print("Connected to {}".format(server.server_info.baseurl)) + print("Server information: {}".format(server.server_info)) + print("Sign up for a test site at https://www.tableau.com/developer") + + +if __name__ == "__main__": + main() diff --git a/samples/getting_started/2_hello_site.py b/samples/getting_started/2_hello_site.py new file mode 100644 index 000000000..d62896059 --- /dev/null +++ b/samples/getting_started/2_hello_site.py @@ -0,0 +1,50 @@ +#### +# Getting started Part Two of Three +# This script demonstrates how to use the Tableau Server Client to +# view the content on an existing site on Tableau Server/Online +# It assumes that you have already got a site and can visit it in a browser +# +#### + +import getpass +import tableauserverclient as TSC + + +# 0 - launch your Tableau site in a web browser and look at the url to set the values below +def main(): + # 1 - replace with your server domain: stop at the slash + server_url = "https://10ax.online.tableau.com" + + # 2 - optional - change to false **for testing only** if you get a certificate error + use_ssl = True + + server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) + print("Connected to {}".format(server.server_info.baseurl)) + + # 3 - replace with your site name exactly as it looks in the url + # e.g https://my-server/#/site/this-is-your-site-url-name/not-this-part + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 - replace with your username. + # REMEMBER: if you are using Tableau Online, your username is the entire email address + username = "your-username-here" + password = getpass.getpass("Your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, uncomment this section to use a Personal Access Token + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + projects, pagination = server.projects.get() + if projects: + print("{} projects".format(pagination.total_available)) + project = projects[0] + print(project.name) + + print("Done") + + +if __name__ == "__main__": + main() diff --git a/samples/getting_started/3_hello_universe.py b/samples/getting_started/3_hello_universe.py new file mode 100644 index 000000000..3ed39fd17 --- /dev/null +++ b/samples/getting_started/3_hello_universe.py @@ -0,0 +1,96 @@ +#### +# Getting Started Part Three of Three +# This script demonstrates all the different types of 'content' a server contains +# +# To make it easy to run, it doesn't take any arguments - you need to edit the code with your info +#### + +import getpass +import tableauserverclient as TSC + + +def main(): + # 1 - replace with your server url + server_url = "https://10ax.online.tableau.com" + + # 2 - change to false **for testing only** if you get a certificate error + use_ssl = True + server = TSC.Server(server_url, use_server_version=True, http_options={"verify": use_ssl}) + + print("Connected to {}".format(server.server_info.baseurl)) + + # 3 - replace with your site name exactly as it looks in a url + # e.g https://my-server/#/this-is-your-site-url-name/ + site_url_name = "" # leave empty if there is no site name in the url (you are on the default site) + + # 4 + username = "your-username-here" + password = getpass.getpass("Your password:") # so you don't save it in this file + tableau_auth = TSC.TableauAuth(username, password, site_id=site_url_name) + + # OR instead of username+password, use a Personal Access Token (PAT) (required by Tableau Cloud) + # token_name = "your-token-name" + # token_value = "your-token-value-long-random-string" + # tableau_auth = TSC.PersonalAccessTokenAuth(token_name, token_value, site_id=site_url_name) + + with server.auth.sign_in(tableau_auth): + projects, pagination = server.projects.get() + if projects: + print("{} projects".format(pagination.total_available)) + for project in projects: + print(project.name) + + workbooks, pagination = server.datasources.get() + if workbooks: + print("{} workbooks".format(pagination.total_available)) + print(workbooks[0]) + + views, pagination = server.views.get() + if views: + print("{} views".format(pagination.total_available)) + print(views[0]) + + datasources, pagination = server.datasources.get() + if datasources: + print("{} datasources".format(pagination.total_available)) + print(datasources[0]) + + # I think all these other content types can go to a hello_universe script + # data alert, dqw, flow, ... do any of these require any add-ons? + jobs, pagination = server.jobs.get() + if jobs: + print("{} jobs".format(pagination.total_available)) + print(jobs[0]) + + metrics, pagination = server.metrics.get() + if metrics: + print("{} metrics".format(pagination.total_available)) + print(metrics[0]) + + schedules, pagination = server.schedules.get() + if schedules: + print("{} schedules".format(pagination.total_available)) + print(schedules[0]) + + tasks, pagination = server.tasks.get() + if tasks: + print("{} tasks".format(pagination.total_available)) + print(tasks[0]) + + webhooks, pagination = server.webhooks.get() + if webhooks: + print("{} webhooks".format(pagination.total_available)) + print(webhooks[0]) + + users, pagination = server.metrics.get() + if users: + print("{} users".format(pagination.total_available)) + print(users[0]) + + groups, pagination = server.groups.get() + if groups: + print("{} groups".format(pagination.total_available)) + print(groups[0]) + + if __name__ == "__main__": + main() diff --git a/tableauserverclient/helpers/logging.py b/tableauserverclient/helpers/logging.py index 414d85786..e64c6d2c8 100644 --- a/tableauserverclient/helpers/logging.py +++ b/tableauserverclient/helpers/logging.py @@ -2,5 +2,3 @@ # TODO change: this defaults to logging *everything* to stdout logger = logging.getLogger("TSC") -logger.setLevel(logging.DEBUG) -logger.addHandler(logging.StreamHandler()) diff --git a/tableauserverclient/models/custom_view_item.py b/tableauserverclient/models/custom_view_item.py index e0b47c738..246a19e7f 100644 --- a/tableauserverclient/models/custom_view_item.py +++ b/tableauserverclient/models/custom_view_item.py @@ -134,6 +134,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomVi cv_item._content_url = custom_view_xml.get("contentUrl", None) cv_item._id = custom_view_xml.get("id", None) cv_item._name = custom_view_xml.get("name", None) + cv_item._shared = string_to_bool(custom_view_xml.get("shared", None)) if owner_elem is not None: parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns) @@ -154,3 +155,7 @@ def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomVi all_view_items.append(cv_item) return all_view_items + + +def string_to_bool(s: Optional[str]) -> bool: + return (s or "").lower() == "true" diff --git a/tableauserverclient/models/datasource_item.py b/tableauserverclient/models/datasource_item.py index 7fcc31ebf..5a867135c 100644 --- a/tableauserverclient/models/datasource_item.py +++ b/tableauserverclient/models/datasource_item.py @@ -326,17 +326,8 @@ def _parse_element(datasource_xml: ET.Element, ns: Dict) -> Tuple: updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) certification_note = datasource_xml.get("certificationNote", None) certified = str(datasource_xml.get("isCertified", None)).lower() == "true" - certification_note = datasource_xml.get("certificationNote", None) - certified = str(datasource_xml.get("isCertified", None)).lower() == "true" - content_url = datasource_xml.get("contentUrl", None) - created_at = parse_datetime(datasource_xml.get("createdAt", None)) - datasource_type = datasource_xml.get("type", None) - description = datasource_xml.get("description", None) encrypt_extracts = datasource_xml.get("encryptExtracts", None) has_extracts = datasource_xml.get("hasExtracts", None) - id_ = datasource_xml.get("id", None) - name = datasource_xml.get("name", None) - updated_at = parse_datetime(datasource_xml.get("updatedAt", None)) use_remote_query_agent = datasource_xml.get("useRemoteQueryAgent", None) webpage_url = datasource_xml.get("webpageUrl", None) diff --git a/tableauserverclient/models/project_item.py b/tableauserverclient/models/project_item.py index 393a7990f..e7254ab5d 100644 --- a/tableauserverclient/models/project_item.py +++ b/tableauserverclient/models/project_item.py @@ -25,6 +25,7 @@ def __init__( description: Optional[str] = None, content_permissions: Optional[str] = None, parent_id: Optional[str] = None, + samples: Optional[bool] = None, ) -> None: self._content_permissions = None self._id: Optional[str] = None @@ -32,6 +33,7 @@ def __init__( self.name: str = name self.content_permissions: Optional[str] = content_permissions self.parent_id: Optional[str] = parent_id + self._samples: Optional[bool] = samples self._permissions = None self._default_workbook_permissions = None diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 54e4badbe..edfd0fe70 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -14,8 +14,6 @@ ) from .property_decorators import ( property_is_enum, - property_not_nullable, - property_is_int, ) Interval = Union[HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval] @@ -27,6 +25,7 @@ class Type: Flow = "Flow" Subscription = "Subscription" DataAcceleration = "DataAcceleration" + ActiveDirectorySync = "ActiveDirectorySync" class ExecutionOrder: Parallel = "Parallel" @@ -74,11 +73,10 @@ def id(self) -> Optional[str]: return self._id @property - def name(self) -> str: + def name(self) -> Optional[str]: return self._name @name.setter - @property_not_nullable def name(self, value: str): self._name = value @@ -91,7 +89,6 @@ def priority(self) -> int: return self._priority @priority.setter - @property_is_int(range=(1, 100)) def priority(self, value: int): self._priority = value @@ -101,7 +98,6 @@ def schedule_type(self) -> str: @schedule_type.setter @property_is_enum(Type) - @property_not_nullable def schedule_type(self, value: str): self._schedule_type = value diff --git a/tableauserverclient/models/subscription_item.py b/tableauserverclient/models/subscription_item.py index e18adc6ae..e96fcc448 100644 --- a/tableauserverclient/models/subscription_item.py +++ b/tableauserverclient/models/subscription_item.py @@ -4,6 +4,7 @@ from .property_decorators import property_is_boolean from .target import Target +from tableauserverclient.models import ScheduleItem if TYPE_CHECKING: from .target import Target @@ -23,6 +24,7 @@ def __init__(self, subject: str, schedule_id: str, user_id: str, target: "Target self.suspended = False self.target = target self.user_id = user_id + self.schedule = None def __repr__(self) -> str: if self.id is not None: @@ -92,9 +94,14 @@ def _parse_element(cls, element, ns): # Schedule element schedule_id = None + schedule = None if schedule_element is not None: schedule_id = schedule_element.get("id", None) + # If schedule id is not provided, then TOL with full schedule provided + if schedule_id is None: + schedule = ScheduleItem.from_element(element, ns) + # Content element target = None send_if_view_empty = None @@ -127,6 +134,7 @@ def _parse_element(cls, element, ns): sub.page_size_option = page_size_option sub.send_if_view_empty = send_if_view_empty sub.suspended = suspended + sub.schedule = schedule return sub diff --git a/tableauserverclient/models/tableau_auth.py b/tableauserverclient/models/tableau_auth.py index db21e4aa2..30639d09b 100644 --- a/tableauserverclient/models/tableau_auth.py +++ b/tableauserverclient/models/tableau_auth.py @@ -1,13 +1,18 @@ -class Credentials: +import abc + + +class Credentials(abc.ABC): def __init__(self, site_id=None, user_id_to_impersonate=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" + @abc.abstractmethod def __repr__(self): return "All Credentials types must have a debug display that does not print secrets" @@ -52,10 +57,10 @@ def site(self, value): class PersonalAccessTokenAuth(Credentials): - def __init__(self, token_name, personal_access_token, site_id=None): + def __init__(self, token_name, personal_access_token, site_id=None, user_id_to_impersonate=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) + super().__init__(site_id=site_id, user_id_to_impersonate=user_id_to_impersonate) self.token_name = token_name self.personal_access_token = personal_access_token @@ -70,3 +75,22 @@ def __repr__(self): return "(site={})".format( self.token_name, self.personal_access_token[:2] + "...", self.site_id ) + + +class JWTAuth(Credentials): + def __init__(self, jwt=None, site_id=None, user_id_to_impersonate=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): + return {"jwt": self.jwt} + + def __repr__(self): + if self.user_id_to_impersonate: + uid = f", user_id_to_impersonate=f{self.user_id_to_impersonate}" + else: + uid = "" + return f"<{self.__class__.__qualname__}(jwt={self.jwt[:5]}..., site_id={self.site_id}{uid})>" diff --git a/tableauserverclient/server/endpoint/auth_endpoint.py b/tableauserverclient/server/endpoint/auth_endpoint.py index 6f1ddc35e..2025de5fb 100644 --- a/tableauserverclient/server/endpoint/auth_endpoint.py +++ b/tableauserverclient/server/endpoint/auth_endpoint.py @@ -1,4 +1,6 @@ import logging +from typing import TYPE_CHECKING +import warnings from defusedxml.ElementTree import fromstring @@ -8,6 +10,10 @@ from tableauserverclient.helpers.logging import logger +if TYPE_CHECKING: + from tableauserverclient.models.site_item import SiteItem + from tableauserverclient.models.tableau_auth import Credentials + class Auth(Endpoint): class contextmgr(object): @@ -21,11 +27,21 @@ def __exit__(self, exc_type, exc_val, exc_tb): self._callback() @property - def baseurl(self): + def baseurl(self) -> str: return "{0}/auth".format(self.parent_srv.baseurl) @api(version="2.0") - def sign_in(self, auth_req): + def sign_in(self, auth_req: "Credentials") -> contextmgr: + """ + Sign in to a Tableau Server or Tableau Online using a credentials object. + + The credentials object can either be a TableauAuth object, a + PersonalAccessTokenAuth object, or a JWTAuth object. This method now + accepts them all. The object should be populated with the site_id and + optionally a user_id to impersonate. + + Creates a context manager that will sign out of the server upon exit. + """ url = "{0}/{1}".format(self.baseurl, "signin") signin_req = RequestFactory.Auth.signin_req(auth_req) server_response = self.parent_srv.session.post( @@ -51,12 +67,12 @@ def sign_in(self, auth_req): return Auth.contextmgr(self.sign_out) @api(version="3.6") - def sign_in_with_personal_access_token(self, auth_req): + def sign_in_with_personal_access_token(self, auth_req: "Credentials") -> contextmgr: # We use the same request that username/password login uses. return self.sign_in(auth_req) @api(version="2.0") - def sign_out(self): + def sign_out(self) -> None: url = "{0}/{1}".format(self.baseurl, "signout") # If there are no auth tokens you're already signed out. No-op if not self.parent_srv.is_signed_in(): @@ -66,7 +82,7 @@ def sign_out(self): logger.info("Signed out") @api(version="2.6") - def switch_site(self, site_item): + def switch_site(self, site_item: "SiteItem") -> contextmgr: url = "{0}/{1}".format(self.baseurl, "switchSite") switch_req = RequestFactory.Auth.switch_req(site_item.content_url) try: @@ -87,7 +103,7 @@ def switch_site(self, site_item): return Auth.contextmgr(self.sign_out) @api(version="3.10") - def revoke_all_server_admin_tokens(self): + def revoke_all_server_admin_tokens(self) -> None: url = "{0}/{1}".format(self.baseurl, "revokeAllServerAdminTokens") self.post_request(url, "") logger.info("Revoked all tokens for all server admins") diff --git a/tableauserverclient/server/endpoint/favorites_endpoint.py b/tableauserverclient/server/endpoint/favorites_endpoint.py index ac9e4b185..f82b1b3d5 100644 --- a/tableauserverclient/server/endpoint/favorites_endpoint.py +++ b/tableauserverclient/server/endpoint/favorites_endpoint.py @@ -1,6 +1,5 @@ from .endpoint import Endpoint, api from requests import Response - from tableauserverclient.helpers.logging import logger from tableauserverclient.models import ( DatasourceItem, diff --git a/tableauserverclient/server/endpoint/groups_endpoint.py b/tableauserverclient/server/endpoint/groups_endpoint.py index ad3828568..ab5f672d1 100644 --- a/tableauserverclient/server/endpoint/groups_endpoint.py +++ b/tableauserverclient/server/endpoint/groups_endpoint.py @@ -82,14 +82,17 @@ def update( ) group_item.minimum_site_role = default_site_role + url = "{0}/{1}".format(self.baseurl, group_item.id) + if not group_item.id: error = "Group item missing ID." raise MissingRequiredFieldError(error) if as_job and (group_item.domain_name is None or group_item.domain_name == "local"): error = "Local groups cannot be updated asynchronously." raise ValueError(error) + elif as_job: + url = "?".join([url, "asJob=True"]) - url = "{0}/{1}".format(self.baseurl, group_item.id) update_req = RequestFactory.Group.update_req(group_item, None) server_response = self.put_request(url, update_req) logger.info("Updated group item (ID: {0})".format(group_item.id)) diff --git a/tableauserverclient/server/endpoint/projects_endpoint.py b/tableauserverclient/server/endpoint/projects_endpoint.py index 510f1ff3d..99bb2e39b 100644 --- a/tableauserverclient/server/endpoint/projects_endpoint.py +++ b/tableauserverclient/server/endpoint/projects_endpoint.py @@ -63,6 +63,8 @@ def update(self, project_item: ProjectItem, samples: bool = False) -> ProjectIte def create(self, project_item: ProjectItem, samples: bool = False) -> ProjectItem: params = {"params": {RequestOptions.Field.PublishSamples: samples}} url = self.baseurl + if project_item._samples: + url = "{0}?publishSamples={1}".format(self.baseurl, project_item._samples) create_req = RequestFactory.Project.create_req(project_item) server_response = self.post_request(url, create_req, XML_CONTENT_TYPE, params) new_project = ProjectItem.from_response(server_response.content, self.parent_srv.namespace)[0] diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index ad1702f58..092597388 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -51,6 +51,17 @@ def get_by_id(self, task_id): server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] + @api(version="3.19") + def create(self, extract_item: TaskItem) -> TaskItem: + if not extract_item: + error = "No extract refresh provided" + raise ValueError(error) + logger.info("Creating an extract refresh ({})".format(extract_item)) + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(TaskItem.Type.ExtractRefresh)) + create_req = RequestFactory.Task.create_extract_req(extract_item) + server_response = self.post_request(url, create_req) + return server_response.content + @api(version="2.6") def run(self, task_item): if not task_item.id: diff --git a/tableauserverclient/server/filter.py b/tableauserverclient/server/filter.py index 8802321fd..b936ceb92 100644 --- a/tableauserverclient/server/filter.py +++ b/tableauserverclient/server/filter.py @@ -11,7 +11,11 @@ def __init__(self, field, operator, value): def __str__(self): value_string = str(self._value) if isinstance(self._value, list): - value_string = value_string.replace(" ", "").replace("'", "") + # this should turn the string representation of the list + # from ['', '', ...] + # to [,] + # so effectively, remove any spaces between "," and "'" and then remove all "'" + value_string = value_string.replace(", '", ",'").replace("'", "") return "{0}:{1}:{2}".format(self.field, self.operator, value_string) @property diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 4bd30bb2c..7fb9bf9ed 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1028,6 +1028,34 @@ def run_req(self, xml_request, task_item): # Send an empty tsRequest pass + @_tsrequest_wrapped + def create_extract_req(self, xml_request: ET.Element, extract_item: "TaskItem") -> bytes: + extract_element = ET.SubElement(xml_request, "extractRefresh") + + # Schedule attributes + schedule_element = ET.SubElement(xml_request, "schedule") + + interval_item = extract_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(): + expression, value = interval + single_interval_element = ET.SubElement(intervals_element, "interval") + single_interval_element.attrib[expression] = value + + # Main attributes + extract_element.attrib["type"] = extract_item.task_type + + target_element = ET.SubElement(extract_element, extract_item.target.type) + target_element.attrib["id"] = extract_item.target.id + + return ET.tostring(xml_request) + class SubscriptionRequest(object): @_tsrequest_wrapped diff --git a/tableauserverclient/server/request_options.py b/tableauserverclient/server/request_options.py index 1ee18e9df..796f8add3 100644 --- a/tableauserverclient/server/request_options.py +++ b/tableauserverclient/server/request_options.py @@ -167,6 +167,7 @@ def get_query_params(self): if self.max_age != -1: params["maxAge"] = self.max_age + self._append_view_filters(params) return params diff --git a/test/assets/group_update_async.xml b/test/assets/group_update_async.xml new file mode 100644 index 000000000..ea6b47eaa --- /dev/null +++ b/test/assets/group_update_async.xml @@ -0,0 +1,10 @@ + + + + diff --git a/test/assets/request_option_filter_name_in.xml b/test/assets/request_option_filter_name_in.xml new file mode 100644 index 000000000..9ec42b8ab --- /dev/null +++ b/test/assets/request_option_filter_name_in.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/tasks_create_extract_task.xml b/test/assets/tasks_create_extract_task.xml new file mode 100644 index 000000000..9e6310fba --- /dev/null +++ b/test/assets/tasks_create_extract_task.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/models/test_repr.py b/test/models/test_repr.py index f3da9fde2..d21e4bc4a 100644 --- a/test/models/test_repr.py +++ b/test/models/test_repr.py @@ -1,7 +1,7 @@ import pytest from unittest import TestCase -import _models +import _models # type: ignore # did not set types for this # ensure that all models have a __repr__ method implemented diff --git a/test/test_custom_view.py b/test/test_custom_view.py index c1fe8c407..55dec5df1 100644 --- a/test/test_custom_view.py +++ b/test/test_custom_view.py @@ -41,14 +41,15 @@ def test_get(self) -> None: self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[0].owner.id) self.assertIsNone(all_views[0].created_at) self.assertIsNone(all_views[0].updated_at) + self.assertFalse(all_views[0].shared) self.assertEqual("fd252f73-593c-4c4e-8584-c032b8022adc", all_views[1].id) self.assertEqual("Overview", all_views[1].name) - self.assertEqual(False, all_views[1].shared) self.assertEqual("6d13b0ca-043d-4d42-8c9d-3f3313ea3a00", all_views[1].workbook.id) self.assertEqual("5de011f8-5aa9-4d5b-b991-f462c8dd6bb7", all_views[1].owner.id) self.assertEqual("2002-05-30T09:00:00Z", format_datetime(all_views[1].created_at)) self.assertEqual("2002-06-05T08:00:59Z", format_datetime(all_views[1].updated_at)) + self.assertTrue(all_views[1].shared) def test_get_by_id(self) -> None: with open(GET_XML_ID, "rb") as f: diff --git a/test/test_datasource.py b/test/test_datasource.py index 730e382da..e299e5291 100644 --- a/test/test_datasource.py +++ b/test/test_datasource.py @@ -2,12 +2,14 @@ import tempfile import unittest from io import BytesIO +from typing import Optional from zipfile import ZipFile import requests_mock from defusedxml.ElementTree import fromstring import tableauserverclient as TSC +from tableauserverclient import ConnectionItem from tableauserverclient.datetime_helpers import format_datetime from tableauserverclient.server.endpoint.exceptions import InternalServerError from tableauserverclient.server.endpoint.fileuploads_endpoint import Fileuploads @@ -167,9 +169,9 @@ def test_populate_connections(self) -> None: single_datasource._id = "9dbd2263-16b5-46e1-9c43-a76bb8ab65fb" self.server.datasources.populate_connections(single_datasource) self.assertEqual("9dbd2263-16b5-46e1-9c43-a76bb8ab65fb", single_datasource.id) - connections = single_datasource.connections + connections: Optional[list[ConnectionItem]] = single_datasource.connections - self.assertTrue(connections) + self.assertIsNotNone(connections) ds1, ds2 = connections self.assertEqual("be786ae0-d2bf-4a4b-9b34-e2de8d2d4488", ds1.id) self.assertEqual("textscan", ds1.connection_type) diff --git a/test/test_filter.py b/test/test_filter.py new file mode 100644 index 000000000..e2121307f --- /dev/null +++ b/test/test_filter.py @@ -0,0 +1,22 @@ +import os +import unittest + +import tableauserverclient as TSC + + +class FilterTests(unittest.TestCase): + def setUp(self): + pass + + def test_filter_equal(self): + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, "Superstore") + + self.assertEqual(str(filter), "name:eq:Superstore") + + def test_filter_in(self): + # create a IN filter condition with project names that + # contain spaces and "special" characters + projects_to_find = ["default", "Salesforce Sales Projeśt"] + filter = TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.In, projects_to_find) + + self.assertEqual(str(filter), "name:in:[default,Salesforce Sales Projeśt]") diff --git a/test/test_group.py b/test/test_group.py index 306d42170..1edc50555 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -1,11 +1,14 @@ # encoding=utf-8 +from pathlib import Path import unittest import os import requests_mock import tableauserverclient as TSC from tableauserverclient.datetime_helpers import format_datetime -TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") +TEST_ASSET_DIR = Path(__file__).absolute().parent / "assets" + +# TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "group_get.xml") POPULATE_USERS = os.path.join(TEST_ASSET_DIR, "group_populate_users.xml") @@ -16,6 +19,7 @@ CREATE_GROUP_AD = os.path.join(TEST_ASSET_DIR, "group_create_ad.xml") CREATE_GROUP_ASYNC = os.path.join(TEST_ASSET_DIR, "group_create_async.xml") UPDATE_XML = os.path.join(TEST_ASSET_DIR, "group_update.xml") +UPDATE_ASYNC_XML = TEST_ASSET_DIR / "group_update_async.xml" class GroupTests(unittest.TestCase): @@ -245,3 +249,16 @@ def test_update_local_async(self) -> None: # mimic group returned from server where domain name is set to 'local' group.domain_name = "local" self.assertRaises(ValueError, self.server.groups.update, group, as_job=True) + + def test_update_ad_async(self) -> None: + group = TSC.GroupItem("myGroup", "example.com") + group._id = "ef8b19c0-43b6-11e6-af50-63f5805dbe3c" + group.minimum_site_role = TSC.UserItem.Roles.Viewer + + with requests_mock.mock() as m: + m.put(f"{self.baseurl}/{group.id}?asJob=True", text=UPDATE_ASYNC_XML.read_bytes().decode("utf8")) + job = self.server.groups.update(group, as_job=True) + + self.assertEqual(job.id, "c2566efc-0767-4f15-89cb-56acb4349c1b") + self.assertEqual(job.mode, "Asynchronous") + self.assertEqual(job.type, "GroupSync") diff --git a/test/test_request_option.py b/test/test_request_option.py index 5d8bdf05e..32526d1e6 100644 --- a/test/test_request_option.py +++ b/test/test_request_option.py @@ -13,6 +13,7 @@ PAGE_NUMBER_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_number.xml") PAGE_SIZE_XML = os.path.join(TEST_ASSET_DIR, "request_option_page_size.xml") FILTER_EQUALS = os.path.join(TEST_ASSET_DIR, "request_option_filter_equals.xml") +FILTER_NAME_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_name_in.xml") FILTER_TAGS_IN = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") FILTER_MULTIPLE = os.path.join(TEST_ASSET_DIR, "request_option_filter_tags_in.xml") SLICING_QUERYSET = os.path.join(TEST_ASSET_DIR, "request_option_slicing_queryset.xml") @@ -114,6 +115,30 @@ def test_filter_tags_in(self) -> None: self.assertEqual(set(["safari"]), matching_workbooks[1].tags) self.assertEqual(set(["sample"]), matching_workbooks[2].tags) + # check if filtered projects with spaces & special characters + # get correctly returned + def test_filter_name_in(self) -> None: + with open(FILTER_NAME_IN, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get( + self.baseurl + "/projects?filter=name%3Ain%3A%5Bdefault%2CSalesforce+Sales+Proje%C5%9Bt%5D", + text=response_xml, + ) + req_option = TSC.RequestOptions() + req_option.filter.add( + TSC.Filter( + TSC.RequestOptions.Field.Name, + TSC.RequestOptions.Operator.In, + ["default", "Salesforce Sales Projeśt"], + ) + ) + matching_projects, pagination_item = self.server.projects.get(req_option) + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual("default", matching_projects[0].name) + self.assertEqual("Salesforce Sales Projeśt", matching_projects[1].name) + def test_filter_tags_in_shorthand(self) -> None: with open(FILTER_TAGS_IN, "rb") as f: response_xml = f.read().decode("utf-8") diff --git a/test/test_task.py b/test/test_task.py index 5c432208d..4eb2c02e2 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -1,5 +1,6 @@ import os import unittest +from datetime import time import requests_mock @@ -15,12 +16,13 @@ GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") GET_XML_DATAACCELERATION_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_dataacceleration_task.xml") GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") +GET_XML_CREATE_TASK_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_create_extract_task.xml") class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test", False) - self.server.version = "3.8" + self.server.version = "3.19" # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" @@ -141,3 +143,26 @@ def test_run_now(self): self.assertTrue("7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" in job_response_content) self.assertTrue("RefreshExtract" in job_response_content) + + def test_create_extract_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("workbook_id", "workbook") + + task = TaskItem(None, "FullRefresh", None, schedule_item=monthly_schedule, target=target_item) + + with open(GET_XML_CREATE_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.tasks.create(task).decode("utf-8") + + self.assertTrue("task_id" in create_response_content) + self.assertTrue("workbook_id" in create_response_content) + self.assertTrue("FullRefresh" in create_response_content) From 674495b313369425dbfc903b2a8daacf70265594 Mon Sep 17 00:00:00 2001 From: Jac Date: Thu, 28 Sep 2023 16:01:33 -0700 Subject: [PATCH 2/2] Update publish-pypi.yml bump python version to 3.9 --- .github/workflows/publish-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index fe8fffc42..330bfe7d3 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -19,7 +19,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.9 - name: Build dist files run: | python -m pip install --upgrade pip